From fcfa32e65efa104579437522c7c2bb0b29be8ea0 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:15:38 +1000 Subject: [PATCH 01/14] docs: design for the PlayerNotifications adapter Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- .../specs/2026-08-22-pn-adapter-design.md | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-22-pn-adapter-design.md diff --git a/docs/superpowers/specs/2026-08-22-pn-adapter-design.md b/docs/superpowers/specs/2026-08-22-pn-adapter-design.md new file mode 100644 index 0000000..757cc0e --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-pn-adapter-design.md @@ -0,0 +1,205 @@ +# PlayerNotifications adapter for Realty + +Date: 2026-08-22 + +## Goal + +Deliver Realty notifications through the PlayerNotifications (PN) plugin, so players get +per-category preferences, sink fan-out, offline delivery and an inbox — instead of the +all-or-nothing online-chat delivery `chat-adapter` provides. + +## 1. Event change: carry the message key + +`RealtyNotificationEvent` (realty-paper-api) gains `@NotNull String messageKey`, placed +second: + +```java +public RealtyNotificationEvent(@NotNull List targets, + @NotNull String messageKey, + @NotNull Component message, + @Nullable WorldGuardRegion region) +``` + +`messageKey` is the `messages.yml` path the fire site rendered from, e.g. +`"notification.outbid"`. It is an identity for routing/filtering, never rendered. +Consumers MUST tolerate unknown keys. + +- Validated non-blank in the constructor, alongside the existing empty-targets check. +- Stays a plain `String`: `MessageKeys` lives in `realty-paper`, the event in + `realty-paper-api`. No dependency inversion; third-party fire sites may use own keys. +- Position 2 (not trailing) so every un-migrated call site fails to compile. + +### Fire-site migration — 33 sites, 9 files + +Mechanical: the constant is already the first argument to the adjacent `messageFor` call. + +```java +events.fireSync(new RealtyNotificationEvent(List.of(bidderId), + MessageKeys.NOTIFICATION_OUTBID, + this.messages.messageFor(MessageKeys.NOTIFICATION_OUTBID, ...), + region)); +``` + +| File | sites | +|---|---| +| `listener/RegionNotificationListener.java` | 15 | +| `command/OfferCommandGroup.java` | 6 | +| `Realty.java` (expiry sweeps, ~L402-441) | 4 | +| `command/AuctionCommandGroup.java` | 3 | +| `command/AgentInvite{Accept,Reject,Withdraw}Command.java`, `AgentInviteCommand.java`, `AgentRemoveCommand.java` | 1 each | + +Repeating the constant is deliberate; reworking `MessageContainer` to return a +(key, component) pair would touch far more than these 33 sites for a cosmetic gain. + +Existing consumers `ChatNotificationListener` and `EssentialsMailListener` ignore the new +field — no behaviour change. Their tests and `RealtyNotificationEventTest` need the new +argument threaded through. + +## 2. The adapter module + +New subproject `realty-paper-adapters/pn-adapter`, added to `settings.gradle.kts`, +mirroring `essentials-adapter`: `java-library` + `realty-conventions` + shadow, everything +`compileOnly` (`realty-paper`, `realty-paper-api`, paper-api, annotations, +`plugin-infrastructure`) plus: + +```kotlin +compileOnly("io.github.md5sha256:player-notifications-api:1.0.0") +``` + +Available from the `maven.democracycraft.net` repos Realty already declares — no +build-script repo changes. + +`module-manifest.yml`: `module-name: pn-adapter`, `reloadable: true`. + +`PlayerNotificationsAdapterModule extends SimplePluginModule`, initialize order — +**all fallible work before `registerListener`**, because if anything after it throws, +`ModuleLifecycleManager` closes the class loader without calling `shutdown()`, leaving a +live listener on a dead class loader: + +1. Look up `NotificationService` from Bukkit's services manager (PN registers it in + `PlayerNotificationsPlugin#onEnable`); throw `IllegalStateException` if PN is absent or + disabled. +2. Load the key -> dataType mapping from the module's `dataFolder`. +3. Register payload types, renderers, categories. +4. `registerListener(...)`. + +`shutdown` unregisters the listener and all five dataTypes (see the trap in §3). + +`PlayerNotifications` goes in Realty's `paper-plugin.yml` as a **softdepend with +`join-classpath: true`** — exactly how EssentialsX is handled. Realty must not hard-depend +on a plugin optional for most servers; the module fails loudly at initialize instead. + +## 3. Payload, renderer, categories + +Registries are plain `HashMap.put` (`NotificationDataTypeRegistry`), so re-registration is +idempotent and `reloadable: true` is safe. + +**One payload class, five dataTypes.** The registry keys serializers/renderers by payload +*class* and `payloadMapping` by dataType. Sharing one class across our dataTypes means +sharing handlers — which is exactly what we want, since only the routing label differs. + +```java +public record RealtyNotificationPayload( + @NotNull String messageKey, // provenance + title source + @NotNull String body, // the Component, GSON-serialized + @Nullable String regionId, // null is routine: refund after region deletion + @Nullable String worldId) { } +``` + +`body` uses `GsonComponentSerializer`, not MiniMessage: the event hands us a built +`Component` and a MiniMessage round-trip is lossy for programmatically-built components. +Region identity is carried as strings because the payload is persisted and outlives the +region. + +`NotificationRenderer` returns `RenderableNotification(title, +body)` with the body deserialized verbatim. Titles come from module config, keyed by +category with a per-messageKey override. Titles MUST NOT rely on click events — non- +Minecraft sinks flatten to plain text. + +Registered via `registerJsonRenderable` (never `registerJsonPayload`: an explicit processor +wins dispatch precedence and bypasses preferences and sinks entirely), each claimed under a +matching category in `categoryRegistry`: + +| dataType | covers | +|---|---| +| `realty.auction` | outbid, auction won / ended-no-bids / cancelled, bid payment expiry | +| `realty.offer` | offer placed / accepted / rejected / withdrawn, offer payment expiry | +| `realty.lease` | leasehold expiry & termination, modification proposals & resolutions, rented / unrented | +| `realty.agent` | the five agent-invite and agent-removal notifications | +| `realty.general` | region bought, ownership transferred, **and the fallback for unmapped keys** | + +The mapping ships as `categories.yml` in the module data folder, defaulted to the table +above. An unrecognised key routes to `realty.general` with a `FINE` log — never dropped. + +### The shutdown trap + +All five dataTypes share one payload class, so `unregisterPayloadMapping("realty.auction")` +cascades into `unregisterSerializer`/`unregisterRenderer` on the shared class, silently +breaking the other four. `shutdown` therefore unregisters **all five**; doing it partially +is what corrupts state. This is a footgun in the PN API and must be commented as such. + +### Enqueue + +One `TypedNotification` per event: + +- `notifKey` — a fresh `UUID` +- `notifScheduledTime` — `Instant.now()` +- `notifExpiryTime` — from config, default 30 days +- `notifTarget` — the event's target list verbatim +- `notifPriority` — from config, per category +- `overwriteAllowed` — `false`; Realty notifications are distinct events, never updates + +## 4. De-bundling chat-adapter + +The `chat-adapter` subproject is unchanged — it keeps building, testing and shadow-jarring. + +1. **`realty-paper/build.gradle.kts`** — remove + `dependsOn(":realty-paper-adapters:chat-adapter:shadowJar")` and the + `from(...) { into("modules") }` block from `shadowJar` (~L161-166). +2. **`Realty.startModules()`** — remove the `BundledModuleExtractor.extract(...)` call and + its `IOException` handler. `BundledModuleExtractor` then has no callers: delete it. + - Keep both warnings, reworded from failed-extraction recovery advice to a plain + statement that no delivery module is installed and where to put one. + - The "no modules at all" warning names `pn-adapter.jar` alongside `chat-adapter.jar`. + - The chat-adapter-specific warning stays quiet when `pn-adapter` is loaded, so a + PN-only server does not warn at every startup about a deliberate choice. +3. **`runServer`** — keep staging `chat-adapter.jar` (a dev convenience independent of + shipping; removing it breaks local smoke tests) and add `pn-adapter.jar` to the same + `doFirst` staging. PN itself is not downloaded by `runServer`, so `pn-adapter` will fail + there with the clear "PlayerNotifications is not installed" error. That is correct. + +**Upgrade behaviour:** an existing server keeps its previously-extracted +`chat-adapter.jar`; nothing deletes it, so behaviour is unchanged across the upgrade and +the jar is now under operator control. No cleanup pass (explicit decision). + +**New-install behaviour change:** a fresh install delivers nothing until the operator +installs a module. Covered by the startup warning, which stays at `WARNING`. + +`README.md` and `CLAUDE.md` both currently state chat-adapter is bundled and extracted on +first enable; both must change to "published, install it yourself". + +## 5. Testing + +Follow the existing adapter test shape: constructor-injected functional interfaces, plain +JUnit 5, no MockBukkit, no Bukkit runtime. + +- **`RealtyNotificationEventTest`** — thread `messageKey` through existing cases; add + blank/null key rejection, matching the existing empty-targets test. +- **`RealtyNotificationPayloadTest`** — `GsonComponentSerializer` round-trip: a coloured + component, a programmatically-built one with hover/click, one with null region/world. + This is the executable form of the "MiniMessage would be lossy" decision. +- **`NotificationCategoryMapperTest`** — the mapper is extracted as a standalone class + precisely so this needs no PN: each category resolves from a representative key; an + unmapped key falls back to `realty.general`; a config override beats the default. +- **`PlayerNotificationsListenerTest`** — recording fake service: one event enqueues + exactly one `TypedNotification`; targets carried verbatim incl. multi-target; + `overwriteAllowed` is false; two events from the same key get different `notifKey`s; a + null region yields null `regionId`/`worldId` without throwing. +- **Registration lifecycle** — against a real `NotificationDataTypeRegistry` (plain + concrete class, no Bukkit): registering then unregistering all five leaves it clean, and + the partial-unregister hazard is asserted so the footgun is documented executably. +- **Existing adapter tests** — thread the new constructor argument through; no behavioural + assertions change. + +**Not automated** (manual `runServer` checklist): the services-manager lookup, module load +ordering against PN's `onEnable`, and the de-bundling. From cba8587db51d03fbc3b91a5c3c82880cf0ac7d9b Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:24:00 +1000 Subject: [PATCH 02/14] feat: deliver notifications through PlayerNotifications Adds a pn-adapter module that routes Realty notifications into the PlayerNotifications plugin, giving players per-category preferences, sink fan-out, offline delivery and an inbox. RealtyNotificationEvent gains a messageKey field carrying the messages.yml path the fire site rendered from. The key was already in scope at all 33 fire sites as the first argument to messageFor(); the event was simply discarding it. It is an identity for routing, never rendered, and consumers must tolerate unknown keys. The adapter maps those keys to five PN dataTypes (realty.auction, .offer, .lease, .agent, .general) via a configurable categories.yml, falling back to realty.general so an unmapped key is never dropped. All five share one payload class, which makes PN's registry cascade a partial unregister into the shared serializer and renderer; shutdown therefore unregisters all five, and the hazard is asserted in RegistrationLifecycleTest. chat-adapter is no longer bundled in the plugin jar or extracted on first enable -- operators install delivery modules themselves. Existing servers keep the jar they already have, so behaviour is unchanged on upgrade; new installs deliver nothing until a module is installed, which the startup warning now states plainly. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- README.md | 8 + .../chat/ChatNotificationListenerTest.java | 8 +- .../EssentialsMailListenerTest.java | 10 +- .../pn-adapter/build.gradle.kts | 18 ++ .../pn/NotificationCategoryMapper.java | 93 ++++++++++ .../adapter/pn/NotificationEnqueuer.java | 18 ++ .../pn/PlayerNotificationsAdapterModule.java | 164 ++++++++++++++++++ .../pn/PlayerNotificationsListener.java | 87 ++++++++++ .../realty/adapter/pn/RealtyDataTypes.java | 94 ++++++++++ .../adapter/pn/RealtyNotificationPayload.java | 67 +++++++ .../pn/RealtyNotificationRenderer.java | 40 +++++ .../src/main/resources/categories.yml | 73 ++++++++ .../src/main/resources/module-manifest.yml | 5 + .../pn/NotificationCategoryMapperTest.java | 75 ++++++++ .../pn/PlayerNotificationsListenerTest.java | 137 +++++++++++++++ .../pn/RealtyNotificationPayloadTest.java | 62 +++++++ .../adapter/pn/RegistrationLifecycleTest.java | 140 +++++++++++++++ .../api/event/RealtyNotificationEvent.java | 40 ++++- .../event/RealtyNotificationEventTest.java | 26 ++- realty-paper/build.gradle.kts | 15 +- .../realty/BundledModuleExtractor.java | 36 ---- .../io/github/md5sha256/realty/Realty.java | 28 +-- .../command/AgentInviteAcceptCommand.java | 1 + .../realty/command/AgentInviteCommand.java | 1 + .../command/AgentInviteRejectCommand.java | 1 + .../command/AgentInviteWithdrawCommand.java | 1 + .../realty/command/AgentRemoveCommand.java | 1 + .../realty/command/AuctionCommandGroup.java | 3 + .../realty/command/OfferCommandGroup.java | 6 + .../listener/RegionNotificationListener.java | 15 ++ .../src/main/resources/paper-plugin.yml | 4 + .../realty/BundledModuleExtractionTest.java | 43 ----- settings.gradle.kts | 1 + 33 files changed, 1201 insertions(+), 120 deletions(-) create mode 100644 realty-paper-adapters/pn-adapter/build.gradle.kts create mode 100644 realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapper.java create mode 100644 realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationEnqueuer.java create mode 100644 realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsAdapterModule.java create mode 100644 realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListener.java create mode 100644 realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyDataTypes.java create mode 100644 realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayload.java create mode 100644 realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationRenderer.java create mode 100644 realty-paper-adapters/pn-adapter/src/main/resources/categories.yml create mode 100644 realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml create mode 100644 realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapperTest.java create mode 100644 realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListenerTest.java create mode 100644 realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayloadTest.java create mode 100644 realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RegistrationLifecycleTest.java delete mode 100644 realty-paper/src/main/java/io/github/md5sha256/realty/BundledModuleExtractor.java delete mode 100644 realty-paper/src/test/java/io/github/md5sha256/realty/BundledModuleExtractionTest.java diff --git a/README.md b/README.md index b9f6b5a..9d182bc 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,14 @@ immutable: republishing a version that already exists fails with a 409, so bump | `realty-paper` | Main Paper plugin | | `realty-paper-plan-extension` | Optional [Plan](https://github.com/plan-player-analytics/Plan) integration | | `realty-areashop-importer` | Optional AreaShop migration helper | +| `realty-paper-adapters/chat-adapter` | Notification delivery to online players via chat | +| `realty-paper-adapters/essentials-adapter` | Notification delivery via EssentialsX mail | +| `realty-paper-adapters/pn-adapter` | Notification delivery via [PlayerNotifications](https://github.com/MCCitiesNetwork/player-notifications) | + +The adapter modules are **not bundled in the plugin jar**. Each is published as its own jar; install +the ones you want by placing them in `plugins/Realty/modules` and restarting the server. Realty +delivers no notifications until at least one delivery module is installed, and logs a warning at +startup while none is. ## Documentation 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 index 01e7da4..170f6d1 100644 --- 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 @@ -14,6 +14,8 @@ class ChatNotificationListenerTest { + private static final String KEY = "notification.offer.rejected"; + @Test void sendsToOnlineTargets() { UUID online = UUID.randomUUID(); @@ -23,7 +25,7 @@ void sendsToOnlineTargets() { ChatNotificationListener listener = new ChatNotificationListener(players::get); RealtyNotificationEvent event = - new RealtyNotificationEvent(List.of(online), Component.text("rejected"), null); + new RealtyNotificationEvent(List.of(online), KEY, Component.text("rejected"), null); listener.onNotification(event); @@ -34,7 +36,7 @@ void sendsToOnlineTargets() { void offlineTargetIsSkippedWithoutThrowing() { ChatNotificationListener listener = new ChatNotificationListener(uuid -> null); RealtyNotificationEvent event = new RealtyNotificationEvent( - List.of(UUID.randomUUID()), Component.text("rejected"), null); + List.of(UUID.randomUUID()), KEY, Component.text("rejected"), null); Assertions.assertDoesNotThrow(() -> listener.onNotification(event)); } @@ -51,7 +53,7 @@ void multiTargetEventFansOutOncePerOnlineTarget() { ChatNotificationListener listener = new ChatNotificationListener(players::get); RealtyNotificationEvent event = new RealtyNotificationEvent( - List.of(first, second, offline), Component.text("rejected"), null); + List.of(first, second, offline), KEY, Component.text("rejected"), null); listener.onNotification(event); 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 index 1ecf368..a3f67f9 100644 --- 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 @@ -15,6 +15,8 @@ class EssentialsMailListenerTest { + private static final String KEY = "notification.offer.rejected"; + @Test void mailsOfflineTargets() { UUID offline = UUID.randomUUID(); @@ -24,7 +26,7 @@ void mailsOfflineTargets() { (uuid, text) -> sent.add(Map.entry(uuid, text)), uuid -> false); RealtyNotificationEvent event = new RealtyNotificationEvent( - List.of(offline), Component.text("rejected"), null); + List.of(offline), KEY, Component.text("rejected"), null); listener.onNotification(event); @@ -41,7 +43,7 @@ void onlineTargetIsNotMailed() { (uuid, text) -> sent.add(Map.entry(uuid, text)), uuid -> true); RealtyNotificationEvent event = new RealtyNotificationEvent( - List.of(UUID.randomUUID()), Component.text("rejected"), null); + List.of(UUID.randomUUID()), KEY, Component.text("rejected"), null); listener.onNotification(event); @@ -56,7 +58,7 @@ void messageIsSerializedToLegacySection() { (uuid, text) -> sent.add(Map.entry(uuid, text)), uuid -> false); RealtyNotificationEvent event = new RealtyNotificationEvent( - List.of(UUID.randomUUID()), Component.text("sold", NamedTextColor.RED), null); + List.of(UUID.randomUUID()), KEY, Component.text("sold", NamedTextColor.RED), null); listener.onNotification(event); @@ -78,7 +80,7 @@ void aFailingSendDoesNotStopRemainingTargets() { }, uuid -> false); RealtyNotificationEvent event = new RealtyNotificationEvent( - List.of(first, second), Component.text("rejected"), null); + List.of(first, second), KEY, Component.text("rejected"), null); listener.onNotification(event); diff --git a/realty-paper-adapters/pn-adapter/build.gradle.kts b/realty-paper-adapters/pn-adapter/build.gradle.kts new file mode 100644 index 0000000..16b2431 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/build.gradle.kts @@ -0,0 +1,18 @@ +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:26.1.2.build.74-stable") + compileOnly("org.jetbrains:annotations:26.0.2-1") + compileOnly("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") + compileOnly("io.github.md5sha256:player-notifications-api:1.0.0-SNAPSHOT") + + testImplementation(project(":realty-paper-api")) + testImplementation("io.papermc.paper:paper-api:26.1.2.build.74-stable") + testImplementation("io.github.md5sha256:player-notifications-api:1.0.0-SNAPSHOT") +} diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapper.java b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapper.java new file mode 100644 index 0000000..f8ebbc9 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapper.java @@ -0,0 +1,93 @@ +package io.github.md5sha256.realty.adapter.pn; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Resolves a Realty message key to the PlayerNotifications {@code dataType} it is enqueued under, + * and to the title and priority that data type is rendered with. + * + *

Deliberately a plain class with no PlayerNotifications and no Bukkit types on it: the routing + * decision is the part worth testing, and keeping it free of both means it can be tested without a + * server or a live PN install.

+ * + *

An unrecognised key resolves to {@link #FALLBACK_DATA_TYPE} rather than throwing or dropping. + * Realty adds message keys over time and third-party fire sites may use keys of their own; a + * notification the mapper has never seen is still a notification a player should receive.

+ */ +public final class NotificationCategoryMapper { + + /** The data type an unmapped message key routes to. */ + public static final String FALLBACK_DATA_TYPE = "realty.general"; + + /** Every data type this adapter registers, in a stable order. */ + public static final List DATA_TYPES = List.of( + "realty.auction", + "realty.offer", + "realty.lease", + "realty.agent", + "realty.general"); + + private static final String DEFAULT_TITLE = "Realty"; + + private final Map keyToDataType; + private final Map dataTypeTitles; + private final Map titleOverrides; + private final Map priorities; + + /** + * @param keyToDataType message key to data type; unlisted keys fall back + * @param dataTypeTitles data type to display title + * @param titleOverrides message key to display title, beating {@code dataTypeTitles} + * @param priorities data type to delivery priority; unlisted data types get 0 + */ + public NotificationCategoryMapper(@NotNull Map keyToDataType, + @NotNull Map dataTypeTitles, + @NotNull Map titleOverrides, + @NotNull Map priorities) { + this.keyToDataType = Map.copyOf(Objects.requireNonNull(keyToDataType, "keyToDataType")); + this.dataTypeTitles = Map.copyOf(Objects.requireNonNull(dataTypeTitles, "dataTypeTitles")); + this.titleOverrides = Map.copyOf(Objects.requireNonNull(titleOverrides, "titleOverrides")); + this.priorities = Map.copyOf(Objects.requireNonNull(priorities, "priorities")); + } + + /** + * The data type the given message key routes to, or {@link #FALLBACK_DATA_TYPE} if the key is + * not mapped. + */ + public @NotNull String dataTypeFor(@NotNull String messageKey) { + Objects.requireNonNull(messageKey, "messageKey"); + return this.keyToDataType.getOrDefault(messageKey, FALLBACK_DATA_TYPE); + } + + /** + * Whether the given message key is explicitly mapped. Callers use this to log the fallback, + * because {@link #dataTypeFor} cannot distinguish an unmapped key from one deliberately mapped + * to {@link #FALLBACK_DATA_TYPE}. + */ + public boolean isMapped(@NotNull String messageKey) { + return this.keyToDataType.containsKey(Objects.requireNonNull(messageKey, "messageKey")); + } + + /** + * The title to render for the given message key: its own override if it has one, otherwise the + * title of its data type, otherwise a plain default. + */ + public @NotNull String titleFor(@NotNull String messageKey) { + String override = this.titleOverrides.get(Objects.requireNonNull(messageKey, "messageKey")); + if (override != null) { + return override; + } + return this.dataTypeTitles.getOrDefault(dataTypeFor(messageKey), DEFAULT_TITLE); + } + + /** + * The delivery priority for the given message key's data type; 0 when unconfigured. + */ + public int priorityFor(@NotNull String messageKey) { + return this.priorities.getOrDefault(dataTypeFor(messageKey), 0); + } +} diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationEnqueuer.java b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationEnqueuer.java new file mode 100644 index 0000000..b88ae89 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationEnqueuer.java @@ -0,0 +1,18 @@ +package io.github.md5sha256.realty.adapter.pn; + +import io.github.md5sha256.playernotifications.api.TypedNotification; +import org.jetbrains.annotations.NotNull; + +/** + * The single operation {@link PlayerNotificationsListener} needs from PlayerNotifications' + * {@code NotificationService}. + * + *

Narrowing the ~20-method service down to this one call is what lets the listener be tested + * with a three-line recording fake instead of a mock server.

+ */ +@FunctionalInterface +public interface NotificationEnqueuer { + + void enqueue(@NotNull TypedNotification notification, + boolean overwriteAllowed); +} diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsAdapterModule.java new file mode 100644 index 0000000..35e1a4e --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsAdapterModule.java @@ -0,0 +1,164 @@ +package io.github.md5sha256.realty.adapter.pn; + +import com.minecraftcitiesnetwork.pluginInfrastructure.modules.SimplePluginModule; +import io.github.md5sha256.playernotifications.api.NotificationService; +import io.github.md5sha256.realty.Realty; +import org.bukkit.Bukkit; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Delivers Realty notifications through the PlayerNotifications plugin, so players get per-category + * preferences, sink fan-out, offline delivery and an inbox instead of the all-or-nothing online + * chat delivery {@code chat-adapter} provides. + */ +public final class PlayerNotificationsAdapterModule extends SimplePluginModule { + + private static final String CATEGORIES_FILE = "categories.yml"; + private static final int DEFAULT_EXPIRY_DAYS = 30; + + private @Nullable NotificationService service; + + /** + * {@inheritDoc} + * + *

Order matters: every fallible step happens before {@code registerListener}. If + * anything thrown after the listener is registered escapes this method, + * {@code ModuleLifecycleManager} closes the module's class loader without calling + * {@link #shutdown}, so the listener is never unregistered and stays live on a dead class + * loader — every subsequent notification then dies in a {@code NoClassDefFoundError} inside + * Bukkit's event dispatch. Registering the listener last makes the failure path clean: nothing + * is live, so nothing needs unwinding.

+ * + *

As on {@code EssentialsAdapterModule}, the failure is signalled unchecked: + * {@code SimplePluginModule.initialize} declares no checked exception and an override may only + * narrow a throws clause. The lifecycle manager catches + * {@code ModuleInitializationException | RuntimeException} identically.

+ * + * @throws IllegalStateException if PlayerNotifications is absent, disabled, or has not + * registered its service + */ + @Override + public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { + super.initialize(plugin, dataFolder); + + // 1. PlayerNotifications registers NotificationService in its own onEnable; a null here + // means PN is missing, disabled, or started after us. + Plugin pnPlugin = Bukkit.getPluginManager().getPlugin("PlayerNotifications"); + if (pnPlugin == null || !pnPlugin.isEnabled()) { + throw new IllegalStateException( + "PlayerNotifications is not installed or not enabled — pn-adapter cannot start"); + } + NotificationService notificationService = + Bukkit.getServicesManager().load(NotificationService.class); + if (notificationService == null) { + throw new IllegalStateException( + "PlayerNotifications is enabled but registered no NotificationService — " + + "pn-adapter cannot start"); + } + + // 2. Load the message-key -> dataType mapping from the module's data folder. + YamlConfiguration config = loadCategoriesConfig(dataFolder); + NotificationCategoryMapper categoryMapper = readMapper(config); + Duration expiry = Duration.ofDays(config.getLong("expiry-days", DEFAULT_EXPIRY_DAYS)); + + // 3. Register payload types, renderers and categories. + RealtyDataTypes.registerAll(notificationService, new RealtyNotificationRenderer(categoryMapper)); + this.service = notificationService; + + // 4. Only now, with nothing left that can throw, does a live listener appear. + registerListener(new PlayerNotificationsListener( + notificationService::enqueueNotification, + categoryMapper, + expiry, + plugin.getLogger())); + } + + @Override + public void shutdown(@NotNull Realty plugin) { + unregisterListeners(); + NotificationService notificationService = this.service; + if (notificationService != null) { + // All five, never a subset — see RealtyDataTypes for why a partial unregister silently + // corrupts the registry for the data types left behind. + RealtyDataTypes.unregisterAll(notificationService.dataTypeRegistry()); + RealtyDataTypes.unclaimAll(notificationService.categoryRegistry()); + this.service = null; + } + super.shutdown(plugin); + } + + /** + * Reads {@code categories.yml} from the module's data folder, writing the bundled default there + * first if the operator has none. + */ + private static @NotNull YamlConfiguration loadCategoriesConfig(@NotNull Path dataFolder) { + Path file = dataFolder.resolve(CATEGORIES_FILE); + try { + if (!Files.exists(file)) { + Files.createDirectories(dataFolder); + try (InputStream defaults = PlayerNotificationsAdapterModule.class + .getClassLoader() + .getResourceAsStream(CATEGORIES_FILE)) { + if (defaults == null) { + throw new IllegalStateException( + "pn-adapter jar is missing its bundled " + CATEGORIES_FILE); + } + Files.copy(defaults, file, StandardCopyOption.REPLACE_EXISTING); + } + } + return YamlConfiguration.loadConfiguration(Files.newBufferedReader(file)); + } catch (IOException ex) { + throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); + } + } + + /** + * Builds the mapper from a loaded {@code categories.yml}. + */ + static @NotNull NotificationCategoryMapper readMapper(@NotNull YamlConfiguration config) { + Objects.requireNonNull(config, "config"); + return new NotificationCategoryMapper( + readStrings(config.getConfigurationSection("categories")), + readStrings(config.getConfigurationSection("titles")), + readStrings(config.getConfigurationSection("title-overrides")), + readInts(config.getConfigurationSection("priorities"))); + } + + private static @NotNull Map readStrings(@Nullable ConfigurationSection section) { + Map values = new HashMap<>(); + if (section != null) { + for (String key : section.getKeys(false)) { + String value = section.getString(key); + if (value != null && !value.isBlank()) { + values.put(key, value); + } + } + } + return values; + } + + private static @NotNull Map readInts(@Nullable ConfigurationSection section) { + Map values = new HashMap<>(); + if (section != null) { + for (String key : section.getKeys(false)) { + values.put(key, section.getInt(key, 0)); + } + } + return values; + } +} diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListener.java b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListener.java new file mode 100644 index 0000000..2138c53 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListener.java @@ -0,0 +1,87 @@ +package io.github.md5sha256.realty.adapter.pn; + +import io.github.md5sha256.playernotifications.api.NotificationTarget; +import io.github.md5sha256.playernotifications.api.TypedNotification; +import io.github.md5sha256.realty.api.WorldGuardRegion; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; +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.time.Duration; +import java.time.Instant; +import java.util.Objects; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Turns each Realty notification into exactly one PlayerNotifications notification, routed to the + * data type its message key maps to. + * + *

Unlike the chat adapter, nothing here checks whether a target is online: handing the + * notification to PN is the whole job, and PN decides per recipient which sinks it reaches and + * when — including delivering to a player who is offline right now.

+ * + *

{@link RealtyNotificationEvent} is fired synchronously, so this handler already runs on the + * main thread.

+ */ +public final class PlayerNotificationsListener implements Listener { + + private final NotificationEnqueuer enqueuer; + private final NotificationCategoryMapper categoryMapper; + private final Duration expiry; + private final Logger logger; + + /** + * @param enqueuer hands the built notification to PlayerNotifications + * @param categoryMapper resolves data type and priority from the event's message key + * @param expiry how long an enqueued notification survives before PN expires it + * @param logger used only for the FINE unmapped-key trace + */ + public PlayerNotificationsListener(@NotNull NotificationEnqueuer enqueuer, + @NotNull NotificationCategoryMapper categoryMapper, + @NotNull Duration expiry, + @NotNull Logger logger) { + this.enqueuer = Objects.requireNonNull(enqueuer, "enqueuer"); + this.categoryMapper = Objects.requireNonNull(categoryMapper, "categoryMapper"); + this.expiry = Objects.requireNonNull(expiry, "expiry"); + this.logger = Objects.requireNonNull(logger, "logger"); + } + + @EventHandler(priority = EventPriority.NORMAL) + public void onNotification(@NotNull RealtyNotificationEvent event) { + String messageKey = event.getMessageKey(); + String dataType = this.categoryMapper.dataTypeFor(messageKey); + if (!this.categoryMapper.isMapped(messageKey)) { + // Never dropped: an unknown key is far more likely to be a Realty key newer than this + // module's categories.yml than a mistake, and a player still wants to be told. + this.logger.log(Level.FINE, + "Unmapped Realty message key {0}; routing to {1}", + new Object[]{messageKey, dataType}); + } + + @Nullable WorldGuardRegion region = event.getRegion(); + @Nullable String regionId = region == null ? null : region.region().getId(); + @Nullable String worldId = region == null ? null : region.world().getUID().toString(); + + RealtyNotificationPayload payload = RealtyNotificationPayload.of( + messageKey, event.getMessage(), regionId, worldId); + + TypedNotification notification = new TypedNotification<>( + UUID.randomUUID().toString(), + Instant.now(), + Instant.now().plus(this.expiry), + new NotificationTarget(event.getTargets()), + dataType, + payload, + this.categoryMapper.priorityFor(messageKey)); + + // overwriteAllowed is false: every Realty notification is a distinct event — a second + // outbid is a second thing that happened, not a correction of the first — so none of them + // may replace an earlier one in the player's inbox. + this.enqueuer.enqueue(notification, false); + } +} diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyDataTypes.java b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyDataTypes.java new file mode 100644 index 0000000..0628669 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyDataTypes.java @@ -0,0 +1,94 @@ +package io.github.md5sha256.realty.adapter.pn; + +import io.github.md5sha256.playernotifications.api.NotificationDataTypeRegistry; +import io.github.md5sha256.playernotifications.api.NotificationService; +import io.github.md5sha256.playernotifications.api.category.NotificationCategoryRegistry; +import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; +import org.jetbrains.annotations.NotNull; + +import java.util.Map; + +/** + * Registers and unregisters Realty's five PlayerNotifications data types. + * + *

The shutdown footgun. All five data types share one payload class, + * {@link RealtyNotificationPayload}. {@code NotificationDataTypeRegistry} keys serializers and + * renderers by payload class but the payload mapping by data type string, so + * {@code unregisterPayloadMapping("realty.auction")} does not just drop that one mapping — it + * cascades into {@code unregisterSerializer}/{@code unregisterRenderer} on the shared class, + * silently leaving the other four data types mapped but with no serializer and no renderer. Every + * notification they carry then fails at enqueue or render time.

+ * + *

Unregistering all five is therefore not a tidiness preference, it is the only correct + * sequence: partially unregistering is what corrupts the registry. {@link + * #unregisterAll(NotificationDataTypeRegistry)} exists so no call site can get that wrong, and + * {@code RegistrationLifecycleTest} asserts the hazard so it stays documented executably. This is + * a sharp edge in the PlayerNotifications API, not in this module.

+ */ +public final class RealtyDataTypes { + + /** Category labels, keyed by data type — shown in PN's preference dialogs. */ + private static final Map LABELS = Map.of( + "realty.auction", "Realty auctions", + "realty.offer", "Realty offers", + "realty.lease", "Realty leases", + "realty.agent", "Realty agents", + "realty.general", "Realty"); + + private static final Map DESCRIPTIONS = Map.of( + "realty.auction", "Bids, auction outcomes and bid payment deadlines", + "realty.offer", "Offers on your regions and offer payment deadlines", + "realty.lease", "Rent, lease expiry, terminations and modification proposals", + "realty.agent", "Agent invitations and removals", + "realty.general", "Purchases, ownership transfers and anything uncategorised"); + + private RealtyDataTypes() { + } + + /** + * Binds every Realty data type to {@link RealtyNotificationPayload} and claims it under a + * matching category. + * + *

Uses {@code registerJsonRenderable}, never {@code registerJsonPayload}: an explicit + * processor wins dispatch precedence and bypasses preferences and sinks entirely, which would + * defeat the whole reason for delivering through PN.

+ * + *

Re-registration is idempotent — the underlying registries are plain map puts — which is + * what makes this module safe to declare {@code reloadable: true}.

+ */ + public static void registerAll(@NotNull NotificationService service, + @NotNull NotificationRenderer renderer) { + NotificationCategoryRegistry categories = service.categoryRegistry(); + for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + service.registerJsonRenderable(dataType, RealtyNotificationPayload.class, renderer); + categories.registerCategory(dataType, + LABELS.getOrDefault(dataType, dataType), + DESCRIPTIONS.getOrDefault(dataType, "")); + categories.claimDataType(dataType, dataType); + } + } + + /** + * Unregisters all five data types. See the class javadoc: doing this partially + * corrupts the registry for the data types left behind. + */ + public static void unregisterAll(@NotNull NotificationDataTypeRegistry registry) { + for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + registry.unregisterPayloadMapping(dataType); + } + // The cascade above already removed the shared serializer and renderer, but say so + // explicitly: if a future data type were ever given its own payload class, the loop alone + // would no longer be enough, and these two calls are harmless no-ops today. + registry.unregisterSerializer(RealtyNotificationPayload.class); + registry.unregisterRenderer(RealtyNotificationPayload.class); + } + + /** + * Releases each category's claim on its data type. + */ + public static void unclaimAll(@NotNull NotificationCategoryRegistry categories) { + for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + categories.unclaimDataType(dataType, dataType); + } + } +} diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayload.java b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayload.java new file mode 100644 index 0000000..59f5829 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayload.java @@ -0,0 +1,67 @@ +package io.github.md5sha256.realty.adapter.pn; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +/** + * The persisted form of a Realty notification inside PlayerNotifications. + * + *

One payload class serves all of Realty's data types ({@code realty.auction}, + * {@code realty.offer}, {@code realty.lease}, {@code realty.agent}, {@code realty.general}) — + * only the routing label differs, so sharing the serializer and renderer across them is exactly + * the intent. See {@link PlayerNotificationsAdapterModule} for the shutdown consequence of that + * sharing.

+ * + *

{@link #body} is a GSON-serialized {@link Component}, not MiniMessage. The event hands the + * adapter an already-built {@code Component}; a MiniMessage round-trip is lossy for components + * assembled programmatically (hover and click events, insertions, custom fonts), whereas the GSON + * form is the same tree the server itself sends over the wire.

+ * + *

Region identity is carried as plain strings rather than as a live region handle: the payload + * is persisted and routinely outlives the region it describes — a refund is announced after the + * region has already been deleted — so both {@code regionId} and {@code worldId} being null is + * routine, not a defect.

+ * + * @param messageKey the {@code messages.yml} path the notification was rendered from; provenance + * and the source of the rendered title + * @param body the rendered message, serialized with {@link GsonComponentSerializer} + * @param regionId the WorldGuard region id, or null when the notification names no region + * @param worldId the region's world UUID as a string, or null alongside a null {@code regionId} + */ +public record RealtyNotificationPayload(@NotNull String messageKey, + @NotNull String body, + @Nullable String regionId, + @Nullable String worldId) { + + public RealtyNotificationPayload { + Objects.requireNonNull(messageKey, "messageKey"); + Objects.requireNonNull(body, "body"); + if (messageKey.isBlank()) { + throw new IllegalArgumentException("messageKey must not be blank"); + } + } + + /** + * Builds a payload from a rendered component, serializing it to the GSON form. + */ + public static @NotNull RealtyNotificationPayload of(@NotNull String messageKey, + @NotNull Component message, + @Nullable String regionId, + @Nullable String worldId) { + return new RealtyNotificationPayload(messageKey, + GsonComponentSerializer.gson().serialize(message), + regionId, + worldId); + } + + /** + * The message as a {@link Component} again, deserialized verbatim from {@link #body}. + */ + public @NotNull Component bodyComponent() { + return GsonComponentSerializer.gson().deserialize(this.body); + } +} diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationRenderer.java b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationRenderer.java new file mode 100644 index 0000000..70ee2da --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationRenderer.java @@ -0,0 +1,40 @@ +package io.github.md5sha256.realty.adapter.pn; + +import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; +import io.github.md5sha256.playernotifications.api.render.RenderableNotification; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; + +import java.util.Objects; +import java.util.UUID; + +/** + * Renders a stored {@link RealtyNotificationPayload} back into the medium-neutral title/body form + * PlayerNotifications fans out to whichever sinks the recipient prefers. + * + *

The body is the payload's component deserialized verbatim — Realty already rendered the text + * at the fire site, so there is nothing left to decide here. The title comes from module config + * via {@link NotificationCategoryMapper}, keyed by data type with a per-message-key override.

+ * + *

Rendering ignores the target: Realty's messages are already per-target (several targets means + * several people get the same text), so there is nothing to personalise.

+ * + *

Neither title nor body may depend on click events to be understood. Sinks that are not + * Minecraft clients — Essentials mail, Discord — flatten components to plain text and are free to + * drop interaction entirely.

+ */ +public final class RealtyNotificationRenderer implements NotificationRenderer { + + private final NotificationCategoryMapper categoryMapper; + + public RealtyNotificationRenderer(@NotNull NotificationCategoryMapper categoryMapper) { + this.categoryMapper = Objects.requireNonNull(categoryMapper, "categoryMapper"); + } + + @Override + public @NotNull RenderableNotification render(@NotNull RealtyNotificationPayload payload, + @NotNull UUID target) { + Component title = Component.text(this.categoryMapper.titleFor(payload.messageKey())); + return new RenderableNotification(title, payload.bodyComponent()); + } +} diff --git a/realty-paper-adapters/pn-adapter/src/main/resources/categories.yml b/realty-paper-adapters/pn-adapter/src/main/resources/categories.yml new file mode 100644 index 0000000..a7cfc88 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/resources/categories.yml @@ -0,0 +1,73 @@ +# Maps a Realty message key (the messages.yml path the notification was rendered from) to the +# PlayerNotifications dataType it is enqueued under. The dataType is what PN groups by in its +# preference dialogs, so this is how an operator decides how finely players may opt in and out. +# +# A key that is absent here routes to realty.general and is logged at FINE — never dropped. +# Adding a key that is not one of Realty's own is harmless; it simply never matches. +categories: + # realty.agent — agent invites and removals + notification.agent-invited: realty.agent + notification.agent-invite-accepted: realty.agent + notification.agent-invite-rejected: realty.agent + notification.agent-invite-withdrawn: realty.agent + notification.agent-removed: realty.agent + + # realty.auction — bidding, auction outcomes, bid payment expiry + notification.outbid: realty.auction + notification.auction-cancelled: realty.auction + notification.auction-won: realty.auction + notification.auction-ended-no-bids: realty.auction + notification.bid-payment-expired: realty.auction + + # realty.offer — offers and offer payment expiry + notification.offer-placed: realty.offer + notification.offer-accepted: realty.offer + notification.offer-rejected: realty.offer + notification.offer-withdrawn: realty.offer + notification.offer-payment-expired: realty.offer + + # realty.lease — leasehold lifecycle, modification proposals, terminations + notification.region-rented: realty.lease + notification.region-unrented: realty.lease + notification.leasehold-expired: realty.lease + notification.leasehold-expired-landlord: realty.lease + notification.modify-proposed-landlord: realty.lease + notification.modify-proposed-tenant: realty.lease + notification.modify-accepted: realty.lease + notification.modify-rejected: realty.lease + notification.modify-withdrawn: realty.lease + notification.termination-scheduled-tenant: realty.lease + notification.termination-scheduled-landlord: realty.lease + notification.termination-cancelled: realty.lease + notification.leasehold-terminated-tenant: realty.lease + notification.leasehold-terminated-landlord: realty.lease + + # realty.general — freehold sales, and the fallback for anything unmapped + notification.region-bought: realty.general + notification.ownership-transferred: realty.general + +# Display titles, keyed by dataType, with an optional per-message-key override below. +# Titles are plain text: non-Minecraft sinks flatten components, so they must not depend on +# click or hover events to make sense. +titles: + realty.agent: "Realty — Agents" + realty.auction: "Realty — Auction" + realty.offer: "Realty — Offer" + realty.lease: "Realty — Lease" + realty.general: "Realty" + +# Per-message-key title overrides. Beats the dataType title above. +title-overrides: + notification.auction-won: "Realty — Auction won" + notification.outbid: "Realty — You were outbid" + +# Per-dataType delivery priority. Higher sorts first in the inbox. +priorities: + realty.agent: 0 + realty.auction: 1 + realty.offer: 1 + realty.lease: 1 + realty.general: 0 + +# How long an enqueued notification stays in the inbox before PN expires it. +expiry-days: 30 diff --git a/realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml b/realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml new file mode 100644 index 0000000..fa8dcd6 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml @@ -0,0 +1,5 @@ +module-name: pn-adapter +entry-class: io.github.md5sha256.realty.adapter.pn.PlayerNotificationsAdapterModule +author: md5sha256 +expected-plugin-class: io.github.md5sha256.realty.Realty +reloadable: true diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapperTest.java b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapperTest.java new file mode 100644 index 0000000..4f52293 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapperTest.java @@ -0,0 +1,75 @@ +package io.github.md5sha256.realty.adapter.pn; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +class NotificationCategoryMapperTest { + + private static NotificationCategoryMapper defaults() { + return new NotificationCategoryMapper( + Map.of("notification.outbid", "realty.auction", + "notification.offer-placed", "realty.offer", + "notification.leasehold-expired", "realty.lease", + "notification.agent-invited", "realty.agent", + "notification.region-bought", "realty.general"), + Map.of("realty.auction", "Realty — Auction", + "realty.general", "Realty"), + Map.of(), + Map.of("realty.auction", 1)); + } + + @Test + void eachCategoryResolvesFromARepresentativeKey() { + NotificationCategoryMapper mapper = defaults(); + + Assertions.assertEquals("realty.auction", mapper.dataTypeFor("notification.outbid")); + Assertions.assertEquals("realty.offer", mapper.dataTypeFor("notification.offer-placed")); + Assertions.assertEquals("realty.lease", mapper.dataTypeFor("notification.leasehold-expired")); + Assertions.assertEquals("realty.agent", mapper.dataTypeFor("notification.agent-invited")); + Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.region-bought")); + } + + @Test + void anUnmappedKeyFallsBackToGeneral() { + NotificationCategoryMapper mapper = defaults(); + + Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.some-future-key")); + Assertions.assertFalse(mapper.isMapped("notification.some-future-key")); + Assertions.assertTrue(mapper.isMapped("notification.region-bought")); + } + + @Test + void aConfigOverrideBeatsTheDefault() { + NotificationCategoryMapper mapper = new NotificationCategoryMapper( + Map.of("notification.outbid", "realty.general"), + Map.of("realty.auction", "Realty — Auction", "realty.general", "Realty"), + Map.of(), + Map.of()); + + Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.outbid")); + } + + @Test + void aTitleOverrideBeatsTheDataTypeTitle() { + NotificationCategoryMapper mapper = new NotificationCategoryMapper( + Map.of("notification.outbid", "realty.auction", + "notification.auction-won", "realty.auction"), + Map.of("realty.auction", "Realty — Auction"), + Map.of("notification.auction-won", "Realty — Auction won"), + Map.of()); + + Assertions.assertEquals("Realty — Auction won", mapper.titleFor("notification.auction-won")); + Assertions.assertEquals("Realty — Auction", mapper.titleFor("notification.outbid")); + } + + @Test + void anUnconfiguredTitleAndPriorityFallBack() { + NotificationCategoryMapper mapper = defaults(); + + Assertions.assertEquals("Realty", mapper.titleFor("notification.some-future-key")); + Assertions.assertEquals(1, mapper.priorityFor("notification.outbid")); + Assertions.assertEquals(0, mapper.priorityFor("notification.offer-placed")); + } +} diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListenerTest.java b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListenerTest.java new file mode 100644 index 0000000..da88baa --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListenerTest.java @@ -0,0 +1,137 @@ +package io.github.md5sha256.realty.adapter.pn; + +import io.github.md5sha256.playernotifications.api.TypedNotification; +import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; +import net.kyori.adventure.text.Component; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Logger; + +class PlayerNotificationsListenerTest { + + private static final NotificationCategoryMapper MAPPER = new NotificationCategoryMapper( + Map.of("notification.outbid", "realty.auction", + "notification.region-bought", "realty.general"), + Map.of("realty.auction", "Realty — Auction"), + Map.of(), + Map.of("realty.auction", 3)); + + private static PlayerNotificationsListener listener( + List> enqueued, + List overwriteFlags) { + return new PlayerNotificationsListener( + (notification, overwriteAllowed) -> { + enqueued.add(notification); + overwriteFlags.add(overwriteAllowed); + }, + MAPPER, + Duration.ofDays(30), + Logger.getLogger(PlayerNotificationsListenerTest.class.getName())); + } + + @Test + void oneEventEnqueuesExactlyOneNotification() { + List> enqueued = new ArrayList<>(); + List overwriteFlags = new ArrayList<>(); + UUID target = UUID.randomUUID(); + + listener(enqueued, overwriteFlags).onNotification(new RealtyNotificationEvent( + List.of(target), "notification.outbid", Component.text("outbid"), null)); + + Assertions.assertEquals(1, enqueued.size()); + TypedNotification notification = enqueued.get(0); + Assertions.assertEquals("realty.auction", notification.notifPayloadType()); + Assertions.assertEquals(3, notification.notifPriority()); + Assertions.assertEquals("notification.outbid", notification.notifPayload().messageKey()); + } + + @Test + void targetsAreCarriedVerbatimIncludingMultipleTargets() { + List> enqueued = new ArrayList<>(); + List overwriteFlags = new ArrayList<>(); + UUID first = UUID.randomUUID(); + UUID second = UUID.randomUUID(); + + listener(enqueued, overwriteFlags).onNotification(new RealtyNotificationEvent( + List.of(first, second), "notification.outbid", Component.text("outbid"), null)); + + Assertions.assertEquals(List.of(first, second), + enqueued.get(0).notifTarget().playerUUIDs()); + } + + @Test + void overwriteIsNeverAllowed() { + List> enqueued = new ArrayList<>(); + List overwriteFlags = new ArrayList<>(); + + listener(enqueued, overwriteFlags).onNotification(new RealtyNotificationEvent( + List.of(UUID.randomUUID()), "notification.outbid", Component.text("outbid"), null)); + + Assertions.assertEquals(List.of(Boolean.FALSE), overwriteFlags); + } + + @Test + void twoEventsFromTheSameKeyGetDifferentNotificationKeys() { + List> enqueued = new ArrayList<>(); + List overwriteFlags = new ArrayList<>(); + PlayerNotificationsListener listener = listener(enqueued, overwriteFlags); + UUID target = UUID.randomUUID(); + + listener.onNotification(new RealtyNotificationEvent( + List.of(target), "notification.outbid", Component.text("outbid"), null)); + listener.onNotification(new RealtyNotificationEvent( + List.of(target), "notification.outbid", Component.text("outbid again"), null)); + + Assertions.assertNotEquals(enqueued.get(0).notifKey(), enqueued.get(1).notifKey()); + } + + @Test + void aNullRegionYieldsNullRegionAndWorldIds() { + List> enqueued = new ArrayList<>(); + List overwriteFlags = new ArrayList<>(); + + listener(enqueued, overwriteFlags).onNotification(new RealtyNotificationEvent( + List.of(UUID.randomUUID()), + "notification.bid-payment-expired", + Component.text("refunded"), + null)); + + RealtyNotificationPayload payload = enqueued.get(0).notifPayload(); + Assertions.assertNull(payload.regionId()); + Assertions.assertNull(payload.worldId()); + } + + @Test + void anUnmappedKeyStillEnqueuesUnderGeneral() { + List> enqueued = new ArrayList<>(); + List overwriteFlags = new ArrayList<>(); + + listener(enqueued, overwriteFlags).onNotification(new RealtyNotificationEvent( + List.of(UUID.randomUUID()), + "notification.some-future-key", + Component.text("something happened"), + null)); + + Assertions.assertEquals(1, enqueued.size()); + Assertions.assertEquals("realty.general", enqueued.get(0).notifPayloadType()); + } + + @Test + void theRenderedMessageSurvivesIntoThePayload() { + List> enqueued = new ArrayList<>(); + List overwriteFlags = new ArrayList<>(); + Component message = Component.text("You were outbid on plot1"); + + listener(enqueued, overwriteFlags).onNotification(new RealtyNotificationEvent( + List.of(UUID.randomUUID()), "notification.outbid", message, null)); + + Assertions.assertEquals(message.compact(), + enqueued.get(0).notifPayload().bodyComponent().compact()); + } +} diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayloadTest.java b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayloadTest.java new file mode 100644 index 0000000..805095c --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayloadTest.java @@ -0,0 +1,62 @@ +package io.github.md5sha256.realty.adapter.pn; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +class RealtyNotificationPayloadTest { + + @Test + void aColouredComponentSurvivesTheRoundTrip() { + Component message = Component.text("You were outbid", NamedTextColor.RED); + + RealtyNotificationPayload payload = + RealtyNotificationPayload.of("notification.outbid", message, "plot1", "world-uuid"); + + Assertions.assertEquals(message.compact(), payload.bodyComponent().compact()); + } + + @Test + void aProgrammaticallyBuiltComponentKeepsItsHoverAndClick() { + Component message = Component.text("Region ") + .append(Component.text("plot1", NamedTextColor.GOLD) + .decorate(TextDecoration.UNDERLINED) + .hoverEvent(HoverEvent.showText(Component.text("Click to view"))) + .clickEvent(ClickEvent.runCommand("/realty info plot1"))) + .append(Component.text(" was sold.")); + + RealtyNotificationPayload payload = + RealtyNotificationPayload.of("notification.region-bought", message, "plot1", "world-uuid"); + Component restored = payload.bodyComponent(); + + // This is the executable form of the "MiniMessage would be lossy" decision: the GSON form + // preserves the whole tree, hover and click events included. + Assertions.assertEquals(message.compact(), restored.compact()); + Component regionPart = restored.children().get(0); + Assertions.assertEquals(ClickEvent.runCommand("/realty info plot1"), regionPart.clickEvent()); + Assertions.assertNotNull(regionPart.hoverEvent()); + Assertions.assertEquals(NamedTextColor.GOLD, regionPart.color()); + } + + @Test + void aNullRegionAndWorldAreCarriedAsNull() { + Component message = Component.text("Your bid was refunded."); + + RealtyNotificationPayload payload = + RealtyNotificationPayload.of("notification.bid-payment-expired", message, null, null); + + Assertions.assertNull(payload.regionId()); + Assertions.assertNull(payload.worldId()); + Assertions.assertEquals(message.compact(), payload.bodyComponent().compact()); + } + + @Test + void aBlankMessageKeyIsRejected() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new RealtyNotificationPayload(" ", "{}", null, null)); + } +} diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RegistrationLifecycleTest.java b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RegistrationLifecycleTest.java new file mode 100644 index 0000000..92dc053 --- /dev/null +++ b/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RegistrationLifecycleTest.java @@ -0,0 +1,140 @@ +package io.github.md5sha256.realty.adapter.pn; + +import io.github.md5sha256.playernotifications.api.NotificationDataTypeRegistry; +import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; +import io.github.md5sha256.playernotifications.api.render.RenderableNotification; +import io.github.md5sha256.playernotifications.api.serialize.PayloadSerializer; +import net.kyori.adventure.text.Component; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.Map; +import java.util.UUID; + +/** + * Exercises registration and unregistration against a real {@link NotificationDataTypeRegistry} — + * it is a plain concrete class with no Bukkit dependency, so no server is needed. + */ +class RegistrationLifecycleTest { + + private static final NotificationRenderer RENDERER = + new RealtyNotificationRenderer(new NotificationCategoryMapper( + Map.of(), Map.of(), Map.of(), Map.of())); + + /** + * Stands in for the reflective JSON serializer {@code registerJsonRenderable} installs; only its + * presence or absence in the registry is under test. + */ + private static final PayloadSerializer SERIALIZER = + new PayloadSerializer<>() { + @Override + public @NotNull String serialize(@NotNull RealtyNotificationPayload payload) { + return payload.body(); + } + + @Override + public @NotNull RealtyNotificationPayload deserialize(@NotNull String json) { + return new RealtyNotificationPayload("notification.outbid", json, null, null); + } + }; + + /** + * Mirrors what {@code NotificationService.registerJsonRenderable} does to the registry: bind the + * data type to the payload class, and register a serializer and renderer for that class. + */ + private static NotificationDataTypeRegistry registerAllFive() { + NotificationDataTypeRegistry registry = new NotificationDataTypeRegistry(); + for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + registry.registerPayloadMapping(dataType, RealtyNotificationPayload.class); + registry.registerSerializer(RealtyNotificationPayload.class, SERIALIZER); + registry.registerRenderer(RealtyNotificationPayload.class, RENDERER); + } + return registry; + } + + @Test + void allFiveDataTypesRegister() { + NotificationDataTypeRegistry registry = registerAllFive(); + + for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + Assertions.assertTrue(registry.dataTypes().contains(dataType), dataType); + Assertions.assertTrue(registry.getSerializer(dataType).isPresent(), dataType); + Assertions.assertTrue(registry.getRenderer(dataType).isPresent(), dataType); + } + Assertions.assertEquals(5, registry.dataTypes().size()); + } + + @Test + void unregisteringAllFiveLeavesTheRegistryClean() { + NotificationDataTypeRegistry registry = registerAllFive(); + + RealtyDataTypes.unregisterAll(registry); + + Assertions.assertEquals(Map.of().keySet(), registry.dataTypes()); + Assertions.assertTrue(registry.getSerializer(RealtyNotificationPayload.class).isEmpty()); + Assertions.assertTrue(registry.getRenderer(RealtyNotificationPayload.class).isEmpty()); + } + + @Test + void unregisteringAllFiveIsIdempotent() { + NotificationDataTypeRegistry registry = registerAllFive(); + + RealtyDataTypes.unregisterAll(registry); + RealtyDataTypes.unregisterAll(registry); + + Assertions.assertTrue(registry.dataTypes().isEmpty()); + } + + @Test + void reRegisteringOverAnExistingRegistrationIsIdempotent() { + NotificationDataTypeRegistry registry = registerAllFive(); + + for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + registry.registerPayloadMapping(dataType, RealtyNotificationPayload.class); + } + + // Plain map puts, which is what makes `reloadable: true` safe for this module. + Assertions.assertEquals(5, registry.dataTypes().size()); + Assertions.assertTrue(registry.getRenderer("realty.auction").isPresent()); + } + + /** + * Documents the PlayerNotifications footgun executably: all five data types share one payload + * class, and the registry keys serializers and renderers by class while keying the + * payload mapping by data type. Dropping one data type therefore rips the shared serializer and + * renderer out from under the other four, which stay mapped but can no longer be serialized or + * rendered. This is exactly why {@link RealtyDataTypes#unregisterAll} unregisters all five. + */ + @Test + void aPartialUnregisterSilentlyBreaksTheOtherFourDataTypes() { + NotificationDataTypeRegistry registry = registerAllFive(); + + registry.unregisterPayloadMapping("realty.auction"); + + Assertions.assertFalse(registry.dataTypes().contains("realty.auction")); + Assertions.assertEquals(4, registry.dataTypes().size()); + for (String survivor : NotificationCategoryMapper.DATA_TYPES) { + if (survivor.equals("realty.auction")) { + continue; + } + Assertions.assertTrue(registry.dataTypes().contains(survivor), survivor); + Assertions.assertTrue(registry.getSerializer(survivor).isEmpty(), + survivor + " lost its serializer to the shared-class cascade"); + Assertions.assertTrue(registry.getRenderer(survivor).isEmpty(), + survivor + " lost its renderer to the shared-class cascade"); + } + } + + @Test + void theRendererProducesATitleAndTheVerbatimBody() { + Component message = Component.text("You were outbid"); + RealtyNotificationPayload payload = + RealtyNotificationPayload.of("notification.outbid", message, null, null); + + RenderableNotification rendered = RENDERER.render(payload, UUID.randomUUID()); + + Assertions.assertEquals(message.compact(), rendered.body().compact()); + Assertions.assertEquals(Component.text("Realty"), rendered.title()); + } +} 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 c029739..d5ff3b9 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 @@ -20,29 +20,47 @@ * *

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

+ * + *

Alongside the rendered message the event carries a {@linkplain #getMessageKey() message key}: + * the {@code messages.yml} path the fire site rendered from, e.g. {@code "notification.outbid"}. + * It is an identity for routing, filtering and categorisation only, and must never be rendered to + * a player — the rendered text is {@link #getMessage()}. Consumers must tolerate unknown keys: + * third-party fire sites may use keys of their own, and Realty may add new ones at any time, so a + * key a consumer does not recognise has to fall back to sane default handling rather than being + * dropped or treated as an error.

*/ public final class RealtyNotificationEvent extends Event { private static final HandlerList HANDLERS = new HandlerList(); private final List targets; + private final String messageKey; 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 + * @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 messageKey the {@code messages.yml} path the fire site rendered {@code message} from, + * e.g. {@code "notification.outbid"}; never blank. This is an identity used + * for routing, filtering and categorisation and must never be rendered to a + * player. Consumers must tolerate keys they do not recognise. + * @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 String messageKey, @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.messageKey = Objects.requireNonNull(messageKey, "messageKey"); + if (this.messageKey.isBlank()) { + throw new IllegalArgumentException("A notification needs a non-blank message key"); + } this.message = Objects.requireNonNull(message, "message"); this.region = region; } @@ -51,6 +69,18 @@ public RealtyNotificationEvent(@NotNull List targets, return this.targets; } + /** + * The {@code messages.yml} path the fire site rendered {@link #getMessage()} from, e.g. + * {@code "notification.outbid"}. + * + *

An identity for routing, filtering and categorisation — never render it to a player. + * Consumers must tolerate unknown keys and fall back to default handling rather than dropping + * the notification.

+ */ + public @NotNull String getMessageKey() { + return this.messageKey; + } + public @NotNull Component getMessage() { return this.message; } 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 index cd88af3..ed3b91a 100644 --- 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 @@ -11,14 +11,16 @@ class RealtyNotificationEventTest { private static final Component MESSAGE = Component.text("rendered"); + private static final String KEY = "notification.outbid"; @Test void exposesTargetsAndMessage() { UUID target = UUID.randomUUID(); RealtyNotificationEvent event = - new RealtyNotificationEvent(List.of(target), MESSAGE, null); + new RealtyNotificationEvent(List.of(target), KEY, MESSAGE, null); Assertions.assertEquals(List.of(target), event.getTargets()); + Assertions.assertEquals(KEY, event.getMessageKey()); Assertions.assertEquals(MESSAGE, event.getMessage()); Assertions.assertNull(event.getRegion()); } @@ -28,7 +30,7 @@ void targetsAreDefensivelyCopiedAndImmutable() { List mutable = new ArrayList<>(); mutable.add(UUID.randomUUID()); RealtyNotificationEvent event = - new RealtyNotificationEvent(mutable, MESSAGE, null); + new RealtyNotificationEvent(mutable, KEY, MESSAGE, null); mutable.add(UUID.randomUUID()); @@ -40,21 +42,31 @@ void targetsAreDefensivelyCopiedAndImmutable() { @Test void rejectsEmptyTargets() { Assertions.assertThrows(IllegalArgumentException.class, - () -> new RealtyNotificationEvent(List.of(), MESSAGE, null)); + () -> new RealtyNotificationEvent(List.of(), KEY, MESSAGE, null)); + } + + @Test + void rejectsBlankMessageKey() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new RealtyNotificationEvent(List.of(UUID.randomUUID()), "", MESSAGE, null)); + Assertions.assertThrows(IllegalArgumentException.class, + () -> new RealtyNotificationEvent(List.of(UUID.randomUUID()), " ", MESSAGE, null)); } @Test void rejectsNulls() { Assertions.assertThrows(NullPointerException.class, - () -> new RealtyNotificationEvent(null, MESSAGE, null)); + () -> new RealtyNotificationEvent(null, KEY, MESSAGE, null)); + Assertions.assertThrows(NullPointerException.class, + () -> new RealtyNotificationEvent(List.of(UUID.randomUUID()), null, MESSAGE, null)); Assertions.assertThrows(NullPointerException.class, - () -> new RealtyNotificationEvent(List.of(UUID.randomUUID()), null, null)); + () -> new RealtyNotificationEvent(List.of(UUID.randomUUID()), KEY, null, null)); } @Test void isSynchronous() { RealtyNotificationEvent event = - new RealtyNotificationEvent(List.of(UUID.randomUUID()), MESSAGE, null); + new RealtyNotificationEvent(List.of(UUID.randomUUID()), KEY, MESSAGE, null); Assertions.assertFalse(event.isAsynchronous()); } @@ -62,7 +74,7 @@ void isSynchronous() { @Test void handlerListIsShared() { RealtyNotificationEvent event = - new RealtyNotificationEvent(List.of(UUID.randomUUID()), MESSAGE, null); + new RealtyNotificationEvent(List.of(UUID.randomUUID()), KEY, MESSAGE, null); Assertions.assertSame(RealtyNotificationEvent.getHandlerList(), event.getHandlers()); } diff --git a/realty-paper/build.gradle.kts b/realty-paper/build.gradle.kts index 8f12c2d..04c4340 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -157,13 +157,6 @@ 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 { @@ -185,6 +178,11 @@ tasks { // the spec's Essentials smoke test cannot be run as written. val essentialsAdapterJar = project(":realty-paper-adapters:essentials-adapter") .tasks.named("shadowJar", AbstractArchiveTask::class).flatMap { it.archiveFile } + // PlayerNotifications is not downloaded by runServer, so pn-adapter will fail to + // initialize there with its "PlayerNotifications is not installed" error. Staging it + // anyway keeps the jar fresh for a server that does have PN dropped in by hand. + val pnAdapterJar = project(":realty-paper-adapters:pn-adapter") + .tasks.named("shadowJar", AbstractArchiveTask::class).flatMap { it.archiveFile } // Plan is downloaded below, so stage the extension that pairs with it. Staging it // here rather than leaving a hand-copied jar in run/plugins is what stops it going // stale: the copy that lived there was built before RealtyApi became RealtyBackend @@ -195,12 +193,13 @@ tasks { val pluginsDir = layout.projectDirectory.dir("run/plugins").asFile // The archiveFile providers carry their producing task as a dependency, so the // explicit dependsOn declarations they replace are no longer needed. - inputs.files(chatAdapterJar, essentialsAdapterJar, planExtensionJar) + inputs.files(chatAdapterJar, essentialsAdapterJar, pnAdapterJar, planExtensionJar) doFirst { moduleDir.mkdirs() chatAdapterJar.get().asFile.copyTo(moduleDir.resolve("chat-adapter.jar"), overwrite = true) essentialsAdapterJar.get().asFile .copyTo(moduleDir.resolve("essentials-adapter.jar"), overwrite = true) + pnAdapterJar.get().asFile.copyTo(moduleDir.resolve("pn-adapter.jar"), overwrite = true) // Fixed filename, so a rebuild replaces the jar instead of leaving the previous // version behind as a second, duplicate plugin. pluginsDir.mkdirs() 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 deleted file mode 100644 index b2002ab..0000000 --- a/realty-paper/src/main/java/io/github/md5sha256/realty/BundledModuleExtractor.java +++ /dev/null @@ -1,36 +0,0 @@ -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 e6db6dd..db243cb 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 @@ -401,12 +401,14 @@ private void scheduleTasks() { if (auction.winnerId() != null) { this.eventDispatch.fireSync(new RealtyNotificationEvent( List.of(auction.winnerId()), + MessageKeys.NOTIFICATION_AUCTION_WON, this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_WON, Placeholder.unparsed("region", auction.worldGuardRegionId())), wgRegion)); } else { this.eventDispatch.fireSync(new RealtyNotificationEvent( List.of(auction.auctioneerId()), + MessageKeys.NOTIFICATION_AUCTION_ENDED_NO_BIDS, this.messageContainer.messageFor(MessageKeys.NOTIFICATION_AUCTION_ENDED_NO_BIDS, Placeholder.unparsed("region", auction.worldGuardRegionId())), wgRegion)); @@ -425,6 +427,7 @@ private void scheduleTasks() { WorldGuardRegion wgRegion = resolveRegion(payment.worldId(), payment.regionId()); this.eventDispatch.fireSync(new RealtyNotificationEvent( List.of(payment.bidderId()), + MessageKeys.NOTIFICATION_BID_PAYMENT_EXPIRED, this.messageContainer.messageFor(MessageKeys.NOTIFICATION_BID_PAYMENT_EXPIRED, Placeholder.unparsed("region", payment.regionId()), Placeholder.unparsed("amount", @@ -440,6 +443,7 @@ private void scheduleTasks() { WorldGuardRegion wgRegion = resolveRegion(payment.worldId(), payment.regionId()); this.eventDispatch.fireSync(new RealtyNotificationEvent( List.of(payment.offererId()), + MessageKeys.NOTIFICATION_OFFER_PAYMENT_EXPIRED, this.messageContainer.messageFor(MessageKeys.NOTIFICATION_OFFER_PAYMENT_EXPIRED, Placeholder.unparsed("region", payment.regionId()), Placeholder.unparsed("amount", @@ -691,23 +695,19 @@ 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(); if (this.moduleManager.getActiveModules().isEmpty()) { - getLogger().warning("No notification delivery module is loaded. Realty fires notification " + getLogger().warning("No notification delivery module is installed. 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."); + + "auction, etc.) will reach nobody. Install a delivery module by placing " + + "chat-adapter.jar or pn-adapter.jar in " + moduleDir + "."); + } else if (!this.moduleManager.getActiveModules().containsKey("chat-adapter") + && !this.moduleManager.getActiveModules().containsKey("pn-adapter")) { + // pn-adapter is a deliberate alternative to chat delivery, so stay quiet when it + // is installed rather than nagging a PN-only server at every startup. + getLogger().warning("The chat-adapter module is not installed. Online players will not " + + "receive chat notifications. Install it by placing chat-adapter.jar in " + + moduleDir + "."); } if (getServer().getPluginManager().isPluginEnabled("Essentials") && !this.moduleManager.getActiveModules().containsKey("essentials-adapter")) { 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 7616340..c4e13ba 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 @@ -64,6 +64,7 @@ private void execute(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_ACCEPT_SUCCESS, Placeholder.unparsed("region", regionId))); events.fireSync(new RealtyNotificationEvent(List.of(inviterId), + MessageKeys.NOTIFICATION_AGENT_INVITE_ACCEPTED, messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITE_ACCEPTED, Placeholder.unparsed("player", player.getName()), Placeholder.unparsed("region", regionId)), region)); 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 e2a90c4..5d9ccbb 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 @@ -79,6 +79,7 @@ private void execute(@NotNull CommandContext ctx) { Placeholder.unparsed("player", inviteeName), Placeholder.unparsed("region", regionId))); events.fireSync(new RealtyNotificationEvent(List.of(inviteeId), + MessageKeys.NOTIFICATION_AGENT_INVITED, messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITED, Placeholder.unparsed("player", player.getName()), Placeholder.unparsed("region", regionId)), region)); 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 edc14ef..a38a617 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 @@ -65,6 +65,7 @@ private void execute(@NotNull CommandContext ctx) { sender.sendMessage(messages.messageFor(MessageKeys.AGENT_INVITE_REJECT_SUCCESS, Placeholder.unparsed("region", regionId))); events.fireSync(new RealtyNotificationEvent(List.of(inviterId), + MessageKeys.NOTIFICATION_AGENT_INVITE_REJECTED, messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITE_REJECTED, Placeholder.unparsed("player", player.getName()), Placeholder.unparsed("region", regionId)), region)); 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 24216d2..18e9b0a 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 @@ -75,6 +75,7 @@ private void execute(@NotNull CommandContext ctx) { Placeholder.unparsed("player", inviteeName), Placeholder.unparsed("region", regionId))); events.fireSync(new RealtyNotificationEvent(List.of(inviteeId), + MessageKeys.NOTIFICATION_AGENT_INVITE_WITHDRAWN, messages.messageFor(MessageKeys.NOTIFICATION_AGENT_INVITE_WITHDRAWN, Placeholder.unparsed("player", resolveName(player.getUniqueId())), Placeholder.unparsed("region", regionId)), region)); 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 6a9144a..e7249c5 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 @@ -73,6 +73,7 @@ private void execute(@NotNull CommandContext ctx) { Placeholder.unparsed("player", targetName), Placeholder.unparsed("region", regionId))); events.fireSync(new RealtyNotificationEvent(List.of(targetId), + MessageKeys.NOTIFICATION_AGENT_REMOVED, messages.messageFor(MessageKeys.NOTIFICATION_AGENT_REMOVED, Placeholder.unparsed("player", player.getName()), Placeholder.unparsed("region", regionId)), region)); 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 1bb6a0f..172e2a6 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 @@ -231,6 +231,7 @@ private void executeCancel(@NotNull CommandContext ctx) { Placeholder.unparsed("region", regionId))); for (UUID bidderId : result.bidderIds()) { events.fireSync(new RealtyNotificationEvent(List.of(bidderId), + MessageKeys.NOTIFICATION_AUCTION_CANCELLED, messages.messageFor(MessageKeys.NOTIFICATION_AUCTION_CANCELLED, Placeholder.unparsed("region", regionId)), region)); } @@ -272,6 +273,7 @@ private void executeBid(@NotNull CommandContext ctx) { Placeholder.unparsed("region", regionId))); if (success.previousBidderId() != null) { events.fireSync(new RealtyNotificationEvent(List.of(success.previousBidderId()), + MessageKeys.NOTIFICATION_OUTBID, messages.messageFor(MessageKeys.NOTIFICATION_OUTBID, Placeholder.unparsed("region", regionId), Placeholder.unparsed("amount", CurrencyFormatter.format(bidAmount))), region)); @@ -326,6 +328,7 @@ private void executePayBid(@NotNull CommandContext ctx) { Placeholder.unparsed("region", fullyPaid.regionId()))); if (fullyPaid.previousTitleHolderId() != null) { events.fireSync(new RealtyNotificationEvent(List.of(fullyPaid.previousTitleHolderId()), + MessageKeys.NOTIFICATION_OWNERSHIP_TRANSFERRED, messages.messageFor(MessageKeys.NOTIFICATION_OWNERSHIP_TRANSFERRED, Placeholder.unparsed("player", sender.getName()), Placeholder.unparsed("region", fullyPaid.regionId())), region)); 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 f2d7b2a..de37229 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 @@ -150,6 +150,7 @@ private void executeSend(@NotNull CommandContext ctx) { Placeholder.unparsed("region", regionId))); if (success.titleHolderId() != null) { events.fireSync(new RealtyNotificationEvent(List.of(success.titleHolderId()), + MessageKeys.NOTIFICATION_OFFER_PLACED, messages.messageFor(MessageKeys.NOTIFICATION_OFFER_PLACED, Placeholder.unparsed("player", sender.getName()), Placeholder.unparsed("price", CurrencyFormatter.format(price)), @@ -304,6 +305,7 @@ private void executeAccept(@NotNull CommandContext ctx) { Placeholder.unparsed("player", playerName), Placeholder.unparsed("region", regionId))); events.fireSync(new RealtyNotificationEvent(List.of(target.getUniqueId()), + MessageKeys.NOTIFICATION_OFFER_ACCEPTED, messages.messageFor(MessageKeys.NOTIFICATION_OFFER_ACCEPTED, Placeholder.unparsed("region", regionId)), region)); events.fireSync(new OfferAcceptedEvent(region, sender.getUniqueId(), @@ -361,6 +363,7 @@ private void executePay(@NotNull CommandContext ctx) { Placeholder.unparsed("region", fullyPaid.regionId()))); if (fullyPaid.previousTitleHolderId() != null) { events.fireSync(new RealtyNotificationEvent(List.of(fullyPaid.previousTitleHolderId()), + MessageKeys.NOTIFICATION_OWNERSHIP_TRANSFERRED, messages.messageFor(MessageKeys.NOTIFICATION_OWNERSHIP_TRANSFERRED, Placeholder.unparsed("player", sender.getName()), Placeholder.unparsed("region", fullyPaid.regionId())), region)); @@ -413,6 +416,7 @@ private void executeWithdraw(@NotNull CommandContext ctx) { Placeholder.unparsed("region", regionId))); if (titleHolderId != null) { events.fireSync(new RealtyNotificationEvent(List.of(titleHolderId), + MessageKeys.NOTIFICATION_OFFER_WITHDRAWN, messages.messageFor(MessageKeys.NOTIFICATION_OFFER_WITHDRAWN, Placeholder.unparsed("player", sender.getName()), Placeholder.unparsed("region", regionId)), region)); @@ -463,6 +467,7 @@ private void executeReject(@NotNull CommandContext ctx) { Placeholder.unparsed("player", playerName), Placeholder.unparsed("region", regionId))); events.fireSync(new RealtyNotificationEvent(List.of(target.getUniqueId()), + MessageKeys.NOTIFICATION_OFFER_REJECTED, messages.messageFor(MessageKeys.NOTIFICATION_OFFER_REJECTED, Placeholder.unparsed("region", regionId)), region)); events.fireSync(new OfferRejectedEvent(region, sender.getUniqueId(), @@ -509,6 +514,7 @@ private void executeRejectAll(@NotNull CommandContext ctx) { Placeholder.unparsed("region", regionId))); if (!success.offererIds().isEmpty()) { events.fireSync(new RealtyNotificationEvent(List.copyOf(success.offererIds()), + MessageKeys.NOTIFICATION_OFFER_REJECTED, messages.messageFor(MessageKeys.NOTIFICATION_OFFER_REJECTED, Placeholder.unparsed("region", regionId)), region)); } 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 c660387..a7fd99c 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 @@ -53,6 +53,7 @@ public void onRegionBought(@NotNull RegionBoughtEvent event) { return; } this.events.fireSync(new RealtyNotificationEvent(List.of(seller), + MessageKeys.NOTIFICATION_REGION_BOUGHT, this.messages.messageFor(MessageKeys.NOTIFICATION_REGION_BOUGHT, Placeholder.unparsed("player", resolveName(event.getBuyerId())), Placeholder.unparsed("price", CurrencyFormatter.format(event.getPrice())), @@ -63,6 +64,7 @@ public void onRegionBought(@NotNull RegionBoughtEvent event) { @EventHandler public void onRegionRented(@NotNull RegionRentedEvent event) { this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), + MessageKeys.NOTIFICATION_REGION_RENTED, this.messages.messageFor(MessageKeys.NOTIFICATION_REGION_RENTED, Placeholder.unparsed("player", resolveName(event.getTenantId())), Placeholder.unparsed("price", CurrencyFormatter.format(event.getPrice())), @@ -73,6 +75,7 @@ public void onRegionRented(@NotNull RegionRentedEvent event) { @EventHandler public void onRegionUnrented(@NotNull RegionUnrentedEvent event) { this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), + MessageKeys.NOTIFICATION_REGION_UNRENTED, this.messages.messageFor(MessageKeys.NOTIFICATION_REGION_UNRENTED, Placeholder.unparsed("player", resolveName(event.getTenantId())), Placeholder.unparsed("region", event.getRegionId()), @@ -83,10 +86,12 @@ public void onRegionUnrented(@NotNull RegionUnrentedEvent event) { @EventHandler public void onLeaseExpired(@NotNull LeaseExpiredEvent event) { this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), + MessageKeys.NOTIFICATION_LEASEHOLD_EXPIRED, this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_EXPIRED, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), + MessageKeys.NOTIFICATION_LEASEHOLD_EXPIRED_LANDLORD, this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_EXPIRED_LANDLORD, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); @@ -97,12 +102,14 @@ public void onModificationProposed(@NotNull LeaseModificationProposedEvent event if (LeaseholdRoles.LANDLORD.equals(event.getProposerRole())) { // Landlord proposed: notify the tenant, who decides by renewing or not. this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), + MessageKeys.NOTIFICATION_MODIFY_PROPOSED_LANDLORD, this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_PROPOSED_LANDLORD, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); } else { // Tenant proposed: notify the landlord, who must accept or reject. this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), + MessageKeys.NOTIFICATION_MODIFY_PROPOSED_TENANT, this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_PROPOSED_TENANT, Placeholder.unparsed("player", resolveName(event.getProposerId())), Placeholder.unparsed("region", event.getRegionId())), @@ -114,10 +121,12 @@ public void onModificationProposed(@NotNull LeaseModificationProposedEvent event public void onModificationResolved(@NotNull LeaseModificationResolvedEvent event) { switch (event.getResolution()) { case "ACCEPTED" -> this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), + MessageKeys.NOTIFICATION_MODIFY_ACCEPTED, this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_ACCEPTED, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); case "REJECTED" -> this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), + MessageKeys.NOTIFICATION_MODIFY_REJECTED, this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_REJECTED, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); @@ -126,6 +135,7 @@ public void onModificationResolved(@NotNull LeaseModificationResolvedEvent event UUID target = LeaseholdRoles.LANDLORD.equals(event.getProposerRole()) ? event.getTenantId() : event.getLandlordId(); this.events.fireSync(new RealtyNotificationEvent(List.of(target), + MessageKeys.NOTIFICATION_MODIFY_WITHDRAWN, this.messages.messageFor(MessageKeys.NOTIFICATION_MODIFY_WITHDRAWN, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); @@ -139,12 +149,14 @@ public void onTerminationScheduled(@NotNull LeaseTerminationScheduledEvent event String date = event.getEffectiveDate().format(DateTimeFormatters.DATE_TIME); if (LeaseholdRoles.LANDLORD.equals(event.getTerminatedByRole())) { this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), + MessageKeys.NOTIFICATION_TERMINATION_SCHEDULED_TENANT, this.messages.messageFor(MessageKeys.NOTIFICATION_TERMINATION_SCHEDULED_TENANT, Placeholder.unparsed("region", event.getRegionId()), Placeholder.unparsed("date", date)), event.getRegion())); } else { this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), + MessageKeys.NOTIFICATION_TERMINATION_SCHEDULED_LANDLORD, this.messages.messageFor(MessageKeys.NOTIFICATION_TERMINATION_SCHEDULED_LANDLORD, Placeholder.unparsed("region", event.getRegionId()), Placeholder.unparsed("date", date)), @@ -158,6 +170,7 @@ public void onTerminationCancelled(@NotNull LeaseTerminationCancelledEvent event UUID target = LeaseholdRoles.LANDLORD.equals(event.getTerminatedByRole()) ? event.getTenantId() : event.getLandlordId(); this.events.fireSync(new RealtyNotificationEvent(List.of(target), + MessageKeys.NOTIFICATION_TERMINATION_CANCELLED, this.messages.messageFor(MessageKeys.NOTIFICATION_TERMINATION_CANCELLED, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); @@ -166,11 +179,13 @@ public void onTerminationCancelled(@NotNull LeaseTerminationCancelledEvent event @EventHandler public void onLeaseTerminated(@NotNull LeaseTerminatedEvent event) { this.events.fireSync(new RealtyNotificationEvent(List.of(event.getTenantId()), + MessageKeys.NOTIFICATION_LEASEHOLD_TERMINATED_TENANT, this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_TERMINATED_TENANT, Placeholder.unparsed("region", event.getRegionId()), Placeholder.unparsed("refund", CurrencyFormatter.format(event.getRefund()))), event.getRegion())); this.events.fireSync(new RealtyNotificationEvent(List.of(event.getLandlordId()), + MessageKeys.NOTIFICATION_LEASEHOLD_TERMINATED_LANDLORD, this.messages.messageFor(MessageKeys.NOTIFICATION_LEASEHOLD_TERMINATED_LANDLORD, Placeholder.unparsed("region", event.getRegionId())), event.getRegion())); diff --git a/realty-paper/src/main/resources/paper-plugin.yml b/realty-paper/src/main/resources/paper-plugin.yml index 6314321..400f259 100644 --- a/realty-paper/src/main/resources/paper-plugin.yml +++ b/realty-paper/src/main/resources/paper-plugin.yml @@ -26,6 +26,10 @@ dependencies: load: BEFORE join-classpath: true required: false + PlayerNotifications: + load: BEFORE + join-classpath: true + required: false permissions: realty.command.agent.invite: description: Allows using /realty agent invite 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 deleted file mode 100644 index 8a7d2ef..0000000 --- a/realty-paper/src/test/java/io/github/md5sha256/realty/BundledModuleExtractionTest.java +++ /dev/null @@ -1,43 +0,0 @@ -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)); - } -} diff --git a/settings.gradle.kts b/settings.gradle.kts index 9267cdd..9b2e0ce 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,3 +8,4 @@ include("realty-areashop-importer") include("realty-paper-plan-extension") include("realty-paper-adapters:chat-adapter") include("realty-paper-adapters:essentials-adapter") +include("realty-paper-adapters:pn-adapter") From 44a1cb05bf1f67fa6af3bf72fd65c6fba029664d Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 17:57:10 +1000 Subject: [PATCH 03/14] fix: resolve the PlayerNotifications API and rename the adapter The API publishes to maven.minecraftcitiesnetwork.com, which Realty did not declare; add it and depend on the released 1.0.0 rather than the locally-installed snapshot. Rename pn-adapter to player-notifications-adapter, including its package (adapter.pn -> adapter.playernotifications), so it reads like the module it delivers to and matches how chat-adapter and essentials-adapter name their packages after themselves. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- README.md | 2 +- .../src/main/kotlin/realty-conventions.gradle.kts | 4 ++++ ...026-08-22-player-notifications-adapter-design.md} | 12 ++++++------ .../build.gradle.kts | 4 ++-- .../NotificationCategoryMapper.java | 2 +- .../playernotifications}/NotificationEnqueuer.java | 2 +- .../PlayerNotificationsAdapterModule.java | 8 ++++---- .../PlayerNotificationsListener.java | 2 +- .../playernotifications}/RealtyDataTypes.java | 2 +- .../RealtyNotificationPayload.java | 2 +- .../RealtyNotificationRenderer.java | 2 +- .../src/main/resources/categories.yml | 0 .../src/main/resources/module-manifest.yml | 5 +++++ .../NotificationCategoryMapperTest.java | 2 +- .../PlayerNotificationsListenerTest.java | 2 +- .../RealtyNotificationPayloadTest.java | 2 +- .../RegistrationLifecycleTest.java | 2 +- .../src/main/resources/module-manifest.yml | 5 ----- realty-paper/build.gradle.kts | 8 ++++---- .../main/java/io/github/md5sha256/realty/Realty.java | 6 +++--- settings.gradle.kts | 2 +- 21 files changed, 40 insertions(+), 36 deletions(-) rename docs/superpowers/specs/{2026-08-22-pn-adapter-design.md => 2026-08-22-player-notifications-adapter-design.md} (94%) rename realty-paper-adapters/{pn-adapter => player-notifications-adapter}/build.gradle.kts (94%) rename realty-paper-adapters/{pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications}/NotificationCategoryMapper.java (98%) rename realty-paper-adapters/{pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications}/NotificationEnqueuer.java (90%) rename realty-paper-adapters/{pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications}/PlayerNotificationsAdapterModule.java (95%) rename realty-paper-adapters/{pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications}/PlayerNotificationsListener.java (98%) rename realty-paper-adapters/{pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications}/RealtyDataTypes.java (98%) rename realty-paper-adapters/{pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications}/RealtyNotificationPayload.java (98%) rename realty-paper-adapters/{pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications}/RealtyNotificationRenderer.java (96%) rename realty-paper-adapters/{pn-adapter => player-notifications-adapter}/src/main/resources/categories.yml (100%) create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml rename realty-paper-adapters/{pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications}/NotificationCategoryMapperTest.java (98%) rename realty-paper-adapters/{pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications}/PlayerNotificationsListenerTest.java (98%) rename realty-paper-adapters/{pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications}/RealtyNotificationPayloadTest.java (97%) rename realty-paper-adapters/{pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn => player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications}/RegistrationLifecycleTest.java (98%) delete mode 100644 realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml diff --git a/README.md b/README.md index 9d182bc..0934791 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ immutable: republishing a version that already exists fails with a 409, so bump | `realty-areashop-importer` | Optional AreaShop migration helper | | `realty-paper-adapters/chat-adapter` | Notification delivery to online players via chat | | `realty-paper-adapters/essentials-adapter` | Notification delivery via EssentialsX mail | -| `realty-paper-adapters/pn-adapter` | Notification delivery via [PlayerNotifications](https://github.com/MCCitiesNetwork/player-notifications) | +| `realty-paper-adapters/player-notifications-adapter` | Notification delivery via [PlayerNotifications](https://github.com/MCCitiesNetwork/player-notifications) | The adapter modules are **not bundled in the plugin jar**. Each is published as its own jar; install the ones you want by placing them in `plugins/Realty/modules` and restarting the server. Realty diff --git a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts index cb123a1..b52bb15 100644 --- a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts @@ -44,6 +44,10 @@ repositories { name = "paradaux-snapshots" url = uri("https://repo.paradaux.io/snapshots") } + maven { + name = "mccities-releases" + url = uri("https://maven.minecraftcitiesnetwork.com/releases") + } } dependencies { diff --git a/docs/superpowers/specs/2026-08-22-pn-adapter-design.md b/docs/superpowers/specs/2026-08-22-player-notifications-adapter-design.md similarity index 94% rename from docs/superpowers/specs/2026-08-22-pn-adapter-design.md rename to docs/superpowers/specs/2026-08-22-player-notifications-adapter-design.md index 757cc0e..52e0438 100644 --- a/docs/superpowers/specs/2026-08-22-pn-adapter-design.md +++ b/docs/superpowers/specs/2026-08-22-player-notifications-adapter-design.md @@ -57,7 +57,7 @@ argument threaded through. ## 2. The adapter module -New subproject `realty-paper-adapters/pn-adapter`, added to `settings.gradle.kts`, +New subproject `realty-paper-adapters/player-notifications-adapter`, added to `settings.gradle.kts`, mirroring `essentials-adapter`: `java-library` + `realty-conventions` + shadow, everything `compileOnly` (`realty-paper`, `realty-paper-api`, paper-api, annotations, `plugin-infrastructure`) plus: @@ -69,7 +69,7 @@ compileOnly("io.github.md5sha256:player-notifications-api:1.0.0") Available from the `maven.democracycraft.net` repos Realty already declares — no build-script repo changes. -`module-manifest.yml`: `module-name: pn-adapter`, `reloadable: true`. +`module-manifest.yml`: `module-name: player-notifications-adapter`, `reloadable: true`. `PlayerNotificationsAdapterModule extends SimplePluginModule`, initialize order — **all fallible work before `registerListener`**, because if anything after it throws, @@ -160,12 +160,12 @@ The `chat-adapter` subproject is unchanged — it keeps building, testing and sh its `IOException` handler. `BundledModuleExtractor` then has no callers: delete it. - Keep both warnings, reworded from failed-extraction recovery advice to a plain statement that no delivery module is installed and where to put one. - - The "no modules at all" warning names `pn-adapter.jar` alongside `chat-adapter.jar`. - - The chat-adapter-specific warning stays quiet when `pn-adapter` is loaded, so a + - The "no modules at all" warning names `player-notifications-adapter.jar` alongside `chat-adapter.jar`. + - The chat-adapter-specific warning stays quiet when `player-notifications-adapter` is loaded, so a PN-only server does not warn at every startup about a deliberate choice. 3. **`runServer`** — keep staging `chat-adapter.jar` (a dev convenience independent of - shipping; removing it breaks local smoke tests) and add `pn-adapter.jar` to the same - `doFirst` staging. PN itself is not downloaded by `runServer`, so `pn-adapter` will fail + shipping; removing it breaks local smoke tests) and add `player-notifications-adapter.jar` to the same + `doFirst` staging. PN itself is not downloaded by `runServer`, so `player-notifications-adapter` will fail there with the clear "PlayerNotifications is not installed" error. That is correct. **Upgrade behaviour:** an existing server keeps its previously-extracted diff --git a/realty-paper-adapters/pn-adapter/build.gradle.kts b/realty-paper-adapters/player-notifications-adapter/build.gradle.kts similarity index 94% rename from realty-paper-adapters/pn-adapter/build.gradle.kts rename to realty-paper-adapters/player-notifications-adapter/build.gradle.kts index 16b2431..2a55dc9 100644 --- a/realty-paper-adapters/pn-adapter/build.gradle.kts +++ b/realty-paper-adapters/player-notifications-adapter/build.gradle.kts @@ -10,9 +10,9 @@ dependencies { compileOnly("io.papermc.paper:paper-api:26.1.2.build.74-stable") compileOnly("org.jetbrains:annotations:26.0.2-1") compileOnly("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") - compileOnly("io.github.md5sha256:player-notifications-api:1.0.0-SNAPSHOT") + compileOnly("io.github.md5sha256:player-notifications-api:1.0.0") testImplementation(project(":realty-paper-api")) testImplementation("io.papermc.paper:paper-api:26.1.2.build.74-stable") - testImplementation("io.github.md5sha256:player-notifications-api:1.0.0-SNAPSHOT") + testImplementation("io.github.md5sha256:player-notifications-api:1.0.0") } diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapper.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapper.java similarity index 98% rename from realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapper.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapper.java index f8ebbc9..0e7048f 100644 --- a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapper.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapper.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import org.jetbrains.annotations.NotNull; diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationEnqueuer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationEnqueuer.java similarity index 90% rename from realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationEnqueuer.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationEnqueuer.java index b88ae89..71f173a 100644 --- a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/NotificationEnqueuer.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationEnqueuer.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import io.github.md5sha256.playernotifications.api.TypedNotification; import org.jetbrains.annotations.NotNull; diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsAdapterModule.java similarity index 95% rename from realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsAdapterModule.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsAdapterModule.java index 35e1a4e..235bd9e 100644 --- a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsAdapterModule.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsAdapterModule.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import com.minecraftcitiesnetwork.pluginInfrastructure.modules.SimplePluginModule; import io.github.md5sha256.playernotifications.api.NotificationService; @@ -61,14 +61,14 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { Plugin pnPlugin = Bukkit.getPluginManager().getPlugin("PlayerNotifications"); if (pnPlugin == null || !pnPlugin.isEnabled()) { throw new IllegalStateException( - "PlayerNotifications is not installed or not enabled — pn-adapter cannot start"); + "PlayerNotifications is not installed or not enabled — player-notifications-adapter cannot start"); } NotificationService notificationService = Bukkit.getServicesManager().load(NotificationService.class); if (notificationService == null) { throw new IllegalStateException( "PlayerNotifications is enabled but registered no NotificationService — " - + "pn-adapter cannot start"); + + "player-notifications-adapter cannot start"); } // 2. Load the message-key -> dataType mapping from the module's data folder. @@ -116,7 +116,7 @@ public void shutdown(@NotNull Realty plugin) { .getResourceAsStream(CATEGORIES_FILE)) { if (defaults == null) { throw new IllegalStateException( - "pn-adapter jar is missing its bundled " + CATEGORIES_FILE); + "player-notifications-adapter jar is missing its bundled " + CATEGORIES_FILE); } Files.copy(defaults, file, StandardCopyOption.REPLACE_EXISTING); } diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListener.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListener.java similarity index 98% rename from realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListener.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListener.java index 2138c53..700b1a8 100644 --- a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListener.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListener.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import io.github.md5sha256.playernotifications.api.NotificationTarget; import io.github.md5sha256.playernotifications.api.TypedNotification; diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyDataTypes.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyDataTypes.java similarity index 98% rename from realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyDataTypes.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyDataTypes.java index 0628669..1ac9982 100644 --- a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyDataTypes.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyDataTypes.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import io.github.md5sha256.playernotifications.api.NotificationDataTypeRegistry; import io.github.md5sha256.playernotifications.api.NotificationService; diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayload.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayload.java similarity index 98% rename from realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayload.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayload.java index 59f5829..26bf0a0 100644 --- a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayload.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayload.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; diff --git a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationRenderer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationRenderer.java similarity index 96% rename from realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationRenderer.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationRenderer.java index 70ee2da..80bf864 100644 --- a/realty-paper-adapters/pn-adapter/src/main/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationRenderer.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationRenderer.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; import io.github.md5sha256.playernotifications.api.render.RenderableNotification; diff --git a/realty-paper-adapters/pn-adapter/src/main/resources/categories.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml similarity index 100% rename from realty-paper-adapters/pn-adapter/src/main/resources/categories.yml rename to realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml new file mode 100644 index 0000000..811f8b5 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml @@ -0,0 +1,5 @@ +module-name: player-notifications-adapter +entry-class: io.github.md5sha256.realty.adapter.playernotifications.PlayerNotificationsAdapterModule +author: md5sha256 +expected-plugin-class: io.github.md5sha256.realty.Realty +reloadable: true diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapperTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapperTest.java similarity index 98% rename from realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapperTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapperTest.java index 4f52293..f1a341c 100644 --- a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/NotificationCategoryMapperTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapperTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListenerTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListenerTest.java similarity index 98% rename from realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListenerTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListenerTest.java index da88baa..a75cd77 100644 --- a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/PlayerNotificationsListenerTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListenerTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import io.github.md5sha256.playernotifications.api.TypedNotification; import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayloadTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayloadTest.java similarity index 97% rename from realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayloadTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayloadTest.java index 805095c..2f125df 100644 --- a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RealtyNotificationPayloadTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayloadTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; diff --git a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RegistrationLifecycleTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RegistrationLifecycleTest.java similarity index 98% rename from realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RegistrationLifecycleTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RegistrationLifecycleTest.java index 92dc053..0585e6a 100644 --- a/realty-paper-adapters/pn-adapter/src/test/java/io/github/md5sha256/realty/adapter/pn/RegistrationLifecycleTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RegistrationLifecycleTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.pn; +package io.github.md5sha256.realty.adapter.playernotifications; import io.github.md5sha256.playernotifications.api.NotificationDataTypeRegistry; import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; diff --git a/realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml b/realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml deleted file mode 100644 index fa8dcd6..0000000 --- a/realty-paper-adapters/pn-adapter/src/main/resources/module-manifest.yml +++ /dev/null @@ -1,5 +0,0 @@ -module-name: pn-adapter -entry-class: io.github.md5sha256.realty.adapter.pn.PlayerNotificationsAdapterModule -author: md5sha256 -expected-plugin-class: io.github.md5sha256.realty.Realty -reloadable: true diff --git a/realty-paper/build.gradle.kts b/realty-paper/build.gradle.kts index 04c4340..d3dfbed 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -178,10 +178,10 @@ tasks { // the spec's Essentials smoke test cannot be run as written. val essentialsAdapterJar = project(":realty-paper-adapters:essentials-adapter") .tasks.named("shadowJar", AbstractArchiveTask::class).flatMap { it.archiveFile } - // PlayerNotifications is not downloaded by runServer, so pn-adapter will fail to + // PlayerNotifications is not downloaded by runServer, so player-notifications-adapter will fail to // initialize there with its "PlayerNotifications is not installed" error. Staging it // anyway keeps the jar fresh for a server that does have PN dropped in by hand. - val pnAdapterJar = project(":realty-paper-adapters:pn-adapter") + val playerNotificationsAdapterJar = project(":realty-paper-adapters:player-notifications-adapter") .tasks.named("shadowJar", AbstractArchiveTask::class).flatMap { it.archiveFile } // Plan is downloaded below, so stage the extension that pairs with it. Staging it // here rather than leaving a hand-copied jar in run/plugins is what stops it going @@ -193,13 +193,13 @@ tasks { val pluginsDir = layout.projectDirectory.dir("run/plugins").asFile // The archiveFile providers carry their producing task as a dependency, so the // explicit dependsOn declarations they replace are no longer needed. - inputs.files(chatAdapterJar, essentialsAdapterJar, pnAdapterJar, planExtensionJar) + inputs.files(chatAdapterJar, essentialsAdapterJar, playerNotificationsAdapterJar, planExtensionJar) doFirst { moduleDir.mkdirs() chatAdapterJar.get().asFile.copyTo(moduleDir.resolve("chat-adapter.jar"), overwrite = true) essentialsAdapterJar.get().asFile .copyTo(moduleDir.resolve("essentials-adapter.jar"), overwrite = true) - pnAdapterJar.get().asFile.copyTo(moduleDir.resolve("pn-adapter.jar"), overwrite = true) + playerNotificationsAdapterJar.get().asFile.copyTo(moduleDir.resolve("player-notifications-adapter.jar"), overwrite = true) // Fixed filename, so a rebuild replaces the jar instead of leaving the previous // version behind as a second, duplicate plugin. pluginsDir.mkdirs() 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 db243cb..f32e660 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,10 +700,10 @@ private void startModules() { getLogger().warning("No notification delivery module is installed. Realty fires notification " + "events but delivers nothing on its own; every notification (sale, lease, offer, " + "auction, etc.) will reach nobody. Install a delivery module by placing " - + "chat-adapter.jar or pn-adapter.jar in " + moduleDir + "."); + + "chat-adapter.jar or player-notifications-adapter.jar in " + moduleDir + "."); } else if (!this.moduleManager.getActiveModules().containsKey("chat-adapter") - && !this.moduleManager.getActiveModules().containsKey("pn-adapter")) { - // pn-adapter is a deliberate alternative to chat delivery, so stay quiet when it + && !this.moduleManager.getActiveModules().containsKey("player-notifications-adapter")) { + // player-notifications-adapter is a deliberate alternative to chat delivery, so stay quiet when it // is installed rather than nagging a PN-only server at every startup. getLogger().warning("The chat-adapter module is not installed. Online players will not " + "receive chat notifications. Install it by placing chat-adapter.jar in " diff --git a/settings.gradle.kts b/settings.gradle.kts index 9b2e0ce..4e82ee1 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -8,4 +8,4 @@ include("realty-areashop-importer") include("realty-paper-plan-extension") include("realty-paper-adapters:chat-adapter") include("realty-paper-adapters:essentials-adapter") -include("realty-paper-adapters:pn-adapter") +include("realty-paper-adapters:player-notifications-adapter") From bd7da9332c4b6fa7bb089cb0d86e3b856eb8ac11 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:00:35 +1000 Subject: [PATCH 04/14] refactor: shorten the adapter package to adapter.playernotifs Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- .../NotificationCategoryMapper.java | 2 +- .../NotificationEnqueuer.java | 2 +- .../PlayerNotificationsAdapterModule.java | 2 +- .../PlayerNotificationsListener.java | 2 +- .../{playernotifications => playernotifs}/RealtyDataTypes.java | 2 +- .../RealtyNotificationPayload.java | 2 +- .../RealtyNotificationRenderer.java | 2 +- .../src/main/resources/module-manifest.yml | 2 +- .../NotificationCategoryMapperTest.java | 2 +- .../PlayerNotificationsListenerTest.java | 2 +- .../RealtyNotificationPayloadTest.java | 2 +- .../RegistrationLifecycleTest.java | 2 +- 12 files changed, 12 insertions(+), 12 deletions(-) rename realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/NotificationCategoryMapper.java (98%) rename realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/NotificationEnqueuer.java (90%) rename realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/PlayerNotificationsAdapterModule.java (99%) rename realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/PlayerNotificationsListener.java (98%) rename realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/RealtyDataTypes.java (98%) rename realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/RealtyNotificationPayload.java (98%) rename realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/RealtyNotificationRenderer.java (96%) rename realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/NotificationCategoryMapperTest.java (98%) rename realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/PlayerNotificationsListenerTest.java (98%) rename realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/RealtyNotificationPayloadTest.java (97%) rename realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/{playernotifications => playernotifs}/RegistrationLifecycleTest.java (98%) diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapper.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java similarity index 98% rename from realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapper.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java index 0e7048f..48066b9 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapper.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import org.jetbrains.annotations.NotNull; diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationEnqueuer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationEnqueuer.java similarity index 90% rename from realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationEnqueuer.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationEnqueuer.java index 71f173a..a48e115 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationEnqueuer.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationEnqueuer.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import io.github.md5sha256.playernotifications.api.TypedNotification; import org.jetbrains.annotations.NotNull; diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java similarity index 99% rename from realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsAdapterModule.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java index 235bd9e..bef2b69 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsAdapterModule.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import com.minecraftcitiesnetwork.pluginInfrastructure.modules.SimplePluginModule; import io.github.md5sha256.playernotifications.api.NotificationService; diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListener.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java similarity index 98% rename from realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListener.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java index 700b1a8..aba0d76 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListener.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import io.github.md5sha256.playernotifications.api.NotificationTarget; import io.github.md5sha256.playernotifications.api.TypedNotification; diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyDataTypes.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java similarity index 98% rename from realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyDataTypes.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java index 1ac9982..b1f3eee 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyDataTypes.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import io.github.md5sha256.playernotifications.api.NotificationDataTypeRegistry; import io.github.md5sha256.playernotifications.api.NotificationService; diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayload.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayload.java similarity index 98% rename from realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayload.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayload.java index 26bf0a0..678160f 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayload.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayload.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.serializer.gson.GsonComponentSerializer; diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationRenderer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java similarity index 96% rename from realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationRenderer.java rename to realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java index 80bf864..ca108f5 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationRenderer.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; import io.github.md5sha256.playernotifications.api.render.RenderableNotification; diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml index 811f8b5..633b91b 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml +++ b/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml @@ -1,5 +1,5 @@ module-name: player-notifications-adapter -entry-class: io.github.md5sha256.realty.adapter.playernotifications.PlayerNotificationsAdapterModule +entry-class: io.github.md5sha256.realty.adapter.playernotifs.PlayerNotificationsAdapterModule author: md5sha256 expected-plugin-class: io.github.md5sha256.realty.Realty reloadable: true diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapperTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java similarity index 98% rename from realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapperTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java index f1a341c..e829672 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/NotificationCategoryMapperTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListenerTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java similarity index 98% rename from realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListenerTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java index a75cd77..47be7a0 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/PlayerNotificationsListenerTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import io.github.md5sha256.playernotifications.api.TypedNotification; import io.github.md5sha256.realty.api.event.RealtyNotificationEvent; diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayloadTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayloadTest.java similarity index 97% rename from realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayloadTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayloadTest.java index 2f125df..5c9c663 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RealtyNotificationPayloadTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayloadTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.ClickEvent; diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RegistrationLifecycleTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java similarity index 98% rename from realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RegistrationLifecycleTest.java rename to realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java index 0585e6a..a4f9dee 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifications/RegistrationLifecycleTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java @@ -1,4 +1,4 @@ -package io.github.md5sha256.realty.adapter.playernotifications; +package io.github.md5sha256.realty.adapter.playernotifs; import io.github.md5sha256.playernotifications.api.NotificationDataTypeRegistry; import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; From f313bedc862a36106c33b4324ff1a7dee3ec4b19 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:12:32 +1000 Subject: [PATCH 05/14] build: bump version to 1.4.2 and stop shipping a stale manifest version processResources captured the project version into the expand() closure but never declared it as a task input, so Gradle hashed only paper-plugin.yml, found it unchanged across a bump, and reused the previously-expanded output. Every release therefore shipped a jar whose manifest announced the previous version -- a 1.4.1 jar reporting v1.4.0, which is exactly what made a stale deployment impossible to spot in the server log. Declaring the version via inputs.property invalidates the task on a bump. Applied to realty-paper-plan-extension too, which had the same pattern. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- realty-paper-plan-extension/build.gradle.kts | 3 +++ realty-paper/build.gradle.kts | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/realty-paper-plan-extension/build.gradle.kts b/realty-paper-plan-extension/build.gradle.kts index 5c08f4f..b7b62e8 100644 --- a/realty-paper-plan-extension/build.gradle.kts +++ b/realty-paper-plan-extension/build.gradle.kts @@ -33,6 +33,9 @@ tasks { processResources { val projectVersion = version + // See realty-paper: without this the task is up-to-date across a version bump and the + // manifest keeps announcing the previous version. + inputs.property("version", projectVersion) filesMatching("paper-plugin.yml") { expand("version" to projectVersion) } diff --git a/realty-paper/build.gradle.kts b/realty-paper/build.gradle.kts index d3dfbed..fd7dc1a 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -161,6 +161,10 @@ tasks { processResources { val projectVersion = version + // Declared as an input so a version bump invalidates the task. Without this Gradle only + // hashes paper-plugin.yml itself, finds it unchanged, and reuses the previously-expanded + // output -- shipping a jar whose manifest announces the *previous* version. + inputs.property("version", projectVersion) filesMatching("paper-plugin.yml") { expand("version" to projectVersion) } From 0d6e4442cdb808e87f59df698f313f1a100c7991 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:41:41 +1000 Subject: [PATCH 06/14] build: compile the adapter against player-notifications-api 1.0.1 1.0.1 carries the category change listener the plugin now subscribes to, so a Realty registration arriving after PN's snapshot is built reaches the preference dialogs instead of falling into uncategorized. The adapter's own code is unchanged -- the fix is entirely host-side. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- .../player-notifications-adapter/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/realty-paper-adapters/player-notifications-adapter/build.gradle.kts b/realty-paper-adapters/player-notifications-adapter/build.gradle.kts index 2a55dc9..f09295b 100644 --- a/realty-paper-adapters/player-notifications-adapter/build.gradle.kts +++ b/realty-paper-adapters/player-notifications-adapter/build.gradle.kts @@ -10,9 +10,9 @@ dependencies { compileOnly("io.papermc.paper:paper-api:26.1.2.build.74-stable") compileOnly("org.jetbrains:annotations:26.0.2-1") compileOnly("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") - compileOnly("io.github.md5sha256:player-notifications-api:1.0.0") + compileOnly("io.github.md5sha256:player-notifications-api:1.0.1") testImplementation(project(":realty-paper-api")) testImplementation("io.papermc.paper:paper-api:26.1.2.build.74-stable") - testImplementation("io.github.md5sha256:player-notifications-api:1.0.0") + testImplementation("io.github.md5sha256:player-notifications-api:1.0.1") } From 15f74e71a254e1f666461d7a03c5edb1495d0a26 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:24:43 +1000 Subject: [PATCH 07/14] feat(player-notifications-adapter): read the category set from categories.yml The adapter's categories.yml already carried the message-key routing, but the set of PlayerNotifications data types was compiled in: DATA_TYPES listed five strings and RealtyDataTypes held their labels and descriptions. An operator who routed a key to a category of their own got a notification enqueued under a data type that was never registered -- no renderer, no category claim. The labels players read in /notifications preferences also came from the code, not from the `titles:` block, so editing the file appeared to do nothing. categories.yml now mirrors PlayerNotifications' own shape: each category owns its label, description, title, priority and the keys it claims. NotificationCategoryMapper derives the data types from it and is the only source; RealtyDataTypes loops over mapper.dataTypes(). Adding or re-splitting a category needs no rebuild. Two hazards the change introduces, both covered by tests: a key claimed by two categories and a fallback-category that is not declared are rejected at load rather than resolving unpredictably or enqueueing into an unregistered type; and teardown uses the mapper the registrations were made with, never a freshly-parsed one, so a category deleted between reloads is not orphaned in PN's registry. Fixes a latent bug found while testing the parse: Bukkit splits configuration keys on '.' as it loads, and every key in this file contains a dot. The previous flat format therefore never parsed -- `notification.outbid: realty.auction` became a nested section, getKeys(false) returned only "notification", and the routing map came out empty, so every notification fell through to realty.general regardless of what the file said. CategoriesConfig.load sets the path separator before loading. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- README.md | 14 ++ .../playernotifs/CategoriesConfig.java | 131 +++++++++++++++ .../playernotifs/CategoryDefinition.java | 58 +++++++ .../NotificationCategoryMapper.java | 136 ++++++++++++---- .../PlayerNotificationsAdapterModule.java | 81 ++++------ .../adapter/playernotifs/RealtyDataTypes.java | 69 ++++---- .../src/main/resources/categories.yml | 150 ++++++++++-------- .../playernotifs/CategoriesConfigTest.java | 137 ++++++++++++++++ .../NotificationCategoryMapperTest.java | 133 +++++++++++++--- .../PlayerNotificationsListenerTest.java | 9 +- .../RegistrationLifecycleTest.java | 78 ++++++--- .../adapter/playernotifs/TestCategories.java | 34 ++++ 12 files changed, 792 insertions(+), 238 deletions(-) create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java create mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java create mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java diff --git a/README.md b/README.md index 0934791..18b1895 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,20 @@ the ones you want by placing them in `plugins/Realty/modules` and restarting the delivers no notifications until at least one delivery module is installed, and logs a warning at startup while none is. +### Notification categories + +`player-notifications-adapter` writes a `categories.yml` into its data folder +(`plugins/Realty/modules/player-notifications-adapter/`) on first start. Every category declared there is +registered with PlayerNotifications as a data type: the unit players switch on and off in +`/notifications preferences`. Each carries its own player-facing label and description, the title shown on the +notification, a delivery priority, and the Realty message keys routed to it. + +The category set is read from that file rather than compiled in, so you can rename a category, split one into +several, or add your own without a new build of the adapter. A message key may belong to exactly one category, +and `fallback-category` must name one of the declared categories — the adapter refuses to start otherwise +instead of enqueueing notifications nobody can receive. A key you list nowhere routes to the fallback and is +never dropped. + ## Documentation ### Getting Started diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java new file mode 100644 index 0000000..c0a6b0a --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java @@ -0,0 +1,131 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.io.IOException; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Parses the module's {@code categories.yml} into a {@link NotificationCategoryMapper}. + * + *

Kept out of {@link PlayerNotificationsAdapterModule} so the parse can be tested directly: the + * module extends {@code SimplePluginModule}, and reaching it from a test would drag + * {@code plugin-infrastructure} — a {@code compileOnly} dependency — onto the test classpath. + * Only Bukkit's own config classes are needed here, and those run without a server.

+ */ +public final class CategoriesConfig { + + static final String CATEGORIES_FILE = "categories.yml"; + private static final String DEFAULT_FALLBACK = "realty.general"; + private static final int DEFAULT_EXPIRY_DAYS = 30; + + private CategoriesConfig() { + } + + /** + * Loads {@code categories.yml} with dots treated as ordinary characters rather than as path + * separators. + * + *

Why this cannot be done on an already-loaded configuration. Every key in this file + * — {@code realty.auction}, {@code notification.outbid} — contains a dot, and Bukkit's default + * path separator is a dot. {@code YamlConfiguration} applies the separator while loading + * (each key is {@code set} by path), so {@code realty.auction: {...}} silently becomes a section + * {@code realty} containing {@code auction}, and the top-level key the parser then reads back is + * {@code realty}. Setting the separator after {@code loadConfiguration} is far too late — the + * nesting has already happened. It is set here, on a configuration that has not read anything + * yet, to a character a YAML key cannot contain.

+ * + * @throws IllegalArgumentException if the YAML is malformed + */ + public static @NotNull YamlConfiguration load(@NotNull Reader reader) { + Objects.requireNonNull(reader, "reader"); + YamlConfiguration config = new YamlConfiguration(); + config.options().pathSeparator('\u0000'); + try { + config.load(reader); + } catch (InvalidConfigurationException ex) { + throw new IllegalArgumentException(CATEGORIES_FILE + " is not valid YAML", ex); + } catch (IOException ex) { + throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); + } + return config; + } + + /** + * Builds the mapper from a loaded {@code categories.yml}. + * + *

Category declaration order is the file's order, which {@code YamlConfiguration} preserves, + * so the registration order an operator reads in the file is the one used at runtime.

+ * + * @throws IllegalArgumentException if the file declares no usable category set + */ + public static @NotNull NotificationCategoryMapper readMapper(@NotNull YamlConfiguration config) { + Objects.requireNonNull(config, "config"); + ConfigurationSection section = config.getConfigurationSection("categories"); + if (section == null) { + throw new IllegalArgumentException( + CATEGORIES_FILE + " has no 'categories' section; it must declare at least one category"); + } + + List categories = new ArrayList<>(); + for (String key : section.getKeys(false)) { + ConfigurationSection entry = section.getConfigurationSection(key); + if (entry == null) { + throw new IllegalArgumentException( + "Category '" + key + "' in " + CATEGORIES_FILE + " is not a section. Since 1.4.2 a " + + "category declares its own label, description and keys; a bare " + + "'message-key: category' line is the pre-1.4.2 format."); + } + categories.add(new CategoryDefinition( + key, + orEmpty(entry.getString("label")), + orEmpty(entry.getString("description")), + orEmpty(entry.getString("title")), + entry.getInt("priority", 0), + List.copyOf(entry.getStringList("keys")))); + } + + return new NotificationCategoryMapper( + categories, + readStrings(config.getConfigurationSection("title-overrides")), + orDefault(config.getString("fallback-category"), DEFAULT_FALLBACK)); + } + + /** How long an enqueued notification survives before PlayerNotifications expires it. */ + public static @NotNull Duration readExpiry(@NotNull YamlConfiguration config) { + return Duration.ofDays( + Objects.requireNonNull(config, "config").getLong("expiry-days", DEFAULT_EXPIRY_DAYS)); + } + + private static @NotNull Map readStrings(@Nullable ConfigurationSection section) { + Map values = new HashMap<>(); + if (section != null) { + for (String key : section.getKeys(false)) { + String value = section.getString(key); + if (value != null && !value.isBlank()) { + values.put(key, value); + } + } + } + return values; + } + + private static @NotNull String orEmpty(@Nullable String value) { + return value == null ? "" : value; + } + + private static @NotNull String orDefault(@Nullable String value, @NotNull String fallback) { + return value == null || value.isBlank() ? fallback : value; + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java new file mode 100644 index 0000000..c42d05d --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java @@ -0,0 +1,58 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.jetbrains.annotations.NotNull; + +import java.util.List; +import java.util.Objects; + +/** + * One category declared in the module's {@code categories.yml}. + * + *

A category is simultaneously two things, which is why its metadata lives in one place rather + * than being split across sections: it is a PlayerNotifications {@code dataType} — the unit players + * opt in and out of in {@code /notifications preferences} — and it is the display grouping those + * dialogs label. {@link #label} and {@link #description} are what a player reads there; + * {@link #title} is what appears on the delivered notification itself.

+ * + *

Mirrors the shape of PlayerNotifications' own {@code categories.yml} + * ({@code NotificationCategoryDefinition}) so an operator who has configured one recognises the + * other. The one addition is {@link #keys}: PN groups data types, whereas this module maps Realty + * message keys onto the data type they are enqueued under, so the leaves here are message keys.

+ * + * @param key the category key, used verbatim as the PN data type + * @param label player-facing name shown in the preference dialogs + * @param description player-facing explanation shown in the preference dialogs + * @param title heading rendered on the notification; falls back to {@code label} when blank + * @param priority delivery priority for every key in this category; higher sorts first + * @param keys the Realty message keys routed to this category; may be empty + */ +public record CategoryDefinition(@NotNull String key, + @NotNull String label, + @NotNull String description, + @NotNull String title, + int priority, + @NotNull List keys) { + + public CategoryDefinition { + Objects.requireNonNull(key, "key"); + Objects.requireNonNull(label, "label"); + Objects.requireNonNull(description, "description"); + Objects.requireNonNull(title, "title"); + Objects.requireNonNull(keys, "keys"); + if (key.isBlank()) { + throw new IllegalArgumentException("A category key may not be blank"); + } + keys = List.copyOf(keys); + } + + /** + * The heading to render: the configured title, or the label when no title was configured. + * + *

Defaulting to the label rather than to a generic constant means an operator who adds a + * category and gives it only a label still gets that label on the notification, instead of a + * bare {@code "Realty"} that tells the player nothing about which category it came from.

+ */ + public @NotNull String effectiveTitle() { + return this.title.isBlank() ? this.label : this.title; + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java index 48066b9..d1cbe68 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java @@ -1,93 +1,163 @@ package io.github.md5sha256.realty.adapter.playernotifs; import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; +import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; /** * Resolves a Realty message key to the PlayerNotifications {@code dataType} it is enqueued under, - * and to the title and priority that data type is rendered with. + * and to the label, description, title and priority that data type is registered and rendered with. + * + *

The category set is whatever {@code categories.yml} declares — this class holds no hardcoded + * list. Everything that registers against PlayerNotifications reads {@link #dataTypes()}, so adding + * a category to the file is enough to have it registered, claimed and shown in the preference + * dialogs; nothing needs recompiling.

* *

Deliberately a plain class with no PlayerNotifications and no Bukkit types on it: the routing * decision is the part worth testing, and keeping it free of both means it can be tested without a * server or a live PN install.

* - *

An unrecognised key resolves to {@link #FALLBACK_DATA_TYPE} rather than throwing or dropping. + *

An unrecognised key resolves to {@link #fallbackDataType()} rather than throwing or dropping. * Realty adds message keys over time and third-party fire sites may use keys of their own; a * notification the mapper has never seen is still a notification a player should receive.

*/ public final class NotificationCategoryMapper { - /** The data type an unmapped message key routes to. */ - public static final String FALLBACK_DATA_TYPE = "realty.general"; - - /** Every data type this adapter registers, in a stable order. */ - public static final List DATA_TYPES = List.of( - "realty.auction", - "realty.offer", - "realty.lease", - "realty.agent", - "realty.general"); - private static final String DEFAULT_TITLE = "Realty"; + private final Map categories; private final Map keyToDataType; - private final Map dataTypeTitles; private final Map titleOverrides; - private final Map priorities; + private final String fallbackDataType; + /** Declaration order, preserved so registration and unregistration are reproducible. */ + private final List orderedKeys; /** - * @param keyToDataType message key to data type; unlisted keys fall back - * @param dataTypeTitles data type to display title - * @param titleOverrides message key to display title, beating {@code dataTypeTitles} - * @param priorities data type to delivery priority; unlisted data types get 0 + * @param categories the declared categories, in the order they should be registered + * @param titleOverrides message key to title, beating the title of the key's category + * @param fallbackDataType the category unmapped keys route to; must be one of {@code categories} + * @throws IllegalArgumentException if {@code categories} is empty, declares the same category + * key twice, claims one message key from two categories, or if + * {@code fallbackDataType} is not a declared category */ - public NotificationCategoryMapper(@NotNull Map keyToDataType, - @NotNull Map dataTypeTitles, + public NotificationCategoryMapper(@NotNull List categories, @NotNull Map titleOverrides, - @NotNull Map priorities) { - this.keyToDataType = Map.copyOf(Objects.requireNonNull(keyToDataType, "keyToDataType")); - this.dataTypeTitles = Map.copyOf(Objects.requireNonNull(dataTypeTitles, "dataTypeTitles")); + @NotNull String fallbackDataType) { + Objects.requireNonNull(categories, "categories"); + Objects.requireNonNull(fallbackDataType, "fallbackDataType"); + if (categories.isEmpty()) { + throw new IllegalArgumentException( + "categories.yml declares no categories; at least one is required so that " + + "notifications have somewhere to be enqueued"); + } + + Map byKey = new LinkedHashMap<>(); + Map routing = new HashMap<>(); + for (CategoryDefinition category : categories) { + CategoryDefinition previous = byKey.put(category.key(), category); + if (previous != null) { + throw new IllegalArgumentException( + "categories.yml declares the category '" + category.key() + "' twice"); + } + for (String messageKey : category.keys()) { + // Rejected rather than last-wins: which of the two categories a player must enable + // to receive the key would otherwise depend on file order, and neither the operator + // nor the player could tell from the dialogs which one had won. + String claimedBy = routing.put(messageKey, category.key()); + if (claimedBy != null) { + throw new IllegalArgumentException( + "categories.yml routes the message key '" + messageKey + "' to both '" + + claimedBy + "' and '" + category.key() + + "'; a key may belong to exactly one category"); + } + } + } + if (!byKey.containsKey(fallbackDataType)) { + // Failing here beats routing to it at runtime: an undeclared fallback is never + // registered, so every unmapped notification would be enqueued under a data type with + // no serializer and no renderer, and would be lost silently. + throw new IllegalArgumentException( + "categories.yml sets fallback-category to '" + fallbackDataType + + "', which is not one of the declared categories " + byKey.keySet()); + } + + this.orderedKeys = List.copyOf(byKey.keySet()); + this.categories = Map.copyOf(byKey); + this.keyToDataType = Map.copyOf(routing); this.titleOverrides = Map.copyOf(Objects.requireNonNull(titleOverrides, "titleOverrides")); - this.priorities = Map.copyOf(Objects.requireNonNull(priorities, "priorities")); + this.fallbackDataType = fallbackDataType; + } + + /** + * Every data type this adapter registers, in the order {@code categories.yml} declares them. + */ + public @NotNull List dataTypes() { + return this.orderedKeys; + } + + /** The data type unmapped message keys route to. */ + public @NotNull String fallbackDataType() { + return this.fallbackDataType; } /** - * The data type the given message key routes to, or {@link #FALLBACK_DATA_TYPE} if the key is + * The data type the given message key routes to, or {@link #fallbackDataType()} if the key is * not mapped. */ public @NotNull String dataTypeFor(@NotNull String messageKey) { Objects.requireNonNull(messageKey, "messageKey"); - return this.keyToDataType.getOrDefault(messageKey, FALLBACK_DATA_TYPE); + return this.keyToDataType.getOrDefault(messageKey, this.fallbackDataType); } /** * Whether the given message key is explicitly mapped. Callers use this to log the fallback, * because {@link #dataTypeFor} cannot distinguish an unmapped key from one deliberately mapped - * to {@link #FALLBACK_DATA_TYPE}. + * to the fallback category. */ public boolean isMapped(@NotNull String messageKey) { return this.keyToDataType.containsKey(Objects.requireNonNull(messageKey, "messageKey")); } /** - * The title to render for the given message key: its own override if it has one, otherwise the - * title of its data type, otherwise a plain default. + * The title to render for the given message key: its own override if it has one, otherwise its + * category's title, otherwise a plain default. */ public @NotNull String titleFor(@NotNull String messageKey) { String override = this.titleOverrides.get(Objects.requireNonNull(messageKey, "messageKey")); if (override != null) { return override; } - return this.dataTypeTitles.getOrDefault(dataTypeFor(messageKey), DEFAULT_TITLE); + CategoryDefinition category = category(dataTypeFor(messageKey)); + String title = category == null ? "" : category.effectiveTitle(); + return title.isBlank() ? DEFAULT_TITLE : title; } /** - * The delivery priority for the given message key's data type; 0 when unconfigured. + * The delivery priority for the given message key's category; 0 when unconfigured. */ public int priorityFor(@NotNull String messageKey) { - return this.priorities.getOrDefault(dataTypeFor(messageKey), 0); + CategoryDefinition category = category(dataTypeFor(messageKey)); + return category == null ? 0 : category.priority(); + } + + /** The preference-dialog label for a data type; the data type itself when it has no label. */ + public @NotNull String labelFor(@NotNull String dataType) { + CategoryDefinition category = category(dataType); + return category == null || category.label().isBlank() ? dataType : category.label(); + } + + /** The preference-dialog description for a data type; empty when it has none. */ + public @NotNull String descriptionFor(@NotNull String dataType) { + CategoryDefinition category = category(dataType); + return category == null ? "" : category.description(); + } + + private @Nullable CategoryDefinition category(@NotNull String dataType) { + return this.categories.get(Objects.requireNonNull(dataType, "dataType")); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java index bef2b69..fcef0c2 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java @@ -4,7 +4,6 @@ import io.github.md5sha256.playernotifications.api.NotificationService; import io.github.md5sha256.realty.Realty; import org.bukkit.Bukkit; -import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; @@ -12,14 +11,12 @@ import java.io.IOException; import java.io.InputStream; +import java.io.Reader; import java.io.UncheckedIOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; import java.time.Duration; -import java.util.HashMap; -import java.util.Map; -import java.util.Objects; /** * Delivers Realty notifications through the PlayerNotifications plugin, so players get per-category @@ -28,10 +25,15 @@ */ public final class PlayerNotificationsAdapterModule extends SimplePluginModule { - private static final String CATEGORIES_FILE = "categories.yml"; - private static final int DEFAULT_EXPIRY_DAYS = 30; + private static final String CATEGORIES_FILE = CategoriesConfig.CATEGORIES_FILE; private @Nullable NotificationService service; + /** + * The mapper the live registrations were made from — never re-read on shutdown. See + * {@link RealtyDataTypes}: tearing down with a mapper built from a newer {@code categories.yml} + * would orphan any data type the operator removed in between. + */ + private @Nullable NotificationCategoryMapper registeredMapper; /** * {@inheritDoc} @@ -49,8 +51,9 @@ public final class PlayerNotificationsAdapterModule extends SimplePluginModule * - * @throws IllegalStateException if PlayerNotifications is absent, disabled, or has not - * registered its service + * @throws IllegalStateException if PlayerNotifications is absent, disabled, or has not + * registered its service + * @throws IllegalArgumentException if {@code categories.yml} is not a usable category set */ @Override public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { @@ -71,14 +74,17 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { + "player-notifications-adapter cannot start"); } - // 2. Load the message-key -> dataType mapping from the module's data folder. + // 2. Load the operator's category set. This decides which data types exist, not just how + // message keys route between them. YamlConfiguration config = loadCategoriesConfig(dataFolder); - NotificationCategoryMapper categoryMapper = readMapper(config); - Duration expiry = Duration.ofDays(config.getLong("expiry-days", DEFAULT_EXPIRY_DAYS)); + NotificationCategoryMapper categoryMapper = CategoriesConfig.readMapper(config); + Duration expiry = CategoriesConfig.readExpiry(config); // 3. Register payload types, renderers and categories. - RealtyDataTypes.registerAll(notificationService, new RealtyNotificationRenderer(categoryMapper)); + RealtyDataTypes.registerAll( + notificationService, categoryMapper, new RealtyNotificationRenderer(categoryMapper)); this.service = notificationService; + this.registeredMapper = categoryMapper; // 4. Only now, with nothing left that can throw, does a live listener appear. registerListener(new PlayerNotificationsListener( @@ -92,12 +98,14 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { public void shutdown(@NotNull Realty plugin) { unregisterListeners(); NotificationService notificationService = this.service; - if (notificationService != null) { - // All five, never a subset — see RealtyDataTypes for why a partial unregister silently - // corrupts the registry for the data types left behind. - RealtyDataTypes.unregisterAll(notificationService.dataTypeRegistry()); - RealtyDataTypes.unclaimAll(notificationService.categoryRegistry()); + NotificationCategoryMapper mapper = this.registeredMapper; + if (notificationService != null && mapper != null) { + // The whole set, never a subset — see RealtyDataTypes for why a partial unregister + // silently corrupts the registry for the data types left behind. + RealtyDataTypes.unregisterAll(notificationService.dataTypeRegistry(), mapper); + RealtyDataTypes.unclaimAll(notificationService.categoryRegistry(), mapper); this.service = null; + this.registeredMapper = null; } super.shutdown(plugin); } @@ -121,44 +129,11 @@ public void shutdown(@NotNull Realty plugin) { Files.copy(defaults, file, StandardCopyOption.REPLACE_EXISTING); } } - return YamlConfiguration.loadConfiguration(Files.newBufferedReader(file)); + try (Reader reader = Files.newBufferedReader(file)) { + return CategoriesConfig.load(reader); + } } catch (IOException ex) { throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); } } - - /** - * Builds the mapper from a loaded {@code categories.yml}. - */ - static @NotNull NotificationCategoryMapper readMapper(@NotNull YamlConfiguration config) { - Objects.requireNonNull(config, "config"); - return new NotificationCategoryMapper( - readStrings(config.getConfigurationSection("categories")), - readStrings(config.getConfigurationSection("titles")), - readStrings(config.getConfigurationSection("title-overrides")), - readInts(config.getConfigurationSection("priorities"))); - } - - private static @NotNull Map readStrings(@Nullable ConfigurationSection section) { - Map values = new HashMap<>(); - if (section != null) { - for (String key : section.getKeys(false)) { - String value = section.getString(key); - if (value != null && !value.isBlank()) { - values.put(key, value); - } - } - } - return values; - } - - private static @NotNull Map readInts(@Nullable ConfigurationSection section) { - Map values = new HashMap<>(); - if (section != null) { - for (String key : section.getKeys(false)) { - values.put(key, section.getInt(key, 0)); - } - } - return values; - } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java index b1f3eee..52bfa1d 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java @@ -6,48 +6,44 @@ import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; import org.jetbrains.annotations.NotNull; -import java.util.Map; - /** - * Registers and unregisters Realty's five PlayerNotifications data types. + * Registers and unregisters the PlayerNotifications data types {@code categories.yml} declares. + * + *

Every method takes the {@link NotificationCategoryMapper} the data types came from, and no + * method holds a list of its own. That is what makes the category set operator-configurable: adding + * a category to the file adds it here, with its configured label and description, without a code + * change.

* - *

The shutdown footgun. All five data types share one payload class, + *

The shared payload class footgun. All data types share one payload class, * {@link RealtyNotificationPayload}. {@code NotificationDataTypeRegistry} keys serializers and * renderers by payload class but the payload mapping by data type string, so * {@code unregisterPayloadMapping("realty.auction")} does not just drop that one mapping — it * cascades into {@code unregisterSerializer}/{@code unregisterRenderer} on the shared class, - * silently leaving the other four data types mapped but with no serializer and no renderer. Every + * silently leaving every other data type mapped but with no serializer and no renderer. Every * notification they carry then fails at enqueue or render time.

* - *

Unregistering all five is therefore not a tidiness preference, it is the only correct + *

Unregistering the whole set is therefore not a tidiness preference, it is the only correct * sequence: partially unregistering is what corrupts the registry. {@link - * #unregisterAll(NotificationDataTypeRegistry)} exists so no call site can get that wrong, and - * {@code RegistrationLifecycleTest} asserts the hazard so it stays documented executably. This is - * a sharp edge in the PlayerNotifications API, not in this module.

+ * #unregisterAll(NotificationDataTypeRegistry, NotificationCategoryMapper)} exists so no call site + * can get that wrong, and {@code RegistrationLifecycleTest} asserts the hazard so it stays + * documented executably. This is a sharp edge in the PlayerNotifications API, not in this + * module.

+ * + *

Reloads must unregister the mapper they registered. Because the set is now read from + * config, a reload that removes or renames a category produces a mapper that no longer knows about + * the data types actually in the registry. Passing the new mapper to + * {@code unregisterAll} would leave those orphaned and mapped to a dead class loader's renderer. + * {@code PlayerNotificationsAdapterModule} keeps the mapper it registered with and tears down with + * that one.

*/ public final class RealtyDataTypes { - /** Category labels, keyed by data type — shown in PN's preference dialogs. */ - private static final Map LABELS = Map.of( - "realty.auction", "Realty auctions", - "realty.offer", "Realty offers", - "realty.lease", "Realty leases", - "realty.agent", "Realty agents", - "realty.general", "Realty"); - - private static final Map DESCRIPTIONS = Map.of( - "realty.auction", "Bids, auction outcomes and bid payment deadlines", - "realty.offer", "Offers on your regions and offer payment deadlines", - "realty.lease", "Rent, lease expiry, terminations and modification proposals", - "realty.agent", "Agent invitations and removals", - "realty.general", "Purchases, ownership transfers and anything uncategorised"); - private RealtyDataTypes() { } /** - * Binds every Realty data type to {@link RealtyNotificationPayload} and claims it under a - * matching category. + * Binds every declared data type to {@link RealtyNotificationPayload} and claims it under a + * category carrying its configured label and description. * *

Uses {@code registerJsonRenderable}, never {@code registerJsonPayload}: an explicit * processor wins dispatch precedence and bypasses preferences and sinks entirely, which would @@ -57,23 +53,25 @@ private RealtyDataTypes() { * what makes this module safe to declare {@code reloadable: true}.

*/ public static void registerAll(@NotNull NotificationService service, + @NotNull NotificationCategoryMapper mapper, @NotNull NotificationRenderer renderer) { NotificationCategoryRegistry categories = service.categoryRegistry(); - for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + for (String dataType : mapper.dataTypes()) { service.registerJsonRenderable(dataType, RealtyNotificationPayload.class, renderer); categories.registerCategory(dataType, - LABELS.getOrDefault(dataType, dataType), - DESCRIPTIONS.getOrDefault(dataType, "")); + mapper.labelFor(dataType), + mapper.descriptionFor(dataType)); categories.claimDataType(dataType, dataType); } } /** - * Unregisters all five data types. See the class javadoc: doing this partially - * corrupts the registry for the data types left behind. + * Unregisters every data type the given mapper declares. See the class javadoc: doing + * this partially corrupts the registry for the data types left behind. */ - public static void unregisterAll(@NotNull NotificationDataTypeRegistry registry) { - for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + public static void unregisterAll(@NotNull NotificationDataTypeRegistry registry, + @NotNull NotificationCategoryMapper mapper) { + for (String dataType : mapper.dataTypes()) { registry.unregisterPayloadMapping(dataType); } // The cascade above already removed the shared serializer and renderer, but say so @@ -86,8 +84,9 @@ public static void unregisterAll(@NotNull NotificationDataTypeRegistry registry) /** * Releases each category's claim on its data type. */ - public static void unclaimAll(@NotNull NotificationCategoryRegistry categories) { - for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + public static void unclaimAll(@NotNull NotificationCategoryRegistry categories, + @NotNull NotificationCategoryMapper mapper) { + for (String dataType : mapper.dataTypes()) { categories.unclaimDataType(dataType, dataType); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml index a7cfc88..a4654a8 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml +++ b/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml @@ -1,73 +1,99 @@ -# Maps a Realty message key (the messages.yml path the notification was rendered from) to the -# PlayerNotifications dataType it is enqueued under. The dataType is what PN groups by in its -# preference dialogs, so this is how an operator decides how finely players may opt in and out. +# Realty -> PlayerNotifications categories. # -# A key that is absent here routes to realty.general and is logged at FINE — never dropped. -# Adding a key that is not one of Realty's own is harmless; it simply never matches. +# Each category below is registered with PlayerNotifications as a dataType: the unit players opt in +# and out of in /notifications preferences. Categories are read from this file, not compiled in, so +# adding, removing, renaming or re-splitting one needs no new build of the adapter. +# +# Per category: +# label the name a player sees in /notifications preferences +# description the explanation shown beneath that name +# title the heading on the delivered notification itself; defaults to `label` when omitted +# priority higher sorts first in the inbox; defaults to 0 +# keys the Realty message keys (messages.yml paths) routed to this category +# +# A message key may belong to exactly one category — the adapter refuses to start on a key claimed +# twice, because which category a player would have to enable would otherwise depend on file order. +# A key listed nowhere routes to `fallback-category` and is logged at FINE; it is never dropped. +# +# Titles are plain text: non-Minecraft sinks flatten components, so they must not depend on click or +# hover events to make sense. + +# Where unlisted message keys go. Must be one of the categories declared below — the adapter fails +# to start otherwise, rather than enqueueing into a dataType that was never registered. +fallback-category: realty.general + +# How long an enqueued notification stays in the inbox before PN expires it. +expiry-days: 30 + categories: - # realty.agent — agent invites and removals - notification.agent-invited: realty.agent - notification.agent-invite-accepted: realty.agent - notification.agent-invite-rejected: realty.agent - notification.agent-invite-withdrawn: realty.agent - notification.agent-removed: realty.agent - # realty.auction — bidding, auction outcomes, bid payment expiry - notification.outbid: realty.auction - notification.auction-cancelled: realty.auction - notification.auction-won: realty.auction - notification.auction-ended-no-bids: realty.auction - notification.bid-payment-expired: realty.auction + realty.agent: + label: "Realty agents" + description: "Agent invitations and removals" + title: "Realty — Agents" + priority: 0 + keys: + - notification.agent-invited + - notification.agent-invite-accepted + - notification.agent-invite-rejected + - notification.agent-invite-withdrawn + - notification.agent-removed - # realty.offer — offers and offer payment expiry - notification.offer-placed: realty.offer - notification.offer-accepted: realty.offer - notification.offer-rejected: realty.offer - notification.offer-withdrawn: realty.offer - notification.offer-payment-expired: realty.offer + realty.auction: + label: "Realty auctions" + description: "Bids, auction outcomes and bid payment deadlines" + title: "Realty — Auction" + priority: 1 + keys: + - notification.outbid + - notification.auction-cancelled + - notification.auction-won + - notification.auction-ended-no-bids + - notification.bid-payment-expired - # realty.lease — leasehold lifecycle, modification proposals, terminations - notification.region-rented: realty.lease - notification.region-unrented: realty.lease - notification.leasehold-expired: realty.lease - notification.leasehold-expired-landlord: realty.lease - notification.modify-proposed-landlord: realty.lease - notification.modify-proposed-tenant: realty.lease - notification.modify-accepted: realty.lease - notification.modify-rejected: realty.lease - notification.modify-withdrawn: realty.lease - notification.termination-scheduled-tenant: realty.lease - notification.termination-scheduled-landlord: realty.lease - notification.termination-cancelled: realty.lease - notification.leasehold-terminated-tenant: realty.lease - notification.leasehold-terminated-landlord: realty.lease + realty.offer: + label: "Realty offers" + description: "Offers on your regions and offer payment deadlines" + title: "Realty — Offer" + priority: 1 + keys: + - notification.offer-placed + - notification.offer-accepted + - notification.offer-rejected + - notification.offer-withdrawn + - notification.offer-payment-expired - # realty.general — freehold sales, and the fallback for anything unmapped - notification.region-bought: realty.general - notification.ownership-transferred: realty.general + realty.lease: + label: "Realty leases" + description: "Rent, lease expiry, terminations and modification proposals" + title: "Realty — Lease" + priority: 1 + keys: + - notification.region-rented + - notification.region-unrented + - notification.leasehold-expired + - notification.leasehold-expired-landlord + - notification.modify-proposed-landlord + - notification.modify-proposed-tenant + - notification.modify-accepted + - notification.modify-rejected + - notification.modify-withdrawn + - notification.termination-scheduled-tenant + - notification.termination-scheduled-landlord + - notification.termination-cancelled + - notification.leasehold-terminated-tenant + - notification.leasehold-terminated-landlord -# Display titles, keyed by dataType, with an optional per-message-key override below. -# Titles are plain text: non-Minecraft sinks flatten components, so they must not depend on -# click or hover events to make sense. -titles: - realty.agent: "Realty — Agents" - realty.auction: "Realty — Auction" - realty.offer: "Realty — Offer" - realty.lease: "Realty — Lease" - realty.general: "Realty" + realty.general: + label: "Realty" + description: "Purchases, ownership transfers and anything uncategorised" + title: "Realty" + priority: 0 + keys: + - notification.region-bought + - notification.ownership-transferred -# Per-message-key title overrides. Beats the dataType title above. +# Per-message-key title overrides. Beats the title of the key's category. title-overrides: notification.auction-won: "Realty — Auction won" notification.outbid: "Realty — You were outbid" - -# Per-dataType delivery priority. Higher sorts first in the inbox. -priorities: - realty.agent: 0 - realty.auction: 1 - realty.offer: 1 - realty.lease: 1 - realty.general: 0 - -# How long an enqueued notification stays in the inbox before PN expires it. -expiry-days: 30 diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java new file mode 100644 index 0000000..6ee7262 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java @@ -0,0 +1,137 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.util.List; + +/** + * Covers the parse from {@code categories.yml} to a {@link NotificationCategoryMapper}. + * + *

{@code YamlConfiguration} needs no running server, so this exercises the real parser rather + * than a stand-in.

+ */ +class CategoriesConfigTest { + + private static NotificationCategoryMapper parse(String yaml) { + return CategoriesConfig.readMapper(CategoriesConfig.load(new StringReader(yaml))); + } + + /** + * The file the module writes into the operator's data folder on first start must itself be a + * valid category set — a default that fails validation would break every fresh install. + */ + @Test + void theBundledDefaultParsesAndDeclaresEveryCategory() throws IOException { + try (InputStream stream = CategoriesConfigTest.class.getClassLoader() + .getResourceAsStream("categories.yml")) { + Assertions.assertNotNull(stream, "categories.yml is missing from the jar"); + try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + NotificationCategoryMapper mapper = + CategoriesConfig.readMapper(CategoriesConfig.load(reader)); + + Assertions.assertEquals( + List.of("realty.agent", "realty.auction", "realty.offer", "realty.lease", + "realty.general"), + mapper.dataTypes()); + Assertions.assertEquals("realty.general", mapper.fallbackDataType()); + Assertions.assertEquals("realty.auction", mapper.dataTypeFor("notification.outbid")); + Assertions.assertEquals("realty.lease", + mapper.dataTypeFor("notification.leasehold-terminated-tenant")); + Assertions.assertEquals("Realty — You were outbid", + mapper.titleFor("notification.outbid")); + Assertions.assertEquals("Realty auctions", mapper.labelFor("realty.auction")); + Assertions.assertEquals(1, mapper.priorityFor("notification.outbid")); + } + } + } + + @Test + void anOperatorAddedCategoryIsParsedWithItsMetadata() { + NotificationCategoryMapper mapper = parse(""" + fallback-category: realty.general + categories: + realty.general: + label: "Realty" + description: "Everything else" + keys: + - notification.region-bought + realty.staff: + label: "Staff alerts" + description: "For staff only" + title: "Staff" + priority: 9 + keys: + - notification.outbid + """); + + Assertions.assertEquals(List.of("realty.general", "realty.staff"), mapper.dataTypes()); + Assertions.assertEquals("realty.staff", mapper.dataTypeFor("notification.outbid")); + Assertions.assertEquals("Staff", mapper.titleFor("notification.outbid")); + Assertions.assertEquals("For staff only", mapper.descriptionFor("realty.staff")); + Assertions.assertEquals(9, mapper.priorityFor("notification.outbid")); + } + + @Test + void anOmittedTitleAndPriorityTakeTheirDefaults() { + NotificationCategoryMapper mapper = parse(""" + fallback-category: realty.general + categories: + realty.general: + label: "Realty" + keys: + - notification.region-bought + """); + + Assertions.assertEquals("Realty", mapper.titleFor("notification.region-bought")); + Assertions.assertEquals(0, mapper.priorityFor("notification.region-bought")); + Assertions.assertEquals("", mapper.descriptionFor("realty.general")); + } + + /** + * {@code fallback-category} is optional; leaving it out keeps the historical behaviour of + * routing unmapped keys to {@code realty.general}. + */ + @Test + void anOmittedFallbackDefaultsToGeneral() { + NotificationCategoryMapper mapper = parse(""" + categories: + realty.general: + label: "Realty" + """); + + Assertions.assertEquals("realty.general", mapper.fallbackDataType()); + } + + @Test + void aFileWithNoCategoriesSectionIsRejected() { + IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, + () -> parse("expiry-days: 30\n")); + + Assertions.assertTrue(thrown.getMessage().contains("categories"), thrown.getMessage()); + } + + /** + * The pre-1.4.2 file mapped a message key straight to a category name. Parsing that as the new + * shape would silently produce categories named after message keys, so it is rejected with a + * message that names the format change. + */ + @Test + void theOldFlatFormatIsRejectedWithAnExplanation() { + IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, + () -> parse(""" + categories: + notification.outbid: realty.auction + notification.region-bought: realty.general + """)); + + Assertions.assertTrue(thrown.getMessage().contains("notification.outbid"), thrown.getMessage()); + Assertions.assertTrue(thrown.getMessage().contains("pre-1.4.2"), thrown.getMessage()); + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java index e829672..35cb613 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java @@ -3,26 +3,14 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; class NotificationCategoryMapperTest { - private static NotificationCategoryMapper defaults() { - return new NotificationCategoryMapper( - Map.of("notification.outbid", "realty.auction", - "notification.offer-placed", "realty.offer", - "notification.leasehold-expired", "realty.lease", - "notification.agent-invited", "realty.agent", - "notification.region-bought", "realty.general"), - Map.of("realty.auction", "Realty — Auction", - "realty.general", "Realty"), - Map.of(), - Map.of("realty.auction", 1)); - } - @Test void eachCategoryResolvesFromARepresentativeKey() { - NotificationCategoryMapper mapper = defaults(); + NotificationCategoryMapper mapper = TestCategories.defaults(); Assertions.assertEquals("realty.auction", mapper.dataTypeFor("notification.outbid")); Assertions.assertEquals("realty.offer", mapper.dataTypeFor("notification.offer-placed")); @@ -32,8 +20,8 @@ void eachCategoryResolvesFromARepresentativeKey() { } @Test - void anUnmappedKeyFallsBackToGeneral() { - NotificationCategoryMapper mapper = defaults(); + void anUnmappedKeyFallsBackToTheFallbackCategory() { + NotificationCategoryMapper mapper = TestCategories.defaults(); Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.some-future-key")); Assertions.assertFalse(mapper.isMapped("notification.some-future-key")); @@ -43,22 +31,21 @@ void anUnmappedKeyFallsBackToGeneral() { @Test void aConfigOverrideBeatsTheDefault() { NotificationCategoryMapper mapper = new NotificationCategoryMapper( - Map.of("notification.outbid", "realty.general"), - Map.of("realty.auction", "Realty — Auction", "realty.general", "Realty"), + List.of(TestCategories.category("realty.general", "Realty", "notification.outbid")), Map.of(), - Map.of()); + "realty.general"); Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.outbid")); } @Test - void aTitleOverrideBeatsTheDataTypeTitle() { + void aTitleOverrideBeatsTheCategoryTitle() { NotificationCategoryMapper mapper = new NotificationCategoryMapper( - Map.of("notification.outbid", "realty.auction", - "notification.auction-won", "realty.auction"), - Map.of("realty.auction", "Realty — Auction"), + List.of(new CategoryDefinition("realty.auction", "Realty auctions", "", + "Realty — Auction", 0, + List.of("notification.outbid", "notification.auction-won"))), Map.of("notification.auction-won", "Realty — Auction won"), - Map.of()); + "realty.auction"); Assertions.assertEquals("Realty — Auction won", mapper.titleFor("notification.auction-won")); Assertions.assertEquals("Realty — Auction", mapper.titleFor("notification.outbid")); @@ -66,10 +53,106 @@ void aTitleOverrideBeatsTheDataTypeTitle() { @Test void anUnconfiguredTitleAndPriorityFallBack() { - NotificationCategoryMapper mapper = defaults(); + NotificationCategoryMapper mapper = TestCategories.defaults(); Assertions.assertEquals("Realty", mapper.titleFor("notification.some-future-key")); Assertions.assertEquals(1, mapper.priorityFor("notification.outbid")); Assertions.assertEquals(0, mapper.priorityFor("notification.offer-placed")); } + + /** + * A category that declares only a label still gets that label on the notification itself, rather + * than a generic "Realty" that would tell the player nothing about where it came from. + */ + @Test + void aCategoryWithNoTitleRendersUnderItsLabel() { + NotificationCategoryMapper mapper = new NotificationCategoryMapper( + List.of(TestCategories.category("realty.staff", "Staff alerts", "notification.custom")), + Map.of(), + "realty.staff"); + + Assertions.assertEquals("Staff alerts", mapper.titleFor("notification.custom")); + } + + /** + * The whole point of the config change: an operator-declared category is a first-class data type, + * so it appears in {@link NotificationCategoryMapper#dataTypes()} and therefore gets registered. + */ + @Test + void anOperatorDeclaredCategoryBecomesARegisteredDataType() { + NotificationCategoryMapper mapper = new NotificationCategoryMapper( + List.of(TestCategories.category("realty.general", "Realty"), + new CategoryDefinition("realty.staff", "Staff alerts", + "Notifications only staff care about", "Staff", 5, + List.of("notification.outbid"))), + Map.of(), + "realty.general"); + + Assertions.assertEquals(List.of("realty.general", "realty.staff"), mapper.dataTypes()); + Assertions.assertEquals("realty.staff", mapper.dataTypeFor("notification.outbid")); + Assertions.assertEquals("Staff alerts", mapper.labelFor("realty.staff")); + Assertions.assertEquals("Notifications only staff care about", + mapper.descriptionFor("realty.staff")); + Assertions.assertEquals(5, mapper.priorityFor("notification.outbid")); + } + + @Test + void declarationOrderIsPreserved() { + NotificationCategoryMapper mapper = new NotificationCategoryMapper( + List.of(TestCategories.category("z.last", "Z"), + TestCategories.category("a.first", "A"), + TestCategories.category("realty.general", "Realty")), + Map.of(), + "realty.general"); + + Assertions.assertEquals(List.of("z.last", "a.first", "realty.general"), mapper.dataTypes()); + } + + /** + * An undeclared fallback would be registered nowhere, so every unmapped notification would be + * enqueued under a data type with no serializer and no renderer, and lost silently. + */ + @Test + void anUndeclaredFallbackCategoryIsRejected() { + IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, + () -> new NotificationCategoryMapper( + List.of(TestCategories.category("realty.auction", "Realty auctions")), + Map.of(), + "realty.general")); + + Assertions.assertTrue(thrown.getMessage().contains("realty.general"), thrown.getMessage()); + } + + @Test + void aMessageKeyClaimedByTwoCategoriesIsRejected() { + IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, + () -> new NotificationCategoryMapper( + List.of(TestCategories.category("realty.general", "Realty", "notification.outbid"), + TestCategories.category("realty.auction", "Auctions", "notification.outbid")), + Map.of(), + "realty.general")); + + Assertions.assertTrue(thrown.getMessage().contains("notification.outbid"), thrown.getMessage()); + } + + @Test + void anEmptyCategorySetIsRejected() { + Assertions.assertThrows(IllegalArgumentException.class, + () -> new NotificationCategoryMapper(List.of(), Map.of(), "realty.general")); + } + + /** + * A category may exist purely so players can be given a switch for keys the operator has not + * routed to it yet. + */ + @Test + void aCategoryThatClaimsNoKeysIsStillRegistered() { + NotificationCategoryMapper mapper = new NotificationCategoryMapper( + List.of(TestCategories.category("realty.general", "Realty"), + TestCategories.category("realty.spare", "Spare")), + Map.of(), + "realty.general"); + + Assertions.assertTrue(mapper.dataTypes().contains("realty.spare")); + } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java index 47be7a0..3b70c4b 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java @@ -16,11 +16,12 @@ class PlayerNotificationsListenerTest { private static final NotificationCategoryMapper MAPPER = new NotificationCategoryMapper( - Map.of("notification.outbid", "realty.auction", - "notification.region-bought", "realty.general"), - Map.of("realty.auction", "Realty — Auction"), + List.of(new CategoryDefinition("realty.auction", "Realty auctions", "", + "Realty — Auction", 3, List.of("notification.outbid")), + new CategoryDefinition("realty.general", "Realty", "", "Realty", 0, + List.of("notification.region-bought"))), Map.of(), - Map.of("realty.auction", 3)); + "realty.general"); private static PlayerNotificationsListener listener( List> enqueued, diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java index a4f9dee..ced7755 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java @@ -9,6 +9,7 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.util.List; import java.util.Map; import java.util.UUID; @@ -18,9 +19,10 @@ */ class RegistrationLifecycleTest { + private static final NotificationCategoryMapper MAPPER = TestCategories.defaults(); + private static final NotificationRenderer RENDERER = - new RealtyNotificationRenderer(new NotificationCategoryMapper( - Map.of(), Map.of(), Map.of(), Map.of())); + new RealtyNotificationRenderer(MAPPER); /** * Stands in for the reflective JSON serializer {@code registerJsonRenderable} installs; only its @@ -43,9 +45,9 @@ class RegistrationLifecycleTest { * Mirrors what {@code NotificationService.registerJsonRenderable} does to the registry: bind the * data type to the payload class, and register a serializer and renderer for that class. */ - private static NotificationDataTypeRegistry registerAllFive() { + private static NotificationDataTypeRegistry registerAll(NotificationCategoryMapper mapper) { NotificationDataTypeRegistry registry = new NotificationDataTypeRegistry(); - for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + for (String dataType : mapper.dataTypes()) { registry.registerPayloadMapping(dataType, RealtyNotificationPayload.class); registry.registerSerializer(RealtyNotificationPayload.class, SERIALIZER); registry.registerRenderer(RealtyNotificationPayload.class, RENDERER); @@ -54,22 +56,22 @@ private static NotificationDataTypeRegistry registerAllFive() { } @Test - void allFiveDataTypesRegister() { - NotificationDataTypeRegistry registry = registerAllFive(); + void everyDeclaredDataTypeRegisters() { + NotificationDataTypeRegistry registry = registerAll(MAPPER); - for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + for (String dataType : MAPPER.dataTypes()) { Assertions.assertTrue(registry.dataTypes().contains(dataType), dataType); Assertions.assertTrue(registry.getSerializer(dataType).isPresent(), dataType); Assertions.assertTrue(registry.getRenderer(dataType).isPresent(), dataType); } - Assertions.assertEquals(5, registry.dataTypes().size()); + Assertions.assertEquals(MAPPER.dataTypes().size(), registry.dataTypes().size()); } @Test - void unregisteringAllFiveLeavesTheRegistryClean() { - NotificationDataTypeRegistry registry = registerAllFive(); + void unregisteringTheWholeSetLeavesTheRegistryClean() { + NotificationDataTypeRegistry registry = registerAll(MAPPER); - RealtyDataTypes.unregisterAll(registry); + RealtyDataTypes.unregisterAll(registry, MAPPER); Assertions.assertEquals(Map.of().keySet(), registry.dataTypes()); Assertions.assertTrue(registry.getSerializer(RealtyNotificationPayload.class).isEmpty()); @@ -77,44 +79,44 @@ void unregisteringAllFiveLeavesTheRegistryClean() { } @Test - void unregisteringAllFiveIsIdempotent() { - NotificationDataTypeRegistry registry = registerAllFive(); + void unregisteringTheWholeSetIsIdempotent() { + NotificationDataTypeRegistry registry = registerAll(MAPPER); - RealtyDataTypes.unregisterAll(registry); - RealtyDataTypes.unregisterAll(registry); + RealtyDataTypes.unregisterAll(registry, MAPPER); + RealtyDataTypes.unregisterAll(registry, MAPPER); Assertions.assertTrue(registry.dataTypes().isEmpty()); } @Test void reRegisteringOverAnExistingRegistrationIsIdempotent() { - NotificationDataTypeRegistry registry = registerAllFive(); + NotificationDataTypeRegistry registry = registerAll(MAPPER); - for (String dataType : NotificationCategoryMapper.DATA_TYPES) { + for (String dataType : MAPPER.dataTypes()) { registry.registerPayloadMapping(dataType, RealtyNotificationPayload.class); } // Plain map puts, which is what makes `reloadable: true` safe for this module. - Assertions.assertEquals(5, registry.dataTypes().size()); + Assertions.assertEquals(MAPPER.dataTypes().size(), registry.dataTypes().size()); Assertions.assertTrue(registry.getRenderer("realty.auction").isPresent()); } /** - * Documents the PlayerNotifications footgun executably: all five data types share one payload + * Documents the PlayerNotifications footgun executably: every data type shares one payload * class, and the registry keys serializers and renderers by class while keying the * payload mapping by data type. Dropping one data type therefore rips the shared serializer and - * renderer out from under the other four, which stay mapped but can no longer be serialized or - * rendered. This is exactly why {@link RealtyDataTypes#unregisterAll} unregisters all five. + * renderer out from under the rest, which stay mapped but can no longer be serialized or + * rendered. This is exactly why {@link RealtyDataTypes#unregisterAll} takes the whole set. */ @Test - void aPartialUnregisterSilentlyBreaksTheOtherFourDataTypes() { - NotificationDataTypeRegistry registry = registerAllFive(); + void aPartialUnregisterSilentlyBreaksTheRemainingDataTypes() { + NotificationDataTypeRegistry registry = registerAll(MAPPER); registry.unregisterPayloadMapping("realty.auction"); Assertions.assertFalse(registry.dataTypes().contains("realty.auction")); - Assertions.assertEquals(4, registry.dataTypes().size()); - for (String survivor : NotificationCategoryMapper.DATA_TYPES) { + Assertions.assertEquals(MAPPER.dataTypes().size() - 1, registry.dataTypes().size()); + for (String survivor : MAPPER.dataTypes()) { if (survivor.equals("realty.auction")) { continue; } @@ -126,6 +128,30 @@ void aPartialUnregisterSilentlyBreaksTheOtherFourDataTypes() { } } + /** + * The reload hazard the configurable category set introduces: if teardown used a mapper rebuilt + * from an edited {@code categories.yml}, a category the operator deleted would be left + * registered, mapped to a renderer on a class loader that is about to be closed. The module + * therefore keeps the mapper it registered with — which is what this asserts. + */ + @Test + void tearingDownWithANewerMapperOrphansARemovedCategory() { + NotificationDataTypeRegistry registry = registerAll(MAPPER); + NotificationCategoryMapper afterOperatorDeletedAuctions = new NotificationCategoryMapper( + List.of(TestCategories.category("realty.general", "Realty", "notification.region-bought")), + Map.of(), + "realty.general"); + + RealtyDataTypes.unregisterAll(registry, afterOperatorDeletedAuctions); + + Assertions.assertTrue(registry.dataTypes().contains("realty.auction"), + "realty.auction was registered but the newer mapper does not know to remove it"); + + // The mapper that registered them removes them all. + RealtyDataTypes.unregisterAll(registry, MAPPER); + Assertions.assertTrue(registry.dataTypes().isEmpty()); + } + @Test void theRendererProducesATitleAndTheVerbatimBody() { Component message = Component.text("You were outbid"); @@ -135,6 +161,6 @@ void theRendererProducesATitleAndTheVerbatimBody() { RenderableNotification rendered = RENDERER.render(payload, UUID.randomUUID()); Assertions.assertEquals(message.compact(), rendered.body().compact()); - Assertions.assertEquals(Component.text("Realty"), rendered.title()); + Assertions.assertEquals(Component.text("Realty — Auction"), rendered.title()); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java new file mode 100644 index 0000000..4100269 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java @@ -0,0 +1,34 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import java.util.List; +import java.util.Map; + +/** + * Builders for the category sets the tests exercise, so each test states only the part it cares + * about rather than repeating a full five-category declaration. + */ +final class TestCategories { + + private TestCategories() { + } + + /** A category with a label and title matching its key and no priority. */ + static CategoryDefinition category(String key, String label, String... keys) { + return new CategoryDefinition(key, label, "", "", 0, List.of(keys)); + } + + /** A representative one-key-per-category set mirroring the shipped defaults. */ + static NotificationCategoryMapper defaults() { + return new NotificationCategoryMapper( + List.of(new CategoryDefinition("realty.auction", "Realty auctions", + "Bids and outcomes", "Realty — Auction", 1, + List.of("notification.outbid")), + category("realty.offer", "Realty offers", "notification.offer-placed"), + category("realty.lease", "Realty leases", "notification.leasehold-expired"), + category("realty.agent", "Realty agents", "notification.agent-invited"), + new CategoryDefinition("realty.general", "Realty", "Everything else", + "Realty", 0, List.of("notification.region-bought"))), + Map.of(), + "realty.general"); + } +} From 95c89fadc370ee7248a8211a5d3a4fe8231c0791 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:32:28 +1000 Subject: [PATCH 08/14] feat: give every operator config a regenerated reference copy Realty core already wrote defaults/default-.yml for four of its six configs; database.yml and region-tags.yml had none, and player-notifications-adapter had none at all. Since a live config is seeded once and then never rewritten -- correctly, it holds the operator's edits -- an upgrade's new keys were invisible unless the operator read the source. Every operator-editable config now ships an untouched copy of the bundled default beside it, rewritten on every start rather than only when absent: a copy left over from an older version answers "what does a current file look like?" wrongly, which is worse than not having one. The adapter's file handling moves from the module into CategoriesConfig so it can be tested without dragging plugin-infrastructure onto the test classpath. Tests cover both halves of the contract -- the operator's file survives a restart untouched, the reference copy does not -- and assert the reference copy itself parses, since one that would fail to load documents a lie. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- .../playernotifs/CategoriesConfig.java | 57 ++++++++++++ .../PlayerNotificationsAdapterModule.java | 37 +------- .../playernotifs/ReferenceCopyTest.java | 89 +++++++++++++++++++ .../io/github/md5sha256/realty/Realty.java | 6 ++ 4 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java index c0a6b0a..89f76e5 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java @@ -7,8 +7,12 @@ import org.jetbrains.annotations.Nullable; import java.io.IOException; +import java.io.InputStream; import java.io.Reader; import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; @@ -27,6 +31,12 @@ public final class CategoriesConfig { static final String CATEGORIES_FILE = "categories.yml"; + /** + * Where the always-current reference copy is written, mirroring the {@code defaults/} folder + * {@code Realty} itself writes for {@code messages.yml} and friends. + */ + static final String DEFAULTS_DIR = "defaults"; + static final String REFERENCE_FILE = "default-categories.yml"; private static final String DEFAULT_FALLBACK = "realty.general"; private static final int DEFAULT_EXPIRY_DAYS = 30; @@ -62,6 +72,53 @@ private CategoriesConfig() { return config; } + /** + * Reads the operator's {@code categories.yml}, writing the bundled default there first if they + * have none, and refreshing the reference copy beside it either way. + */ + public static @NotNull YamlConfiguration read(@NotNull Path dataFolder) { + Objects.requireNonNull(dataFolder, "dataFolder"); + Path file = dataFolder.resolve(CATEGORIES_FILE); + try { + Files.createDirectories(dataFolder); + if (!Files.exists(file)) { + copyBundled(file); + } + writeReferenceCopy(dataFolder); + try (Reader reader = Files.newBufferedReader(file)) { + return load(reader); + } + } catch (IOException ex) { + throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); + } + } + + /** + * Writes {@code defaults/default-categories.yml}, overwriting any previous copy. + * + *

Rewritten on every start rather than only when absent: its whole purpose is to show what a + * current, fully-populated file looks like, so an operator can diff their own against it after + * an upgrade. A copy left over from an older version would answer that question wrongly, which + * is worse than not being there at all. Nothing ever reads it back — only {@link + * #CATEGORIES_FILE} is loaded — so editing it has no effect and clobbering it loses nothing.

+ */ + public static void writeReferenceCopy(@NotNull Path dataFolder) throws IOException { + Path defaults = dataFolder.resolve(DEFAULTS_DIR); + Files.createDirectories(defaults); + copyBundled(defaults.resolve(REFERENCE_FILE)); + } + + private static void copyBundled(@NotNull Path target) throws IOException { + try (InputStream bundled = CategoriesConfig.class.getClassLoader() + .getResourceAsStream(CATEGORIES_FILE)) { + if (bundled == null) { + throw new IllegalStateException( + "player-notifications-adapter jar is missing its bundled " + CATEGORIES_FILE); + } + Files.copy(bundled, target, StandardCopyOption.REPLACE_EXISTING); + } + } + /** * Builds the mapper from a loaded {@code categories.yml}. * diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java index fcef0c2..66087e9 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java @@ -9,13 +9,7 @@ import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.io.UncheckedIOException; -import java.nio.file.Files; import java.nio.file.Path; -import java.nio.file.StandardCopyOption; import java.time.Duration; /** @@ -25,8 +19,6 @@ */ public final class PlayerNotificationsAdapterModule extends SimplePluginModule { - private static final String CATEGORIES_FILE = CategoriesConfig.CATEGORIES_FILE; - private @Nullable NotificationService service; /** * The mapper the live registrations were made from — never re-read on shutdown. See @@ -76,7 +68,7 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { // 2. Load the operator's category set. This decides which data types exist, not just how // message keys route between them. - YamlConfiguration config = loadCategoriesConfig(dataFolder); + YamlConfiguration config = CategoriesConfig.read(dataFolder); NotificationCategoryMapper categoryMapper = CategoriesConfig.readMapper(config); Duration expiry = CategoriesConfig.readExpiry(config); @@ -109,31 +101,4 @@ public void shutdown(@NotNull Realty plugin) { } super.shutdown(plugin); } - - /** - * Reads {@code categories.yml} from the module's data folder, writing the bundled default there - * first if the operator has none. - */ - private static @NotNull YamlConfiguration loadCategoriesConfig(@NotNull Path dataFolder) { - Path file = dataFolder.resolve(CATEGORIES_FILE); - try { - if (!Files.exists(file)) { - Files.createDirectories(dataFolder); - try (InputStream defaults = PlayerNotificationsAdapterModule.class - .getClassLoader() - .getResourceAsStream(CATEGORIES_FILE)) { - if (defaults == null) { - throw new IllegalStateException( - "player-notifications-adapter jar is missing its bundled " + CATEGORIES_FILE); - } - Files.copy(defaults, file, StandardCopyOption.REPLACE_EXISTING); - } - } - try (Reader reader = Files.newBufferedReader(file)) { - return CategoriesConfig.load(reader); - } - } catch (IOException ex) { - throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); - } - } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java new file mode 100644 index 0000000..9771b63 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java @@ -0,0 +1,89 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.Reader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +/** + * Covers the reference copy every config file must ship: a regenerated {@code defaults/} copy an + * operator can diff their own file against after an upgrade. + */ +class ReferenceCopyTest { + + private static Path reference(Path dataFolder) { + return dataFolder.resolve(CategoriesConfig.DEFAULTS_DIR).resolve(CategoriesConfig.REFERENCE_FILE); + } + + @Test + void aFirstStartWritesBothTheLiveFileAndTheReferenceCopy(@TempDir Path dataFolder) { + CategoriesConfig.read(dataFolder); + + Assertions.assertTrue(Files.isRegularFile(dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE))); + Assertions.assertTrue(Files.isRegularFile(reference(dataFolder))); + } + + /** + * The operator's own file is theirs — first start seeds it and nothing overwrites it afterwards. + */ + @Test + void aLaterStartLeavesTheOperatorsFileAlone(@TempDir Path dataFolder) throws IOException { + CategoriesConfig.read(dataFolder); + Path live = dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE); + String edited = Files.readString(live, StandardCharsets.UTF_8) + .replace("Realty auctions", "Auction stuff"); + Files.writeString(live, edited, StandardCharsets.UTF_8); + + CategoriesConfig.read(dataFolder); + + Assertions.assertEquals(edited, Files.readString(live, StandardCharsets.UTF_8)); + } + + /** + * The reference copy is the opposite: stale is worse than absent, since its only job is to answer + * "what does a current file look like?". + */ + @Test + void aStaleReferenceCopyIsOverwrittenOnEveryStart(@TempDir Path dataFolder) throws IOException { + CategoriesConfig.read(dataFolder); + Files.writeString(reference(dataFolder), "# left over from an older version\n", + StandardCharsets.UTF_8); + + CategoriesConfig.read(dataFolder); + + String refreshed = Files.readString(reference(dataFolder), StandardCharsets.UTF_8); + Assertions.assertFalse(refreshed.contains("left over")); + Assertions.assertTrue(refreshed.contains("fallback-category")); + } + + /** The shipped reference must itself be loadable, or it documents a file that would not start. */ + @Test + void theReferenceCopyParses(@TempDir Path dataFolder) throws IOException { + CategoriesConfig.read(dataFolder); + + try (Reader reader = Files.newBufferedReader(reference(dataFolder))) { + NotificationCategoryMapper mapper = CategoriesConfig.readMapper(CategoriesConfig.load(reader)); + Assertions.assertFalse(mapper.dataTypes().isEmpty()); + } + } + + @Test + void theReferenceCopyIsWrittenEvenWhenTheOperatorAlreadyHasAFile(@TempDir Path dataFolder) + throws IOException { + Files.createDirectories(dataFolder); + Files.writeString(dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE), """ + categories: + realty.general: + label: "Realty" + """, StandardCharsets.UTF_8); + + CategoriesConfig.read(dataFolder); + + Assertions.assertTrue(Files.isRegularFile(reference(dataFolder))); + } +} 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 f32e660..2c23187 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 @@ -205,10 +205,16 @@ public TaxSettings taxSettings() { public void onLoad() { try { initDataFolder(); + // Every operator-editable config gets a reference copy under defaults/, rewritten on + // every load so it always shows what a current, fully-populated file looks like. The + // live files themselves are seeded once by copyDefaultsYaml and then left alone, so + // this is the only way an operator can see what an upgrade added. copyResourceTemplate("messages.yml", "defaults/default-messages.yml"); copyResourceTemplate("settings.yml", "defaults/default-settings.yml"); copyResourceTemplate("profiles.yml", "defaults/default-profiles.yml"); copyResourceTemplate("taxes.yml", "defaults/default-taxes.yml"); + copyResourceTemplate("database.yml", "defaults/default-database.yml"); + copyResourceTemplate("region-tags.yml", "defaults/default-region-tags.yml"); reloadMessages(); this.databaseSettings = loadDatabaseSettings(); this.settings.set(loadSettings()); From 53a243b821ed2ebfd6bcb6813bf6ba20ba673ed3 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sat, 22 Aug 2026 21:35:09 +1000 Subject: [PATCH 09/14] fix: self-heal a pre-1.4.2 categories.yml, and gate EssentialsX mail An operator upgrading had the old flat categories.yml on disk, so the parse threw and took the whole module down on start. CategoriesConfig now detects that format structurally -- any direct child of `categories` that is not a section -- backs the file up as categories.yml.pre-1.4.2.bak, and writes the current default in its place. Replaced rather than converted, and this costs the operator nothing: the old format never took effect. Bukkit splits configuration keys on '.' as it loads and every key in that file contained a dot, so the routing map always parsed empty and every notification fell through to the fallback category regardless of what was written. There is no working configuration in it to carry over -- only the operator's intent, which the backup keeps readable. Detection is structural rather than a catch of the parse failure, so the decision to rewrite someone's file is never made from an exception a different mistake could also produce. A missing categories section stays an error. Also adds essentials-adapter/config.yml with notifications-enabled (default true), for servers where another delivery module already covers offline players and the same notification would otherwise arrive twice. It gates only mail delivery: the teleport safety predicate the module installs is a correctness fix rather than a delivery channel, so it applies either way. Defaulting to true keeps existing installs behaving as they did. Both configs follow the reference-copy rule. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018VWZAdCQBEFBP9TtVpDwtx --- README.md | 7 ++ .../essentials/EssentialsAdapterConfig.java | 100 +++++++++++++++++ .../essentials/EssentialsAdapterModule.java | 9 ++ .../src/main/resources/config.yml | 13 +++ .../EssentialsAdapterConfigTest.java | 92 +++++++++++++++ .../playernotifs/CategoriesConfig.java | 67 ++++++++++- .../PlayerNotificationsAdapterModule.java | 2 +- .../LegacyFormatMigrationTest.java | 106 ++++++++++++++++++ .../playernotifs/ReferenceCopyTest.java | 17 +-- 9 files changed, 401 insertions(+), 12 deletions(-) create mode 100644 realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java create mode 100644 realty-paper-adapters/essentials-adapter/src/main/resources/config.yml create mode 100644 realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfigTest.java create mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java diff --git a/README.md b/README.md index 18b1895..3e17f84 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,13 @@ and `fallback-category` must name one of the declared categories — the adapter instead of enqueueing notifications nobody can receive. A key you list nowhere routes to the fallback and is never dropped. +### Turning off EssentialsX mail delivery + +`essentials-adapter` writes a `config.yml` into its data folder. Setting `notifications-enabled: false` +stops Realty notifications being delivered as EssentialsX mail — useful when another delivery module +already covers offline players and you do not want the same notification arriving twice. The module's +teleport-safety integration is not affected by the setting and always applies. + ## Documentation ### Getting Started diff --git a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java new file mode 100644 index 0000000..ddeb346 --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java @@ -0,0 +1,100 @@ +package io.github.md5sha256.realty.adapter.essentials; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Objects; + +/** + * Reads the module's {@code config.yml}. + * + *

Kept out of {@link EssentialsAdapterModule} so it can be tested directly: the module extends + * {@code SimplePluginModule}, and reaching it from a test would drag {@code plugin-infrastructure} — + * a {@code compileOnly} dependency — onto the test classpath.

+ */ +public final class EssentialsAdapterConfig { + + static final String CONFIG_FILE = "config.yml"; + /** See {@code CategoriesConfig}: every operator config ships a regenerated reference copy. */ + static final String DEFAULTS_DIR = "defaults"; + static final String REFERENCE_FILE = "default-config.yml"; + + private static final String NOTIFICATIONS_ENABLED = "notifications-enabled"; + + private final boolean notificationsEnabled; + + EssentialsAdapterConfig(boolean notificationsEnabled) { + this.notificationsEnabled = notificationsEnabled; + } + + /** + * Whether Realty notifications are delivered as EssentialsX mail. + * + *

Only mail delivery is switchable. The teleport-safety integration this module also installs + * is not affected: it is a correctness fix rather than a delivery channel, and a server running + * EssentialsX wants EssentialsX's own block checks whatever it does about notifications.

+ */ + public boolean notificationsEnabled() { + return this.notificationsEnabled; + } + + /** + * Reads the operator's {@code config.yml}, writing the bundled default there first if they have + * none, and refreshing the reference copy beside it either way. + */ + public static @NotNull EssentialsAdapterConfig read(@NotNull Path dataFolder) { + Objects.requireNonNull(dataFolder, "dataFolder"); + Path file = dataFolder.resolve(CONFIG_FILE); + try { + Files.createDirectories(dataFolder); + if (!Files.exists(file)) { + copyBundled(file); + } + writeReferenceCopy(dataFolder); + try (Reader reader = Files.newBufferedReader(file)) { + return from(YamlConfiguration.loadConfiguration(reader)); + } + } catch (IOException ex) { + throw new UncheckedIOException("Failed to read " + CONFIG_FILE, ex); + } + } + + /** + * Builds the settings from a loaded {@code config.yml}. + * + *

Defaults to enabled, so an operator whose file predates this setting keeps the behaviour + * they already had rather than silently losing mail delivery on upgrade.

+ */ + static @NotNull EssentialsAdapterConfig from(@NotNull YamlConfiguration config) { + Objects.requireNonNull(config, "config"); + return new EssentialsAdapterConfig(config.getBoolean(NOTIFICATIONS_ENABLED, true)); + } + + /** + * Writes {@code defaults/default-config.yml}, overwriting any previous copy. Rewritten on every + * start so it always shows what a current file looks like; never read back. + */ + public static void writeReferenceCopy(@NotNull Path dataFolder) throws IOException { + Path defaults = dataFolder.resolve(DEFAULTS_DIR); + Files.createDirectories(defaults); + copyBundled(defaults.resolve(REFERENCE_FILE)); + } + + private static void copyBundled(@NotNull Path target) throws IOException { + try (InputStream bundled = EssentialsAdapterConfig.class.getClassLoader() + .getResourceAsStream(CONFIG_FILE)) { + if (bundled == null) { + throw new IllegalStateException( + "essentials-adapter jar is missing its bundled " + CONFIG_FILE); + } + Files.copy(bundled, target, StandardCopyOption.REPLACE_EXISTING); + } + } +} 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 2a7f2d3..b22639d 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,20 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { throw new IllegalStateException( "EssentialsX is not installed or not enabled — essentials-adapter cannot start"); } + EssentialsAdapterConfig config = EssentialsAdapterConfig.read(dataFolder); // 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)); + if (!config.notificationsEnabled()) { + // Teleport safety above still applies — only mail delivery is switchable. Logged so an + // operator wondering where their mail went is not left guessing. + plugin.getLogger().info( + "essentials-adapter: notifications-enabled is false, so Realty notifications will " + + "not be delivered as EssentialsX mail. Teleport safety is unaffected."); + return; + } registerListener(new EssentialsMailListener( (uuid, text) -> sendMail(essentials, uuid, text), uuid -> Bukkit.getPlayer(uuid) != null, diff --git a/realty-paper-adapters/essentials-adapter/src/main/resources/config.yml b/realty-paper-adapters/essentials-adapter/src/main/resources/config.yml new file mode 100644 index 0000000..54d9efd --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/src/main/resources/config.yml @@ -0,0 +1,13 @@ +# essentials-adapter +# +# This module does two separate things. Only the first is switchable here. +# +# 1. Notification delivery: Realty notifications for players who are offline are sent as EssentialsX +# mail, so they are waiting at next login instead of being lost. +# 2. Teleport safety: Realty uses EssentialsX's own block checks when looking for a safe location. +# That is a correctness fix rather than a delivery channel, so it always applies and this file +# has no setting for it. +# +# Turn notifications off if another delivery module already covers offline players and you do not +# want the same notification arriving twice. +notifications-enabled: true diff --git a/realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfigTest.java b/realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfigTest.java new file mode 100644 index 0000000..6863ae5 --- /dev/null +++ b/realty-paper-adapters/essentials-adapter/src/test/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfigTest.java @@ -0,0 +1,92 @@ +package io.github.md5sha256.realty.adapter.essentials; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +class EssentialsAdapterConfigTest { + + private static EssentialsAdapterConfig parse(String yaml) { + return EssentialsAdapterConfig.from( + YamlConfiguration.loadConfiguration(new StringReader(yaml))); + } + + private static Path reference(Path dataFolder) { + return dataFolder.resolve(EssentialsAdapterConfig.DEFAULTS_DIR) + .resolve(EssentialsAdapterConfig.REFERENCE_FILE); + } + + @Test + void notificationsCanBeTurnedOff() { + Assertions.assertFalse(parse("notifications-enabled: false\n").notificationsEnabled()); + } + + @Test + void notificationsCanBeTurnedOn() { + Assertions.assertTrue(parse("notifications-enabled: true\n").notificationsEnabled()); + } + + /** + * An operator whose file predates this setting keeps the behaviour they already had, rather than + * silently losing mail delivery on upgrade. + */ + @Test + void anAbsentSettingDefaultsToEnabled() { + Assertions.assertTrue(parse("# nothing here\n").notificationsEnabled()); + } + + @Test + void aFirstStartWritesBothTheLiveFileAndTheReferenceCopy(@TempDir Path dataFolder) { + EssentialsAdapterConfig config = EssentialsAdapterConfig.read(dataFolder); + + Assertions.assertTrue( + Files.isRegularFile(dataFolder.resolve(EssentialsAdapterConfig.CONFIG_FILE))); + Assertions.assertTrue(Files.isRegularFile(reference(dataFolder))); + Assertions.assertTrue(config.notificationsEnabled(), "the shipped default is enabled"); + } + + @Test + void aLaterStartLeavesTheOperatorsFileAlone(@TempDir Path dataFolder) throws IOException { + EssentialsAdapterConfig.read(dataFolder); + Path live = dataFolder.resolve(EssentialsAdapterConfig.CONFIG_FILE); + Files.writeString(live, "notifications-enabled: false\n", StandardCharsets.UTF_8); + + EssentialsAdapterConfig config = EssentialsAdapterConfig.read(dataFolder); + + Assertions.assertFalse(config.notificationsEnabled()); + Assertions.assertEquals("notifications-enabled: false\n", + Files.readString(live, StandardCharsets.UTF_8)); + } + + @Test + void aStaleReferenceCopyIsOverwrittenOnEveryStart(@TempDir Path dataFolder) throws IOException { + EssentialsAdapterConfig.read(dataFolder); + Files.writeString(reference(dataFolder), "# left over\n", StandardCharsets.UTF_8); + + EssentialsAdapterConfig.read(dataFolder); + + String refreshed = Files.readString(reference(dataFolder), StandardCharsets.UTF_8); + Assertions.assertFalse(refreshed.contains("left over")); + Assertions.assertTrue(refreshed.contains("notifications-enabled")); + } + + /** A reference copy that would not load documents a lie. */ + @Test + void theReferenceCopyParses(@TempDir Path dataFolder) throws IOException { + EssentialsAdapterConfig.read(dataFolder); + + try (Reader reader = Files.newBufferedReader(reference(dataFolder))) { + Assertions.assertTrue( + EssentialsAdapterConfig.from(YamlConfiguration.loadConfiguration(reader)) + .notificationsEnabled()); + } + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java index 89f76e5..0ea0ec2 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java @@ -19,6 +19,8 @@ import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.logging.Level; +import java.util.logging.Logger; /** * Parses the module's {@code categories.yml} into a {@link NotificationCategoryMapper}. @@ -37,6 +39,7 @@ public final class CategoriesConfig { */ static final String DEFAULTS_DIR = "defaults"; static final String REFERENCE_FILE = "default-categories.yml"; + static final String LEGACY_BACKUP_SUFFIX = ".pre-1.4.2.bak"; private static final String DEFAULT_FALLBACK = "realty.general"; private static final int DEFAULT_EXPIRY_DAYS = 30; @@ -74,10 +77,12 @@ private CategoriesConfig() { /** * Reads the operator's {@code categories.yml}, writing the bundled default there first if they - * have none, and refreshing the reference copy beside it either way. + * have none, refreshing the reference copy beside it either way, and replacing a pre-1.4.2 file + * with the current default. */ - public static @NotNull YamlConfiguration read(@NotNull Path dataFolder) { + public static @NotNull YamlConfiguration read(@NotNull Path dataFolder, @NotNull Logger logger) { Objects.requireNonNull(dataFolder, "dataFolder"); + Objects.requireNonNull(logger, "logger"); Path file = dataFolder.resolve(CATEGORIES_FILE); try { Files.createDirectories(dataFolder); @@ -85,14 +90,68 @@ private CategoriesConfig() { copyBundled(file); } writeReferenceCopy(dataFolder); - try (Reader reader = Files.newBufferedReader(file)) { - return load(reader); + YamlConfiguration config = loadFile(file); + if (isLegacyFormat(config)) { + replaceLegacyFile(file, logger); + config = loadFile(file); } + return config; } catch (IOException ex) { throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); } } + /** + * Whether this is a pre-1.4.2 file, which mapped each message key straight to a category name + * instead of declaring categories as sections. + * + *

Detected structurally — any direct child of {@code categories} that is not itself a section + * — rather than by catching the parse failure, so the decision to rewrite an operator's file is + * never made from an exception that a different mistake could also produce.

+ */ + static boolean isLegacyFormat(@NotNull YamlConfiguration config) { + ConfigurationSection section = config.getConfigurationSection("categories"); + if (section == null) { + return false; + } + for (String key : section.getKeys(false)) { + if (!section.isConfigurationSection(key)) { + return true; + } + } + return false; + } + + /** + * Backs up a pre-1.4.2 file and puts the current default in its place. + * + *

Replaced rather than converted, and this loses the operator nothing: the old format never + * worked. Bukkit splits configuration keys on {@code '.'} as it loads, and every key in that + * file contained a dot, so the routing map always parsed empty and every notification fell + * through to the fallback category no matter what the file said. There is no working + * configuration in it to preserve — only the operator's intent, which the backup keeps + * readable.

+ */ + private static void replaceLegacyFile(@NotNull Path file, @NotNull Logger logger) + throws IOException { + Path backup = file.resolveSibling(CATEGORIES_FILE + LEGACY_BACKUP_SUFFIX); + Files.move(file, backup, StandardCopyOption.REPLACE_EXISTING); + copyBundled(file); + logger.log(Level.WARNING, + "{0} was in the pre-1.4.2 format, which never took effect — Bukkit split its dotted " + + "keys on load, so every notification fell through to the fallback category. " + + "It has been backed up as {1} and replaced with the current default. Re-apply " + + "any routing you intended; {2} shows the current format.", + new Object[]{CATEGORIES_FILE, backup.getFileName(), + DEFAULTS_DIR + "/" + REFERENCE_FILE}); + } + + private static @NotNull YamlConfiguration loadFile(@NotNull Path file) throws IOException { + try (Reader reader = Files.newBufferedReader(file)) { + return load(reader); + } + } + /** * Writes {@code defaults/default-categories.yml}, overwriting any previous copy. * diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java index 66087e9..92daa38 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java @@ -68,7 +68,7 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { // 2. Load the operator's category set. This decides which data types exist, not just how // message keys route between them. - YamlConfiguration config = CategoriesConfig.read(dataFolder); + YamlConfiguration config = CategoriesConfig.read(dataFolder, plugin.getLogger()); NotificationCategoryMapper categoryMapper = CategoriesConfig.readMapper(config); Duration expiry = CategoriesConfig.readExpiry(config); diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java new file mode 100644 index 0000000..45fead7 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java @@ -0,0 +1,106 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.logging.Logger; + +/** + * Covers the replacement of a pre-1.4.2 {@code categories.yml}. + * + *

An operator upgrading has one on disk, and it would otherwise fail the parse and take the whole + * module down on start.

+ */ +class LegacyFormatMigrationTest { + + private static final Logger LOGGER = Logger.getLogger(LegacyFormatMigrationTest.class.getName()); + + private static final String LEGACY = """ + categories: + notification.agent-invited: realty.agent + notification.outbid: realty.auction + titles: + realty.agent: "Realty — Agents" + expiry-days: 30 + """; + + private static Path writeLegacy(Path dataFolder) throws IOException { + Files.createDirectories(dataFolder); + Path file = dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE); + Files.writeString(file, LEGACY, StandardCharsets.UTF_8); + return file; + } + + @Test + void aLegacyFileIsDetected() { + YamlConfiguration legacy = CategoriesConfig.load(new StringReader(LEGACY)); + + Assertions.assertTrue(CategoriesConfig.isLegacyFormat(legacy)); + } + + @Test + void aCurrentFileIsNotMistakenForALegacyOne(@TempDir Path dataFolder) throws IOException { + CategoriesConfig.read(dataFolder, LOGGER); + + YamlConfiguration current = CategoriesConfig.load( + Files.newBufferedReader(dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE))); + + Assertions.assertFalse(CategoriesConfig.isLegacyFormat(current)); + } + + /** A missing categories section is a different failure, and must not trigger a rewrite. */ + @Test + void aFileWithNoCategoriesSectionIsNotTreatedAsLegacy() { + YamlConfiguration empty = CategoriesConfig.load(new StringReader("expiry-days: 30\n")); + + Assertions.assertFalse(CategoriesConfig.isLegacyFormat(empty)); + } + + @Test + void aLegacyFileIsReplacedAndTheModuleStarts(@TempDir Path dataFolder) throws IOException { + writeLegacy(dataFolder); + + NotificationCategoryMapper mapper = + CategoriesConfig.readMapper(CategoriesConfig.read(dataFolder, LOGGER)); + + Assertions.assertTrue(mapper.dataTypes().contains("realty.auction")); + Assertions.assertEquals("realty.auction", mapper.dataTypeFor("notification.outbid")); + } + + @Test + void theLegacyFileIsKeptAsABackup(@TempDir Path dataFolder) throws IOException { + writeLegacy(dataFolder); + + CategoriesConfig.read(dataFolder, LOGGER); + + Path backup = dataFolder.resolve( + CategoriesConfig.CATEGORIES_FILE + CategoriesConfig.LEGACY_BACKUP_SUFFIX); + Assertions.assertTrue(Files.isRegularFile(backup)); + Assertions.assertEquals(LEGACY, Files.readString(backup, StandardCharsets.UTF_8)); + } + + /** + * The replacement runs once. A second start sees a current file and leaves it alone, so an + * operator who re-edits it after the upgrade does not have their work replaced again. + */ + @Test + void aSecondStartDoesNotReplaceTheReplacement(@TempDir Path dataFolder) throws IOException { + writeLegacy(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); + Path live = dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE); + String edited = Files.readString(live, StandardCharsets.UTF_8) + .replace("Realty auctions", "Auction stuff"); + Files.writeString(live, edited, StandardCharsets.UTF_8); + + CategoriesConfig.read(dataFolder, LOGGER); + + Assertions.assertEquals(edited, Files.readString(live, StandardCharsets.UTF_8)); + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java index 9771b63..877e418 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java @@ -9,6 +9,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; +import java.util.logging.Logger; /** * Covers the reference copy every config file must ship: a regenerated {@code defaults/} copy an @@ -16,13 +17,15 @@ */ class ReferenceCopyTest { + private static final Logger LOGGER = Logger.getLogger(ReferenceCopyTest.class.getName()); + private static Path reference(Path dataFolder) { return dataFolder.resolve(CategoriesConfig.DEFAULTS_DIR).resolve(CategoriesConfig.REFERENCE_FILE); } @Test void aFirstStartWritesBothTheLiveFileAndTheReferenceCopy(@TempDir Path dataFolder) { - CategoriesConfig.read(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); Assertions.assertTrue(Files.isRegularFile(dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE))); Assertions.assertTrue(Files.isRegularFile(reference(dataFolder))); @@ -33,13 +36,13 @@ void aFirstStartWritesBothTheLiveFileAndTheReferenceCopy(@TempDir Path dataFolde */ @Test void aLaterStartLeavesTheOperatorsFileAlone(@TempDir Path dataFolder) throws IOException { - CategoriesConfig.read(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); Path live = dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE); String edited = Files.readString(live, StandardCharsets.UTF_8) .replace("Realty auctions", "Auction stuff"); Files.writeString(live, edited, StandardCharsets.UTF_8); - CategoriesConfig.read(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); Assertions.assertEquals(edited, Files.readString(live, StandardCharsets.UTF_8)); } @@ -50,11 +53,11 @@ void aLaterStartLeavesTheOperatorsFileAlone(@TempDir Path dataFolder) throws IOE */ @Test void aStaleReferenceCopyIsOverwrittenOnEveryStart(@TempDir Path dataFolder) throws IOException { - CategoriesConfig.read(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); Files.writeString(reference(dataFolder), "# left over from an older version\n", StandardCharsets.UTF_8); - CategoriesConfig.read(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); String refreshed = Files.readString(reference(dataFolder), StandardCharsets.UTF_8); Assertions.assertFalse(refreshed.contains("left over")); @@ -64,7 +67,7 @@ void aStaleReferenceCopyIsOverwrittenOnEveryStart(@TempDir Path dataFolder) thro /** The shipped reference must itself be loadable, or it documents a file that would not start. */ @Test void theReferenceCopyParses(@TempDir Path dataFolder) throws IOException { - CategoriesConfig.read(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); try (Reader reader = Files.newBufferedReader(reference(dataFolder))) { NotificationCategoryMapper mapper = CategoriesConfig.readMapper(CategoriesConfig.load(reader)); @@ -82,7 +85,7 @@ void theReferenceCopyIsWrittenEvenWhenTheOperatorAlreadyHasAFile(@TempDir Path d label: "Realty" """, StandardCharsets.UTF_8); - CategoriesConfig.read(dataFolder); + CategoriesConfig.read(dataFolder, LOGGER); Assertions.assertTrue(Files.isRegularFile(reference(dataFolder))); } From 60fd8873e3ea137882e0c09fca3fc0875f27ff5b Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:28:55 +1000 Subject: [PATCH 10/14] build: bump version to 1.5.0 1.4.2 shipped from main as the government-account bugfix release, without the notification adapter. This branch adds a delivery module and a new config format, so it is a minor bump, not a patch. Retarget the categories.yml format marker with it. The adapter has never been released, so the format it replaces predates 1.5.0, not 1.4.2 -- including the .pre-1.5.0.bak backup suffix and the operator-facing warning that names the version. Detection itself is format-based, not version-based, so an existing legacy file still migrates. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01SUskvuegwBzQMufaYxp5eM --- .../src/main/kotlin/realty-conventions.gradle.kts | 2 +- .../adapter/playernotifs/CategoriesConfig.java | 14 +++++++------- .../adapter/playernotifs/CategoriesConfigTest.java | 4 ++-- .../playernotifs/LegacyFormatMigrationTest.java | 2 +- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts index b52bb15..4237383 100644 --- a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts @@ -3,7 +3,7 @@ plugins { } group = "io.github.md5sha256" -version = "1.4.2" +version = "1.5.0" val targetJavaVersion = 25 diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java index 0ea0ec2..f607007 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java @@ -39,7 +39,7 @@ public final class CategoriesConfig { */ static final String DEFAULTS_DIR = "defaults"; static final String REFERENCE_FILE = "default-categories.yml"; - static final String LEGACY_BACKUP_SUFFIX = ".pre-1.4.2.bak"; + static final String LEGACY_BACKUP_SUFFIX = ".pre-1.5.0.bak"; private static final String DEFAULT_FALLBACK = "realty.general"; private static final int DEFAULT_EXPIRY_DAYS = 30; @@ -77,7 +77,7 @@ private CategoriesConfig() { /** * Reads the operator's {@code categories.yml}, writing the bundled default there first if they - * have none, refreshing the reference copy beside it either way, and replacing a pre-1.4.2 file + * have none, refreshing the reference copy beside it either way, and replacing a pre-1.5.0 file * with the current default. */ public static @NotNull YamlConfiguration read(@NotNull Path dataFolder, @NotNull Logger logger) { @@ -102,7 +102,7 @@ private CategoriesConfig() { } /** - * Whether this is a pre-1.4.2 file, which mapped each message key straight to a category name + * Whether this is a pre-1.5.0 file, which mapped each message key straight to a category name * instead of declaring categories as sections. * *

Detected structurally — any direct child of {@code categories} that is not itself a section @@ -123,7 +123,7 @@ static boolean isLegacyFormat(@NotNull YamlConfiguration config) { } /** - * Backs up a pre-1.4.2 file and puts the current default in its place. + * Backs up a pre-1.5.0 file and puts the current default in its place. * *

Replaced rather than converted, and this loses the operator nothing: the old format never * worked. Bukkit splits configuration keys on {@code '.'} as it loads, and every key in that @@ -138,7 +138,7 @@ private static void replaceLegacyFile(@NotNull Path file, @NotNull Logger logger Files.move(file, backup, StandardCopyOption.REPLACE_EXISTING); copyBundled(file); logger.log(Level.WARNING, - "{0} was in the pre-1.4.2 format, which never took effect — Bukkit split its dotted " + "{0} was in the pre-1.5.0 format, which never took effect — Bukkit split its dotted " + "keys on load, so every notification fell through to the fallback category. " + "It has been backed up as {1} and replaced with the current default. Re-apply " + "any routing you intended; {2} shows the current format.", @@ -199,9 +199,9 @@ private static void copyBundled(@NotNull Path target) throws IOException { ConfigurationSection entry = section.getConfigurationSection(key); if (entry == null) { throw new IllegalArgumentException( - "Category '" + key + "' in " + CATEGORIES_FILE + " is not a section. Since 1.4.2 a " + "Category '" + key + "' in " + CATEGORIES_FILE + " is not a section. Since 1.5.0 a " + "category declares its own label, description and keys; a bare " - + "'message-key: category' line is the pre-1.4.2 format."); + + "'message-key: category' line is the pre-1.5.0 format."); } categories.add(new CategoryDefinition( key, diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java index 6ee7262..e5cd1d3 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java @@ -118,7 +118,7 @@ void aFileWithNoCategoriesSectionIsRejected() { } /** - * The pre-1.4.2 file mapped a message key straight to a category name. Parsing that as the new + * The pre-1.5.0 file mapped a message key straight to a category name. Parsing that as the new * shape would silently produce categories named after message keys, so it is rejected with a * message that names the format change. */ @@ -132,6 +132,6 @@ void theOldFlatFormatIsRejectedWithAnExplanation() { """)); Assertions.assertTrue(thrown.getMessage().contains("notification.outbid"), thrown.getMessage()); - Assertions.assertTrue(thrown.getMessage().contains("pre-1.4.2"), thrown.getMessage()); + Assertions.assertTrue(thrown.getMessage().contains("pre-1.5.0"), thrown.getMessage()); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java index 45fead7..84c66d8 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java @@ -13,7 +13,7 @@ import java.util.logging.Logger; /** - * Covers the replacement of a pre-1.4.2 {@code categories.yml}. + * Covers the replacement of a pre-1.5.0 {@code categories.yml}. * *

An operator upgrading has one on disk, and it would otherwise fail the parse and take the whole * module down on start.

From 1042a70a6b029c024c7df623d813705ba0878280 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:47:36 +1000 Subject: [PATCH 11/14] build: resolve MCCities snapshots, and get them in before jitpack Two separate reasons player-notifications-api:1.1.0-SNAPSHOT would not resolve. Only mccities-releases was declared, and the snapshot lives in the snapshots repository beside it. jitpack answers 401 rather than 404 for coordinates it does not host, and Gradle treats a 401 as fatal instead of moving on to the next repository. It sat above MCCities in the order, so it killed resolution before MCCities was ever consulted -- for any new dependency, not just this one. Both MCCities repositories now come first. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016k8N9JrH8fHmQVe3RxJpzD --- .../src/main/kotlin/realty-conventions.gradle.kts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts index 4237383..a24bedd 100644 --- a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts +++ b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts @@ -24,6 +24,14 @@ repositories { name = "papermc-repo" url = uri("https://repo.papermc.io/repository/maven-public/") } + maven { + name = "mccities-releases" + url = uri("https://maven.minecraftcitiesnetwork.com/releases") + } + maven { + name = "mccities-snapshots" + url = uri("https://maven.minecraftcitiesnetwork.com/snapshots") + } maven { name = "jitpack" url = uri("https://jitpack.io") @@ -44,10 +52,6 @@ repositories { name = "paradaux-snapshots" url = uri("https://repo.paradaux.io/snapshots") } - maven { - name = "mccities-releases" - url = uri("https://maven.minecraftcitiesnetwork.com/releases") - } } dependencies { From f155e19455ec041455c278f6a5af285542f4de81 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:47:48 +1000 Subject: [PATCH 12/14] feat(player-notifications-adapter): manage categories in PlayerNotifications PlayerNotifications 1.1.0 lets a module register its categories and its data type display names in code, dumps them to a generated categories-defaults.yml and type-names-defaults.yml for the operator to reconcile, and rebuilds its merged snapshot when a plugin registers after startup. That makes this module's own categories.yml redundant: two files claiming the same job, one of which PlayerNotifications cannot see. The category set is now the RealtyCategory enum -- five categories, each registered as both a category and the single dataType it claims, each holding the message keys that route to it. Labels and descriptions are defaults; an operator overrides them, or regroups the data types entirely, in PlayerNotifications' categories.yml. Each data type also carries a display name, without which PN title-cases the registry key and the preference screens read "Realty.auction". Dropped with the file: per-category titles and priorities, which PN is the right place to decide, and the pre-1.5.0 legacy-format backup, which converted a file nothing reads any more. The module keeps a config.yml holding only expiry-days. Because the set is compile-time constant it can no longer drift across a reload, so the module no longer has to tear down with the mapper it registered with. The display name needs its own unregister: it is keyed by data type while the serializer and renderer are keyed by payload class, so the unregisterPayloadMapping cascade does not reach it. An upgrader's categories.yml is left on disk and logged at INFO as no longer read. It holds the grouping they meant, which is what they need in front of them while re-entering it in PN. RealtyCategoryTest asserts the enum covers every notification.* key in messages.yml in both directions, so a new key fails the build until it is given a deliberate category rather than silently falling back to realty.general. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016k8N9JrH8fHmQVe3RxJpzD --- README.md | 28 +- .../essentials/EssentialsAdapterConfig.java | 2 +- .../build.gradle.kts | 4 +- .../adapter/playernotifs/AdapterConfig.java | 102 ++++++++ .../playernotifs/CategoriesConfig.java | 247 ------------------ .../playernotifs/CategoryDefinition.java | 58 ---- .../NotificationCategoryMapper.java | 163 ------------ .../PlayerNotificationsAdapterModule.java | 73 +++--- .../PlayerNotificationsListener.java | 29 +- .../adapter/playernotifs/RealtyCategory.java | 168 ++++++++++++ .../adapter/playernotifs/RealtyDataTypes.java | 69 ++--- .../RealtyNotificationRenderer.java | 27 +- .../src/main/resources/categories.yml | 99 ------- .../src/main/resources/config.yml | 13 + .../playernotifs/CategoriesConfigTest.java | 137 ---------- .../LegacyFormatMigrationTest.java | 106 -------- .../NotificationCategoryMapperTest.java | 158 ----------- .../PlayerNotificationsListenerTest.java | 15 +- .../playernotifs/RealtyCategoryTest.java | 138 ++++++++++ .../playernotifs/ReferenceCopyTest.java | 52 ++-- .../RegistrationLifecycleTest.java | 135 +++++++--- .../adapter/playernotifs/TestCategories.java | 34 --- 22 files changed, 674 insertions(+), 1183 deletions(-) create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/AdapterConfig.java delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/resources/config.yml delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java create mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java delete mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java diff --git a/README.md b/README.md index 3e17f84..4ab258b 100644 --- a/README.md +++ b/README.md @@ -78,17 +78,23 @@ startup while none is. ### Notification categories -`player-notifications-adapter` writes a `categories.yml` into its data folder -(`plugins/Realty/modules/player-notifications-adapter/`) on first start. Every category declared there is -registered with PlayerNotifications as a data type: the unit players switch on and off in -`/notifications preferences`. Each carries its own player-facing label and description, the title shown on the -notification, a delivery priority, and the Realty message keys routed to it. - -The category set is read from that file rather than compiled in, so you can rename a category, split one into -several, or add your own without a new build of the adapter. A message key may belong to exactly one category, -and `fallback-category` must name one of the declared categories — the adapter refuses to start otherwise -instead of enqueueing notifications nobody can receive. A key you list nowhere routes to the fallback and is -never dropped. +`player-notifications-adapter` registers five notification categories with PlayerNotifications — +agents, auctions, offers, leases, and a general catch-all — each one a data type players switch on and +off in `/notifications preferences`. + +**You configure them in PlayerNotifications, not here.** PlayerNotifications writes every category a +module registered into its generated `categories-defaults.yml`; copy the blocks you care about into its +`categories.yml` and edit them there. That is where a label, a description, or a regrouping of Realty's +data types into categories of your own takes effect. + +The adapter's own `config.yml` +(`plugins/Realty/modules/player-notifications-adapter/config.yml`) holds one setting, `expiry-days`: +how long an enqueued notification stays in a player's inbox before PlayerNotifications expires it. + +Realty also supplies a display name for each of its data types; rename one in PlayerNotifications' +`type-names.yml` if you want something different. + +A Realty message key that no category claims still reaches players, routed to the general category. ### Turning off EssentialsX mail delivery diff --git a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java index ddeb346..4c37881 100644 --- a/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java +++ b/realty-paper-adapters/essentials-adapter/src/main/java/io/github/md5sha256/realty/adapter/essentials/EssentialsAdapterConfig.java @@ -22,7 +22,7 @@ public final class EssentialsAdapterConfig { static final String CONFIG_FILE = "config.yml"; - /** See {@code CategoriesConfig}: every operator config ships a regenerated reference copy. */ + /** See the project config rules: every operator config ships a regenerated reference copy. */ static final String DEFAULTS_DIR = "defaults"; static final String REFERENCE_FILE = "default-config.yml"; diff --git a/realty-paper-adapters/player-notifications-adapter/build.gradle.kts b/realty-paper-adapters/player-notifications-adapter/build.gradle.kts index f09295b..559436e 100644 --- a/realty-paper-adapters/player-notifications-adapter/build.gradle.kts +++ b/realty-paper-adapters/player-notifications-adapter/build.gradle.kts @@ -10,9 +10,9 @@ dependencies { compileOnly("io.papermc.paper:paper-api:26.1.2.build.74-stable") compileOnly("org.jetbrains:annotations:26.0.2-1") compileOnly("com.minecraftcitiesnetwork:plugin-infrastructure:1.0.0-SNAPSHOT") - compileOnly("io.github.md5sha256:player-notifications-api:1.0.1") + compileOnly("io.github.md5sha256:player-notifications-api:1.1.0-SNAPSHOT") testImplementation(project(":realty-paper-api")) testImplementation("io.papermc.paper:paper-api:26.1.2.build.74-stable") - testImplementation("io.github.md5sha256:player-notifications-api:1.0.1") + testImplementation("io.github.md5sha256:player-notifications-api:1.1.0-SNAPSHOT") } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/AdapterConfig.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/AdapterConfig.java new file mode 100644 index 0000000..7187697 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/AdapterConfig.java @@ -0,0 +1,102 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.time.Duration; +import java.util.Objects; + +/** + * Reads the module's {@code config.yml}. + * + *

All this file holds is {@code expiry-days}. Categories used to live beside it in a + * {@code categories.yml}; they are now registered in code and presented from PlayerNotifications' own + * {@code categories.yml} — see {@link RealtyCategory}. Expiry stays here because it is neither a + * category nor something PlayerNotifications can infer: it is this adapter's choice of how long a + * Realty notification is worth keeping.

+ * + *

Kept out of {@link PlayerNotificationsAdapterModule} so it can be tested directly: the module + * extends {@code SimplePluginModule}, and reaching it from a test would drag + * {@code plugin-infrastructure} — a {@code compileOnly} dependency — onto the test classpath. Only + * Bukkit's own config classes are needed here, and those run without a server.

+ */ +public final class AdapterConfig { + + static final String CONFIG_FILE = "config.yml"; + /** See the project config rules: every operator config ships a regenerated reference copy. */ + static final String DEFAULTS_DIR = "defaults"; + static final String REFERENCE_FILE = "default-config.yml"; + + private static final String EXPIRY_DAYS = "expiry-days"; + private static final int DEFAULT_EXPIRY_DAYS = 30; + + private final Duration expiry; + + AdapterConfig(@NotNull Duration expiry) { + this.expiry = Objects.requireNonNull(expiry, "expiry"); + } + + /** How long an enqueued notification survives before PlayerNotifications expires it. */ + public @NotNull Duration expiry() { + return this.expiry; + } + + /** + * Reads the operator's {@code config.yml}, writing the bundled default there first if they have + * none, and refreshing the reference copy beside it either way. + */ + public static @NotNull AdapterConfig read(@NotNull Path dataFolder) { + Objects.requireNonNull(dataFolder, "dataFolder"); + Path file = dataFolder.resolve(CONFIG_FILE); + try { + Files.createDirectories(dataFolder); + if (!Files.exists(file)) { + copyBundled(file); + } + writeReferenceCopy(dataFolder); + try (Reader reader = Files.newBufferedReader(file)) { + return from(YamlConfiguration.loadConfiguration(reader)); + } + } catch (IOException ex) { + throw new UncheckedIOException("Failed to read " + CONFIG_FILE, ex); + } + } + + /** + * Builds the settings from a loaded {@code config.yml}, defaulting {@code expiry-days} so a file + * predating this setting — or an operator's file that never had it — still starts. + */ + static @NotNull AdapterConfig from(@NotNull YamlConfiguration config) { + Objects.requireNonNull(config, "config"); + return new AdapterConfig( + Duration.ofDays(config.getLong(EXPIRY_DAYS, DEFAULT_EXPIRY_DAYS))); + } + + /** + * Writes {@code defaults/default-config.yml}, overwriting any previous copy. Rewritten on every + * start so it always shows what a current file looks like; never read back. + */ + public static void writeReferenceCopy(@NotNull Path dataFolder) throws IOException { + Path defaults = dataFolder.resolve(DEFAULTS_DIR); + Files.createDirectories(defaults); + copyBundled(defaults.resolve(REFERENCE_FILE)); + } + + private static void copyBundled(@NotNull Path target) throws IOException { + try (InputStream bundled = AdapterConfig.class.getClassLoader() + .getResourceAsStream(CONFIG_FILE)) { + if (bundled == null) { + throw new IllegalStateException( + "player-notifications-adapter jar is missing its bundled " + CONFIG_FILE); + } + Files.copy(bundled, target, StandardCopyOption.REPLACE_EXISTING); + } + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java deleted file mode 100644 index f607007..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfig.java +++ /dev/null @@ -1,247 +0,0 @@ -package io.github.md5sha256.realty.adapter.playernotifs; - -import org.bukkit.configuration.ConfigurationSection; -import org.bukkit.configuration.InvalidConfigurationException; -import org.bukkit.configuration.file.YamlConfiguration; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.io.IOException; -import java.io.InputStream; -import java.io.Reader; -import java.io.UncheckedIOException; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.StandardCopyOption; -import java.time.Duration; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.logging.Level; -import java.util.logging.Logger; - -/** - * Parses the module's {@code categories.yml} into a {@link NotificationCategoryMapper}. - * - *

Kept out of {@link PlayerNotificationsAdapterModule} so the parse can be tested directly: the - * module extends {@code SimplePluginModule}, and reaching it from a test would drag - * {@code plugin-infrastructure} — a {@code compileOnly} dependency — onto the test classpath. - * Only Bukkit's own config classes are needed here, and those run without a server.

- */ -public final class CategoriesConfig { - - static final String CATEGORIES_FILE = "categories.yml"; - /** - * Where the always-current reference copy is written, mirroring the {@code defaults/} folder - * {@code Realty} itself writes for {@code messages.yml} and friends. - */ - static final String DEFAULTS_DIR = "defaults"; - static final String REFERENCE_FILE = "default-categories.yml"; - static final String LEGACY_BACKUP_SUFFIX = ".pre-1.5.0.bak"; - private static final String DEFAULT_FALLBACK = "realty.general"; - private static final int DEFAULT_EXPIRY_DAYS = 30; - - private CategoriesConfig() { - } - - /** - * Loads {@code categories.yml} with dots treated as ordinary characters rather than as path - * separators. - * - *

Why this cannot be done on an already-loaded configuration. Every key in this file - * — {@code realty.auction}, {@code notification.outbid} — contains a dot, and Bukkit's default - * path separator is a dot. {@code YamlConfiguration} applies the separator while loading - * (each key is {@code set} by path), so {@code realty.auction: {...}} silently becomes a section - * {@code realty} containing {@code auction}, and the top-level key the parser then reads back is - * {@code realty}. Setting the separator after {@code loadConfiguration} is far too late — the - * nesting has already happened. It is set here, on a configuration that has not read anything - * yet, to a character a YAML key cannot contain.

- * - * @throws IllegalArgumentException if the YAML is malformed - */ - public static @NotNull YamlConfiguration load(@NotNull Reader reader) { - Objects.requireNonNull(reader, "reader"); - YamlConfiguration config = new YamlConfiguration(); - config.options().pathSeparator('\u0000'); - try { - config.load(reader); - } catch (InvalidConfigurationException ex) { - throw new IllegalArgumentException(CATEGORIES_FILE + " is not valid YAML", ex); - } catch (IOException ex) { - throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); - } - return config; - } - - /** - * Reads the operator's {@code categories.yml}, writing the bundled default there first if they - * have none, refreshing the reference copy beside it either way, and replacing a pre-1.5.0 file - * with the current default. - */ - public static @NotNull YamlConfiguration read(@NotNull Path dataFolder, @NotNull Logger logger) { - Objects.requireNonNull(dataFolder, "dataFolder"); - Objects.requireNonNull(logger, "logger"); - Path file = dataFolder.resolve(CATEGORIES_FILE); - try { - Files.createDirectories(dataFolder); - if (!Files.exists(file)) { - copyBundled(file); - } - writeReferenceCopy(dataFolder); - YamlConfiguration config = loadFile(file); - if (isLegacyFormat(config)) { - replaceLegacyFile(file, logger); - config = loadFile(file); - } - return config; - } catch (IOException ex) { - throw new UncheckedIOException("Failed to read " + CATEGORIES_FILE, ex); - } - } - - /** - * Whether this is a pre-1.5.0 file, which mapped each message key straight to a category name - * instead of declaring categories as sections. - * - *

Detected structurally — any direct child of {@code categories} that is not itself a section - * — rather than by catching the parse failure, so the decision to rewrite an operator's file is - * never made from an exception that a different mistake could also produce.

- */ - static boolean isLegacyFormat(@NotNull YamlConfiguration config) { - ConfigurationSection section = config.getConfigurationSection("categories"); - if (section == null) { - return false; - } - for (String key : section.getKeys(false)) { - if (!section.isConfigurationSection(key)) { - return true; - } - } - return false; - } - - /** - * Backs up a pre-1.5.0 file and puts the current default in its place. - * - *

Replaced rather than converted, and this loses the operator nothing: the old format never - * worked. Bukkit splits configuration keys on {@code '.'} as it loads, and every key in that - * file contained a dot, so the routing map always parsed empty and every notification fell - * through to the fallback category no matter what the file said. There is no working - * configuration in it to preserve — only the operator's intent, which the backup keeps - * readable.

- */ - private static void replaceLegacyFile(@NotNull Path file, @NotNull Logger logger) - throws IOException { - Path backup = file.resolveSibling(CATEGORIES_FILE + LEGACY_BACKUP_SUFFIX); - Files.move(file, backup, StandardCopyOption.REPLACE_EXISTING); - copyBundled(file); - logger.log(Level.WARNING, - "{0} was in the pre-1.5.0 format, which never took effect — Bukkit split its dotted " - + "keys on load, so every notification fell through to the fallback category. " - + "It has been backed up as {1} and replaced with the current default. Re-apply " - + "any routing you intended; {2} shows the current format.", - new Object[]{CATEGORIES_FILE, backup.getFileName(), - DEFAULTS_DIR + "/" + REFERENCE_FILE}); - } - - private static @NotNull YamlConfiguration loadFile(@NotNull Path file) throws IOException { - try (Reader reader = Files.newBufferedReader(file)) { - return load(reader); - } - } - - /** - * Writes {@code defaults/default-categories.yml}, overwriting any previous copy. - * - *

Rewritten on every start rather than only when absent: its whole purpose is to show what a - * current, fully-populated file looks like, so an operator can diff their own against it after - * an upgrade. A copy left over from an older version would answer that question wrongly, which - * is worse than not being there at all. Nothing ever reads it back — only {@link - * #CATEGORIES_FILE} is loaded — so editing it has no effect and clobbering it loses nothing.

- */ - public static void writeReferenceCopy(@NotNull Path dataFolder) throws IOException { - Path defaults = dataFolder.resolve(DEFAULTS_DIR); - Files.createDirectories(defaults); - copyBundled(defaults.resolve(REFERENCE_FILE)); - } - - private static void copyBundled(@NotNull Path target) throws IOException { - try (InputStream bundled = CategoriesConfig.class.getClassLoader() - .getResourceAsStream(CATEGORIES_FILE)) { - if (bundled == null) { - throw new IllegalStateException( - "player-notifications-adapter jar is missing its bundled " + CATEGORIES_FILE); - } - Files.copy(bundled, target, StandardCopyOption.REPLACE_EXISTING); - } - } - - /** - * Builds the mapper from a loaded {@code categories.yml}. - * - *

Category declaration order is the file's order, which {@code YamlConfiguration} preserves, - * so the registration order an operator reads in the file is the one used at runtime.

- * - * @throws IllegalArgumentException if the file declares no usable category set - */ - public static @NotNull NotificationCategoryMapper readMapper(@NotNull YamlConfiguration config) { - Objects.requireNonNull(config, "config"); - ConfigurationSection section = config.getConfigurationSection("categories"); - if (section == null) { - throw new IllegalArgumentException( - CATEGORIES_FILE + " has no 'categories' section; it must declare at least one category"); - } - - List categories = new ArrayList<>(); - for (String key : section.getKeys(false)) { - ConfigurationSection entry = section.getConfigurationSection(key); - if (entry == null) { - throw new IllegalArgumentException( - "Category '" + key + "' in " + CATEGORIES_FILE + " is not a section. Since 1.5.0 a " - + "category declares its own label, description and keys; a bare " - + "'message-key: category' line is the pre-1.5.0 format."); - } - categories.add(new CategoryDefinition( - key, - orEmpty(entry.getString("label")), - orEmpty(entry.getString("description")), - orEmpty(entry.getString("title")), - entry.getInt("priority", 0), - List.copyOf(entry.getStringList("keys")))); - } - - return new NotificationCategoryMapper( - categories, - readStrings(config.getConfigurationSection("title-overrides")), - orDefault(config.getString("fallback-category"), DEFAULT_FALLBACK)); - } - - /** How long an enqueued notification survives before PlayerNotifications expires it. */ - public static @NotNull Duration readExpiry(@NotNull YamlConfiguration config) { - return Duration.ofDays( - Objects.requireNonNull(config, "config").getLong("expiry-days", DEFAULT_EXPIRY_DAYS)); - } - - private static @NotNull Map readStrings(@Nullable ConfigurationSection section) { - Map values = new HashMap<>(); - if (section != null) { - for (String key : section.getKeys(false)) { - String value = section.getString(key); - if (value != null && !value.isBlank()) { - values.put(key, value); - } - } - } - return values; - } - - private static @NotNull String orEmpty(@Nullable String value) { - return value == null ? "" : value; - } - - private static @NotNull String orDefault(@Nullable String value, @NotNull String fallback) { - return value == null || value.isBlank() ? fallback : value; - } -} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java deleted file mode 100644 index c42d05d..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/CategoryDefinition.java +++ /dev/null @@ -1,58 +0,0 @@ -package io.github.md5sha256.realty.adapter.playernotifs; - -import org.jetbrains.annotations.NotNull; - -import java.util.List; -import java.util.Objects; - -/** - * One category declared in the module's {@code categories.yml}. - * - *

A category is simultaneously two things, which is why its metadata lives in one place rather - * than being split across sections: it is a PlayerNotifications {@code dataType} — the unit players - * opt in and out of in {@code /notifications preferences} — and it is the display grouping those - * dialogs label. {@link #label} and {@link #description} are what a player reads there; - * {@link #title} is what appears on the delivered notification itself.

- * - *

Mirrors the shape of PlayerNotifications' own {@code categories.yml} - * ({@code NotificationCategoryDefinition}) so an operator who has configured one recognises the - * other. The one addition is {@link #keys}: PN groups data types, whereas this module maps Realty - * message keys onto the data type they are enqueued under, so the leaves here are message keys.

- * - * @param key the category key, used verbatim as the PN data type - * @param label player-facing name shown in the preference dialogs - * @param description player-facing explanation shown in the preference dialogs - * @param title heading rendered on the notification; falls back to {@code label} when blank - * @param priority delivery priority for every key in this category; higher sorts first - * @param keys the Realty message keys routed to this category; may be empty - */ -public record CategoryDefinition(@NotNull String key, - @NotNull String label, - @NotNull String description, - @NotNull String title, - int priority, - @NotNull List keys) { - - public CategoryDefinition { - Objects.requireNonNull(key, "key"); - Objects.requireNonNull(label, "label"); - Objects.requireNonNull(description, "description"); - Objects.requireNonNull(title, "title"); - Objects.requireNonNull(keys, "keys"); - if (key.isBlank()) { - throw new IllegalArgumentException("A category key may not be blank"); - } - keys = List.copyOf(keys); - } - - /** - * The heading to render: the configured title, or the label when no title was configured. - * - *

Defaulting to the label rather than to a generic constant means an operator who adds a - * category and gives it only a label still gets that label on the notification, instead of a - * bare {@code "Realty"} that tells the player nothing about which category it came from.

- */ - public @NotNull String effectiveTitle() { - return this.title.isBlank() ? this.label : this.title; - } -} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java deleted file mode 100644 index d1cbe68..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapper.java +++ /dev/null @@ -1,163 +0,0 @@ -package io.github.md5sha256.realty.adapter.playernotifs; - -import org.jetbrains.annotations.NotNull; -import org.jetbrains.annotations.Nullable; - -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.Objects; - -/** - * Resolves a Realty message key to the PlayerNotifications {@code dataType} it is enqueued under, - * and to the label, description, title and priority that data type is registered and rendered with. - * - *

The category set is whatever {@code categories.yml} declares — this class holds no hardcoded - * list. Everything that registers against PlayerNotifications reads {@link #dataTypes()}, so adding - * a category to the file is enough to have it registered, claimed and shown in the preference - * dialogs; nothing needs recompiling.

- * - *

Deliberately a plain class with no PlayerNotifications and no Bukkit types on it: the routing - * decision is the part worth testing, and keeping it free of both means it can be tested without a - * server or a live PN install.

- * - *

An unrecognised key resolves to {@link #fallbackDataType()} rather than throwing or dropping. - * Realty adds message keys over time and third-party fire sites may use keys of their own; a - * notification the mapper has never seen is still a notification a player should receive.

- */ -public final class NotificationCategoryMapper { - - private static final String DEFAULT_TITLE = "Realty"; - - private final Map categories; - private final Map keyToDataType; - private final Map titleOverrides; - private final String fallbackDataType; - /** Declaration order, preserved so registration and unregistration are reproducible. */ - private final List orderedKeys; - - /** - * @param categories the declared categories, in the order they should be registered - * @param titleOverrides message key to title, beating the title of the key's category - * @param fallbackDataType the category unmapped keys route to; must be one of {@code categories} - * @throws IllegalArgumentException if {@code categories} is empty, declares the same category - * key twice, claims one message key from two categories, or if - * {@code fallbackDataType} is not a declared category - */ - public NotificationCategoryMapper(@NotNull List categories, - @NotNull Map titleOverrides, - @NotNull String fallbackDataType) { - Objects.requireNonNull(categories, "categories"); - Objects.requireNonNull(fallbackDataType, "fallbackDataType"); - if (categories.isEmpty()) { - throw new IllegalArgumentException( - "categories.yml declares no categories; at least one is required so that " - + "notifications have somewhere to be enqueued"); - } - - Map byKey = new LinkedHashMap<>(); - Map routing = new HashMap<>(); - for (CategoryDefinition category : categories) { - CategoryDefinition previous = byKey.put(category.key(), category); - if (previous != null) { - throw new IllegalArgumentException( - "categories.yml declares the category '" + category.key() + "' twice"); - } - for (String messageKey : category.keys()) { - // Rejected rather than last-wins: which of the two categories a player must enable - // to receive the key would otherwise depend on file order, and neither the operator - // nor the player could tell from the dialogs which one had won. - String claimedBy = routing.put(messageKey, category.key()); - if (claimedBy != null) { - throw new IllegalArgumentException( - "categories.yml routes the message key '" + messageKey + "' to both '" - + claimedBy + "' and '" + category.key() - + "'; a key may belong to exactly one category"); - } - } - } - if (!byKey.containsKey(fallbackDataType)) { - // Failing here beats routing to it at runtime: an undeclared fallback is never - // registered, so every unmapped notification would be enqueued under a data type with - // no serializer and no renderer, and would be lost silently. - throw new IllegalArgumentException( - "categories.yml sets fallback-category to '" + fallbackDataType - + "', which is not one of the declared categories " + byKey.keySet()); - } - - this.orderedKeys = List.copyOf(byKey.keySet()); - this.categories = Map.copyOf(byKey); - this.keyToDataType = Map.copyOf(routing); - this.titleOverrides = Map.copyOf(Objects.requireNonNull(titleOverrides, "titleOverrides")); - this.fallbackDataType = fallbackDataType; - } - - /** - * Every data type this adapter registers, in the order {@code categories.yml} declares them. - */ - public @NotNull List dataTypes() { - return this.orderedKeys; - } - - /** The data type unmapped message keys route to. */ - public @NotNull String fallbackDataType() { - return this.fallbackDataType; - } - - /** - * The data type the given message key routes to, or {@link #fallbackDataType()} if the key is - * not mapped. - */ - public @NotNull String dataTypeFor(@NotNull String messageKey) { - Objects.requireNonNull(messageKey, "messageKey"); - return this.keyToDataType.getOrDefault(messageKey, this.fallbackDataType); - } - - /** - * Whether the given message key is explicitly mapped. Callers use this to log the fallback, - * because {@link #dataTypeFor} cannot distinguish an unmapped key from one deliberately mapped - * to the fallback category. - */ - public boolean isMapped(@NotNull String messageKey) { - return this.keyToDataType.containsKey(Objects.requireNonNull(messageKey, "messageKey")); - } - - /** - * The title to render for the given message key: its own override if it has one, otherwise its - * category's title, otherwise a plain default. - */ - public @NotNull String titleFor(@NotNull String messageKey) { - String override = this.titleOverrides.get(Objects.requireNonNull(messageKey, "messageKey")); - if (override != null) { - return override; - } - CategoryDefinition category = category(dataTypeFor(messageKey)); - String title = category == null ? "" : category.effectiveTitle(); - return title.isBlank() ? DEFAULT_TITLE : title; - } - - /** - * The delivery priority for the given message key's category; 0 when unconfigured. - */ - public int priorityFor(@NotNull String messageKey) { - CategoryDefinition category = category(dataTypeFor(messageKey)); - return category == null ? 0 : category.priority(); - } - - /** The preference-dialog label for a data type; the data type itself when it has no label. */ - public @NotNull String labelFor(@NotNull String dataType) { - CategoryDefinition category = category(dataType); - return category == null || category.label().isBlank() ? dataType : category.label(); - } - - /** The preference-dialog description for a data type; empty when it has none. */ - public @NotNull String descriptionFor(@NotNull String dataType) { - CategoryDefinition category = category(dataType); - return category == null ? "" : category.description(); - } - - private @Nullable CategoryDefinition category(@NotNull String dataType) { - return this.categories.get(Objects.requireNonNull(dataType, "dataType")); - } -} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java index 92daa38..1091b34 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java @@ -4,28 +4,29 @@ import io.github.md5sha256.playernotifications.api.NotificationService; import io.github.md5sha256.realty.Realty; import org.bukkit.Bukkit; -import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.Plugin; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; +import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; +import java.util.logging.Level; /** * Delivers Realty notifications through the PlayerNotifications plugin, so players get per-category * preferences, sink fan-out, offline delivery and an inbox instead of the all-or-nothing online * chat delivery {@code chat-adapter} provides. + * + *

Categories are registered in code from {@link RealtyCategory} and presented from + * PlayerNotifications' own {@code categories.yml}; this module's {@code config.yml} holds only + * {@code expiry-days}.

*/ public final class PlayerNotificationsAdapterModule extends SimplePluginModule { + /** The pre-1.5.0 category config, read by nothing since categories moved into code. */ + static final String OBSOLETE_CATEGORIES_FILE = "categories.yml"; + private @Nullable NotificationService service; - /** - * The mapper the live registrations were made from — never re-read on shutdown. See - * {@link RealtyDataTypes}: tearing down with a mapper built from a newer {@code categories.yml} - * would orphan any data type the operator removed in between. - */ - private @Nullable NotificationCategoryMapper registeredMapper; /** * {@inheritDoc} @@ -43,9 +44,8 @@ public final class PlayerNotificationsAdapterModule extends SimplePluginModule * - * @throws IllegalStateException if PlayerNotifications is absent, disabled, or has not - * registered its service - * @throws IllegalArgumentException if {@code categories.yml} is not a usable category set + * @throws IllegalStateException if PlayerNotifications is absent, disabled, or has not + * registered its service */ @Override public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { @@ -66,38 +66,51 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { + "player-notifications-adapter cannot start"); } - // 2. Load the operator's category set. This decides which data types exist, not just how - // message keys route between them. - YamlConfiguration config = CategoriesConfig.read(dataFolder, plugin.getLogger()); - NotificationCategoryMapper categoryMapper = CategoriesConfig.readMapper(config); - Duration expiry = CategoriesConfig.readExpiry(config); + // 2. Read expiry. The category set is compiled in, so nothing about it can fail here. + AdapterConfig config = AdapterConfig.read(dataFolder); + warnAboutObsoleteCategoriesFile(plugin, dataFolder); - // 3. Register payload types, renderers and categories. - RealtyDataTypes.registerAll( - notificationService, categoryMapper, new RealtyNotificationRenderer(categoryMapper)); + // 3. Register payload types, renderers and categories. PN's registry notifies its own + // change listener, so registering this late still reaches the preference dialogs. + RealtyDataTypes.registerAll(notificationService, new RealtyNotificationRenderer()); this.service = notificationService; - this.registeredMapper = categoryMapper; // 4. Only now, with nothing left that can throw, does a live listener appear. registerListener(new PlayerNotificationsListener( - notificationService::enqueueNotification, - categoryMapper, - expiry, - plugin.getLogger())); + notificationService::enqueueNotification, config.expiry(), plugin.getLogger())); + } + + /** + * Tells an upgrading operator that their {@code categories.yml} is now inert. + * + *

The file is left on disk rather than deleted or backed up. It is the operator's, it holds + * the grouping they meant, and that grouping is exactly what they need in front of them while + * they re-enter it in PlayerNotifications' {@code categories.yml}. Tidying it away is not this + * module's call.

+ */ + private void warnAboutObsoleteCategoriesFile(@NotNull Realty plugin, @NotNull Path dataFolder) { + if (!Files.exists(dataFolder.resolve(OBSOLETE_CATEGORIES_FILE))) { + return; + } + plugin.getLogger().log(Level.INFO, + "{0} in this module''s folder is no longer read: Realty now registers its notification " + + "categories with PlayerNotifications, which presents them from its own " + + "categories.yml. Copy the grouping you want out of the blocks PlayerNotifications " + + "writes to categories-defaults.yml, then delete this file.", + OBSOLETE_CATEGORIES_FILE); } @Override public void shutdown(@NotNull Realty plugin) { unregisterListeners(); NotificationService notificationService = this.service; - NotificationCategoryMapper mapper = this.registeredMapper; - if (notificationService != null && mapper != null) { + if (notificationService != null) { // The whole set, never a subset — see RealtyDataTypes for why a partial unregister - // silently corrupts the registry for the data types left behind. - RealtyDataTypes.unregisterAll(notificationService.dataTypeRegistry(), mapper); - RealtyDataTypes.unclaimAll(notificationService.categoryRegistry(), mapper); + // silently corrupts the registry for the data types left behind. The set is compile-time + // constant, so unlike the config-driven version this can no longer drift across a reload. + RealtyDataTypes.unregisterAll(notificationService.dataTypeRegistry()); + RealtyDataTypes.unclaimAll(notificationService.categoryRegistry()); this.service = null; - this.registeredMapper = null; } super.shutdown(plugin); } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java index aba0d76..b91d597 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java @@ -19,7 +19,7 @@ /** * Turns each Realty notification into exactly one PlayerNotifications notification, routed to the - * data type its message key maps to. + * data type its message key's {@link RealtyCategory} maps to. * *

Unlike the chat adapter, nothing here checks whether a target is online: handing the * notification to PN is the whole job, and PN decides per recipient which sinks it reaches and @@ -30,23 +30,26 @@ */ public final class PlayerNotificationsListener implements Listener { + /** + * Every Realty notification is enqueued at the same priority. Ordering the inbox by category was + * dropped along with the module's own category config: PlayerNotifications is where a server + * decides how a category is presented, and a per-category priority set here would fight that. + */ + private static final int PRIORITY = 0; + private final NotificationEnqueuer enqueuer; - private final NotificationCategoryMapper categoryMapper; private final Duration expiry; private final Logger logger; /** - * @param enqueuer hands the built notification to PlayerNotifications - * @param categoryMapper resolves data type and priority from the event's message key - * @param expiry how long an enqueued notification survives before PN expires it - * @param logger used only for the FINE unmapped-key trace + * @param enqueuer hands the built notification to PlayerNotifications + * @param expiry how long an enqueued notification survives before PN expires it + * @param logger used only for the FINE unclaimed-key trace */ public PlayerNotificationsListener(@NotNull NotificationEnqueuer enqueuer, - @NotNull NotificationCategoryMapper categoryMapper, @NotNull Duration expiry, @NotNull Logger logger) { this.enqueuer = Objects.requireNonNull(enqueuer, "enqueuer"); - this.categoryMapper = Objects.requireNonNull(categoryMapper, "categoryMapper"); this.expiry = Objects.requireNonNull(expiry, "expiry"); this.logger = Objects.requireNonNull(logger, "logger"); } @@ -54,12 +57,12 @@ public PlayerNotificationsListener(@NotNull NotificationEnqueuer enqueuer, @EventHandler(priority = EventPriority.NORMAL) public void onNotification(@NotNull RealtyNotificationEvent event) { String messageKey = event.getMessageKey(); - String dataType = this.categoryMapper.dataTypeFor(messageKey); - if (!this.categoryMapper.isMapped(messageKey)) { + String dataType = RealtyCategory.forMessageKey(messageKey).dataType(); + if (!RealtyCategory.isClaimed(messageKey)) { // Never dropped: an unknown key is far more likely to be a Realty key newer than this - // module's categories.yml than a mistake, and a player still wants to be told. + // module's category table than a mistake, and a player still wants to be told. this.logger.log(Level.FINE, - "Unmapped Realty message key {0}; routing to {1}", + "Unclaimed Realty message key {0}; routing to {1}", new Object[]{messageKey, dataType}); } @@ -77,7 +80,7 @@ public void onNotification(@NotNull RealtyNotificationEvent event) { new NotificationTarget(event.getTargets()), dataType, payload, - this.categoryMapper.priorityFor(messageKey)); + PRIORITY); // overwriteAllowed is false: every Realty notification is a distinct event — a second // outbid is a second thing that happened, not a correction of the first — so none of them diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java new file mode 100644 index 0000000..b0429d5 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java @@ -0,0 +1,168 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.jetbrains.annotations.NotNull; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Realty's notification categories: the complete set of PlayerNotifications {@code dataType}s this + * adapter registers, and the {@code messages.yml} keys each one claims. + * + *

Why this is code and not config. Until 1.5.0 the set lived in the module's own + * {@code categories.yml}. PlayerNotifications now owns category presentation itself: a module + * registers its categories through {@code NotificationCategoryRegistry}, PN writes them out to its + * generated {@code categories-defaults.yml}, and the operator reconciles the blocks they care about + * into PN's {@code categories.yml} — where a label, a description or a regrouping overrides what was + * registered here. Two config files claiming the same job is one too many, so this side keeps only + * the part PN cannot know: which Realty message key belongs to which category.

+ * + *

The label and description below are therefore defaults, not the last word. They are what + * an operator sees in {@code categories-defaults.yml} and what the preference dialogs show until they + * override them.

+ * + *

Deliberately free of PlayerNotifications and Bukkit types: the routing decision is the part worth + * testing, and keeping it free of both means it can be tested without a server or a live PN install.

+ */ +public enum RealtyCategory { + + AGENT("realty.agent", + "Realty agents", + "Agent invitations and removals", + List.of("notification.agent-invited", + "notification.agent-invite-accepted", + "notification.agent-invite-rejected", + "notification.agent-invite-withdrawn", + "notification.agent-removed")), + + AUCTION("realty.auction", + "Realty auctions", + "Bids, auction outcomes and bid payment deadlines", + List.of("notification.outbid", + "notification.auction-cancelled", + "notification.auction-won", + "notification.auction-ended-no-bids", + "notification.bid-payment-expired")), + + OFFER("realty.offer", + "Realty offers", + "Offers on your regions and offer payment deadlines", + List.of("notification.offer-placed", + "notification.offer-accepted", + "notification.offer-rejected", + "notification.offer-withdrawn", + "notification.offer-payment-expired")), + + LEASE("realty.lease", + "Realty leases", + "Rent, lease expiry, terminations and modification proposals", + List.of("notification.region-rented", + "notification.region-unrented", + "notification.leasehold-expired", + "notification.leasehold-expired-landlord", + "notification.modify-proposed-landlord", + "notification.modify-proposed-tenant", + "notification.modify-accepted", + "notification.modify-rejected", + "notification.modify-withdrawn", + "notification.termination-scheduled-tenant", + "notification.termination-scheduled-landlord", + "notification.termination-cancelled", + "notification.leasehold-terminated-tenant", + "notification.leasehold-terminated-landlord")), + + /** + * Purchases, ownership transfers, and every key no other category claims. Being the fallback is + * why this constant must exist; see {@link #forMessageKey}. + */ + GENERAL("realty.general", + "Realty", + "Purchases, ownership transfers and anything uncategorised", + List.of("notification.region-bought", + "notification.ownership-transferred")); + + /** Where a key no category claims is routed. */ + public static final RealtyCategory FALLBACK = GENERAL; + + private static final Map BY_MESSAGE_KEY = index(); + + private final String dataType; + private final String label; + private final String description; + private final List messageKeys; + + RealtyCategory(@NotNull String dataType, + @NotNull String label, + @NotNull String description, + @NotNull List messageKeys) { + this.dataType = dataType; + this.label = label; + this.description = description; + this.messageKeys = messageKeys; + } + + /** + * Builds the message-key index, failing loudly if two categories claim the same key. + * + *

A duplicate is a programming error rather than an operator one now, but it is still checked: + * the failure it would otherwise cause — which category a player must enable to receive the key + * depending on declaration order — is invisible from both the dialogs and the source.

+ */ + private static @NotNull Map index() { + Map index = new HashMap<>(); + for (RealtyCategory category : values()) { + for (String messageKey : category.messageKeys) { + RealtyCategory claimedBy = index.put(messageKey, category); + if (claimedBy != null) { + throw new IllegalStateException("The message key '" + messageKey + + "' is claimed by both " + claimedBy + " and " + category + + "; a key may belong to exactly one category"); + } + } + } + return Map.copyOf(index); + } + + /** + * The category the given message key belongs to, or {@link #FALLBACK} if no category claims it. + * + *

An unrecognised key is routed rather than dropped: Realty adds message keys over time and + * third-party fire sites may use keys of their own, and a notification this enum has never seen is + * still a notification a player should receive.

+ */ + public static @NotNull RealtyCategory forMessageKey(@NotNull String messageKey) { + Objects.requireNonNull(messageKey, "messageKey"); + return BY_MESSAGE_KEY.getOrDefault(messageKey, FALLBACK); + } + + /** + * Whether any category explicitly claims the given key. Callers use this to log the fallback, + * because {@link #forMessageKey} cannot distinguish an unclaimed key from one deliberately + * claimed by {@link #FALLBACK}. + */ + public static boolean isClaimed(@NotNull String messageKey) { + return BY_MESSAGE_KEY.containsKey(Objects.requireNonNull(messageKey, "messageKey")); + } + + /** The PlayerNotifications {@code dataType} this category is registered as. */ + public @NotNull String dataType() { + return this.dataType; + } + + /** The default preference-dialog label; PN's {@code categories.yml} may override it. */ + public @NotNull String label() { + return this.label; + } + + /** The default preference-dialog description; PN's {@code categories.yml} may override it. */ + public @NotNull String description() { + return this.description; + } + + /** The {@code messages.yml} keys this category claims. */ + public @NotNull List messageKeys() { + return this.messageKeys; + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java index 52bfa1d..563230a 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java @@ -7,12 +7,7 @@ import org.jetbrains.annotations.NotNull; /** - * Registers and unregisters the PlayerNotifications data types {@code categories.yml} declares. - * - *

Every method takes the {@link NotificationCategoryMapper} the data types came from, and no - * method holds a list of its own. That is what makes the category set operator-configurable: adding - * a category to the file adds it here, with its configured label and description, without a code - * change.

+ * Registers and unregisters the PlayerNotifications data types {@link RealtyCategory} declares. * *

The shared payload class footgun. All data types share one payload class, * {@link RealtyNotificationPayload}. {@code NotificationDataTypeRegistry} keys serializers and @@ -24,17 +19,14 @@ * *

Unregistering the whole set is therefore not a tidiness preference, it is the only correct * sequence: partially unregistering is what corrupts the registry. {@link - * #unregisterAll(NotificationDataTypeRegistry, NotificationCategoryMapper)} exists so no call site - * can get that wrong, and {@code RegistrationLifecycleTest} asserts the hazard so it stays - * documented executably. This is a sharp edge in the PlayerNotifications API, not in this - * module.

+ * #unregisterAll(NotificationDataTypeRegistry)} exists so no call site can get that wrong, and + * {@code RegistrationLifecycleTest} asserts the hazard so it stays documented executably. This is a + * sharp edge in the PlayerNotifications API, not in this module.

* - *

Reloads must unregister the mapper they registered. Because the set is now read from - * config, a reload that removes or renames a category produces a mapper that no longer knows about - * the data types actually in the registry. Passing the new mapper to - * {@code unregisterAll} would leave those orphaned and mapped to a dead class loader's renderer. - * {@code PlayerNotificationsAdapterModule} keeps the mapper it registered with and tears down with - * that one.

+ *

Registering late is fine. A module starts from Realty's {@code onEnable}, well after + * PlayerNotifications has built its merged category snapshot. PN's registry fires + * {@code addChangeListener} on every mutation and PN rebuilds, so these categories reach the + * preference dialogs without this module having to know it was late.

*/ public final class RealtyDataTypes { @@ -42,8 +34,19 @@ private RealtyDataTypes() { } /** - * Binds every declared data type to {@link RealtyNotificationPayload} and claims it under a - * category carrying its configured label and description. + * Binds every category's data type to {@link RealtyNotificationPayload}, names it, and registers + * the category with its default label and description. + * + *

The category claims exactly one data type — its own — so a player toggling a category in + * {@code /notifications preferences} toggles precisely the Realty notifications it routes. An + * operator who wants a different grouping regroups these data types in PlayerNotifications' + * {@code categories.yml}; nothing here needs to change for that.

+ * + *

The display name is the category's label, registered with the data type rather than left to + * PlayerNotifications to guess: without one PN title-cases the registry key, and + * {@code realty.auction} title-cases to "Realty.auction". Like the label, it is a default — an + * operator's entry in PN's {@code type-names.yml} wins, and may carry MiniMessage colour these + * plain labels do not.

* *

Uses {@code registerJsonRenderable}, never {@code registerJsonPayload}: an explicit * processor wins dispatch precedence and bypasses preferences and sinks entirely, which would @@ -53,26 +56,27 @@ private RealtyDataTypes() { * what makes this module safe to declare {@code reloadable: true}.

*/ public static void registerAll(@NotNull NotificationService service, - @NotNull NotificationCategoryMapper mapper, @NotNull NotificationRenderer renderer) { NotificationCategoryRegistry categories = service.categoryRegistry(); - for (String dataType : mapper.dataTypes()) { + for (RealtyCategory category : RealtyCategory.values()) { + String dataType = category.dataType(); service.registerJsonRenderable(dataType, RealtyNotificationPayload.class, renderer); - categories.registerCategory(dataType, - mapper.labelFor(dataType), - mapper.descriptionFor(dataType)); + service.dataTypeRegistry().registerDisplayName(dataType, category.label()); + categories.registerCategory(dataType, category.label(), category.description()); categories.claimDataType(dataType, dataType); } } /** - * Unregisters every data type the given mapper declares. See the class javadoc: doing - * this partially corrupts the registry for the data types left behind. + * Unregisters every data type. See the class javadoc: doing this partially corrupts the + * registry for the data types left behind. */ - public static void unregisterAll(@NotNull NotificationDataTypeRegistry registry, - @NotNull NotificationCategoryMapper mapper) { - for (String dataType : mapper.dataTypes()) { - registry.unregisterPayloadMapping(dataType); + public static void unregisterAll(@NotNull NotificationDataTypeRegistry registry) { + for (RealtyCategory category : RealtyCategory.values()) { + registry.unregisterPayloadMapping(category.dataType()); + // Not part of the payload-mapping cascade: a display name is keyed by data type, not by + // payload class, so dropping the mapping leaves the name behind unless it is said here. + registry.unregisterDisplayName(category.dataType()); } // The cascade above already removed the shared serializer and renderer, but say so // explicitly: if a future data type were ever given its own payload class, the loop alone @@ -84,10 +88,9 @@ public static void unregisterAll(@NotNull NotificationDataTypeRegistry registry, /** * Releases each category's claim on its data type. */ - public static void unclaimAll(@NotNull NotificationCategoryRegistry categories, - @NotNull NotificationCategoryMapper mapper) { - for (String dataType : mapper.dataTypes()) { - categories.unclaimDataType(dataType, dataType); + public static void unclaimAll(@NotNull NotificationCategoryRegistry categories) { + for (RealtyCategory category : RealtyCategory.values()) { + categories.unclaimDataType(category.dataType(), category.dataType()); } } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java index ca108f5..a3c3693 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java @@ -5,36 +5,35 @@ import net.kyori.adventure.text.Component; import org.jetbrains.annotations.NotNull; -import java.util.Objects; import java.util.UUID; /** * Renders a stored {@link RealtyNotificationPayload} back into the medium-neutral title/body form * PlayerNotifications fans out to whichever sinks the recipient prefers. * - *

The body is the payload's component deserialized verbatim — Realty already rendered the text - * at the fire site, so there is nothing left to decide here. The title comes from module config - * via {@link NotificationCategoryMapper}, keyed by data type with a per-message-key override.

+ *

The body is the payload's component deserialized verbatim — Realty already rendered the text at + * the fire site, so there is nothing left to decide here. The title is the message key's category + * label.

+ * + *

The title is the registered label, not the operator's. An operator who renames a category + * in PlayerNotifications' {@code categories.yml} changes what the preference dialogs show, but not + * this title: the merged label lives in PN's core and is not on the API this module compiles against. + * Presenting the operator's name here is PlayerNotifications' problem to solve, and when it does this + * renderer follows it rather than growing a title config of its own.

* *

Rendering ignores the target: Realty's messages are already per-target (several targets means * several people get the same text), so there is nothing to personalise.

* - *

Neither title nor body may depend on click events to be understood. Sinks that are not - * Minecraft clients — Essentials mail, Discord — flatten components to plain text and are free to - * drop interaction entirely.

+ *

Neither title nor body may depend on click events to be understood. Sinks that are not Minecraft + * clients — Essentials mail, Discord — flatten components to plain text and are free to drop + * interaction entirely.

*/ public final class RealtyNotificationRenderer implements NotificationRenderer { - private final NotificationCategoryMapper categoryMapper; - - public RealtyNotificationRenderer(@NotNull NotificationCategoryMapper categoryMapper) { - this.categoryMapper = Objects.requireNonNull(categoryMapper, "categoryMapper"); - } - @Override public @NotNull RenderableNotification render(@NotNull RealtyNotificationPayload payload, @NotNull UUID target) { - Component title = Component.text(this.categoryMapper.titleFor(payload.messageKey())); + Component title = Component.text(RealtyCategory.forMessageKey(payload.messageKey()).label()); return new RenderableNotification(title, payload.bodyComponent()); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml deleted file mode 100644 index a4654a8..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/main/resources/categories.yml +++ /dev/null @@ -1,99 +0,0 @@ -# Realty -> PlayerNotifications categories. -# -# Each category below is registered with PlayerNotifications as a dataType: the unit players opt in -# and out of in /notifications preferences. Categories are read from this file, not compiled in, so -# adding, removing, renaming or re-splitting one needs no new build of the adapter. -# -# Per category: -# label the name a player sees in /notifications preferences -# description the explanation shown beneath that name -# title the heading on the delivered notification itself; defaults to `label` when omitted -# priority higher sorts first in the inbox; defaults to 0 -# keys the Realty message keys (messages.yml paths) routed to this category -# -# A message key may belong to exactly one category — the adapter refuses to start on a key claimed -# twice, because which category a player would have to enable would otherwise depend on file order. -# A key listed nowhere routes to `fallback-category` and is logged at FINE; it is never dropped. -# -# Titles are plain text: non-Minecraft sinks flatten components, so they must not depend on click or -# hover events to make sense. - -# Where unlisted message keys go. Must be one of the categories declared below — the adapter fails -# to start otherwise, rather than enqueueing into a dataType that was never registered. -fallback-category: realty.general - -# How long an enqueued notification stays in the inbox before PN expires it. -expiry-days: 30 - -categories: - - realty.agent: - label: "Realty agents" - description: "Agent invitations and removals" - title: "Realty — Agents" - priority: 0 - keys: - - notification.agent-invited - - notification.agent-invite-accepted - - notification.agent-invite-rejected - - notification.agent-invite-withdrawn - - notification.agent-removed - - realty.auction: - label: "Realty auctions" - description: "Bids, auction outcomes and bid payment deadlines" - title: "Realty — Auction" - priority: 1 - keys: - - notification.outbid - - notification.auction-cancelled - - notification.auction-won - - notification.auction-ended-no-bids - - notification.bid-payment-expired - - realty.offer: - label: "Realty offers" - description: "Offers on your regions and offer payment deadlines" - title: "Realty — Offer" - priority: 1 - keys: - - notification.offer-placed - - notification.offer-accepted - - notification.offer-rejected - - notification.offer-withdrawn - - notification.offer-payment-expired - - realty.lease: - label: "Realty leases" - description: "Rent, lease expiry, terminations and modification proposals" - title: "Realty — Lease" - priority: 1 - keys: - - notification.region-rented - - notification.region-unrented - - notification.leasehold-expired - - notification.leasehold-expired-landlord - - notification.modify-proposed-landlord - - notification.modify-proposed-tenant - - notification.modify-accepted - - notification.modify-rejected - - notification.modify-withdrawn - - notification.termination-scheduled-tenant - - notification.termination-scheduled-landlord - - notification.termination-cancelled - - notification.leasehold-terminated-tenant - - notification.leasehold-terminated-landlord - - realty.general: - label: "Realty" - description: "Purchases, ownership transfers and anything uncategorised" - title: "Realty" - priority: 0 - keys: - - notification.region-bought - - notification.ownership-transferred - -# Per-message-key title overrides. Beats the title of the key's category. -title-overrides: - notification.auction-won: "Realty — Auction won" - notification.outbid: "Realty — You were outbid" diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/resources/config.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/config.yml new file mode 100644 index 0000000..d7af9d4 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/resources/config.yml @@ -0,0 +1,13 @@ +# player-notifications-adapter configuration. +# +# Notification *categories* are not configured here. Since 1.5.0 this adapter registers its +# categories with PlayerNotifications in code; PlayerNotifications writes them into its own +# generated `categories-defaults.yml`, and you customise labels, descriptions and grouping by +# copying the blocks you care about into PlayerNotifications' `categories.yml`. That is the one +# place notification categories are managed. +# +# What is left here is the one setting PlayerNotifications cannot infer. + +# How long an enqueued notification stays in a player's inbox before PlayerNotifications expires +# it. Applies at enqueue time, so changing it affects new notifications only. +expiry-days: 30 diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java deleted file mode 100644 index e5cd1d3..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/CategoriesConfigTest.java +++ /dev/null @@ -1,137 +0,0 @@ -package io.github.md5sha256.realty.adapter.playernotifs; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.Reader; -import java.io.StringReader; -import java.nio.charset.StandardCharsets; -import java.util.List; - -/** - * Covers the parse from {@code categories.yml} to a {@link NotificationCategoryMapper}. - * - *

{@code YamlConfiguration} needs no running server, so this exercises the real parser rather - * than a stand-in.

- */ -class CategoriesConfigTest { - - private static NotificationCategoryMapper parse(String yaml) { - return CategoriesConfig.readMapper(CategoriesConfig.load(new StringReader(yaml))); - } - - /** - * The file the module writes into the operator's data folder on first start must itself be a - * valid category set — a default that fails validation would break every fresh install. - */ - @Test - void theBundledDefaultParsesAndDeclaresEveryCategory() throws IOException { - try (InputStream stream = CategoriesConfigTest.class.getClassLoader() - .getResourceAsStream("categories.yml")) { - Assertions.assertNotNull(stream, "categories.yml is missing from the jar"); - try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { - NotificationCategoryMapper mapper = - CategoriesConfig.readMapper(CategoriesConfig.load(reader)); - - Assertions.assertEquals( - List.of("realty.agent", "realty.auction", "realty.offer", "realty.lease", - "realty.general"), - mapper.dataTypes()); - Assertions.assertEquals("realty.general", mapper.fallbackDataType()); - Assertions.assertEquals("realty.auction", mapper.dataTypeFor("notification.outbid")); - Assertions.assertEquals("realty.lease", - mapper.dataTypeFor("notification.leasehold-terminated-tenant")); - Assertions.assertEquals("Realty — You were outbid", - mapper.titleFor("notification.outbid")); - Assertions.assertEquals("Realty auctions", mapper.labelFor("realty.auction")); - Assertions.assertEquals(1, mapper.priorityFor("notification.outbid")); - } - } - } - - @Test - void anOperatorAddedCategoryIsParsedWithItsMetadata() { - NotificationCategoryMapper mapper = parse(""" - fallback-category: realty.general - categories: - realty.general: - label: "Realty" - description: "Everything else" - keys: - - notification.region-bought - realty.staff: - label: "Staff alerts" - description: "For staff only" - title: "Staff" - priority: 9 - keys: - - notification.outbid - """); - - Assertions.assertEquals(List.of("realty.general", "realty.staff"), mapper.dataTypes()); - Assertions.assertEquals("realty.staff", mapper.dataTypeFor("notification.outbid")); - Assertions.assertEquals("Staff", mapper.titleFor("notification.outbid")); - Assertions.assertEquals("For staff only", mapper.descriptionFor("realty.staff")); - Assertions.assertEquals(9, mapper.priorityFor("notification.outbid")); - } - - @Test - void anOmittedTitleAndPriorityTakeTheirDefaults() { - NotificationCategoryMapper mapper = parse(""" - fallback-category: realty.general - categories: - realty.general: - label: "Realty" - keys: - - notification.region-bought - """); - - Assertions.assertEquals("Realty", mapper.titleFor("notification.region-bought")); - Assertions.assertEquals(0, mapper.priorityFor("notification.region-bought")); - Assertions.assertEquals("", mapper.descriptionFor("realty.general")); - } - - /** - * {@code fallback-category} is optional; leaving it out keeps the historical behaviour of - * routing unmapped keys to {@code realty.general}. - */ - @Test - void anOmittedFallbackDefaultsToGeneral() { - NotificationCategoryMapper mapper = parse(""" - categories: - realty.general: - label: "Realty" - """); - - Assertions.assertEquals("realty.general", mapper.fallbackDataType()); - } - - @Test - void aFileWithNoCategoriesSectionIsRejected() { - IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, - () -> parse("expiry-days: 30\n")); - - Assertions.assertTrue(thrown.getMessage().contains("categories"), thrown.getMessage()); - } - - /** - * The pre-1.5.0 file mapped a message key straight to a category name. Parsing that as the new - * shape would silently produce categories named after message keys, so it is rejected with a - * message that names the format change. - */ - @Test - void theOldFlatFormatIsRejectedWithAnExplanation() { - IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, - () -> parse(""" - categories: - notification.outbid: realty.auction - notification.region-bought: realty.general - """)); - - Assertions.assertTrue(thrown.getMessage().contains("notification.outbid"), thrown.getMessage()); - Assertions.assertTrue(thrown.getMessage().contains("pre-1.5.0"), thrown.getMessage()); - } -} diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java deleted file mode 100644 index 84c66d8..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/LegacyFormatMigrationTest.java +++ /dev/null @@ -1,106 +0,0 @@ -package io.github.md5sha256.realty.adapter.playernotifs; - -import org.bukkit.configuration.file.YamlConfiguration; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.io.TempDir; - -import java.io.IOException; -import java.io.StringReader; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.logging.Logger; - -/** - * Covers the replacement of a pre-1.5.0 {@code categories.yml}. - * - *

An operator upgrading has one on disk, and it would otherwise fail the parse and take the whole - * module down on start.

- */ -class LegacyFormatMigrationTest { - - private static final Logger LOGGER = Logger.getLogger(LegacyFormatMigrationTest.class.getName()); - - private static final String LEGACY = """ - categories: - notification.agent-invited: realty.agent - notification.outbid: realty.auction - titles: - realty.agent: "Realty — Agents" - expiry-days: 30 - """; - - private static Path writeLegacy(Path dataFolder) throws IOException { - Files.createDirectories(dataFolder); - Path file = dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE); - Files.writeString(file, LEGACY, StandardCharsets.UTF_8); - return file; - } - - @Test - void aLegacyFileIsDetected() { - YamlConfiguration legacy = CategoriesConfig.load(new StringReader(LEGACY)); - - Assertions.assertTrue(CategoriesConfig.isLegacyFormat(legacy)); - } - - @Test - void aCurrentFileIsNotMistakenForALegacyOne(@TempDir Path dataFolder) throws IOException { - CategoriesConfig.read(dataFolder, LOGGER); - - YamlConfiguration current = CategoriesConfig.load( - Files.newBufferedReader(dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE))); - - Assertions.assertFalse(CategoriesConfig.isLegacyFormat(current)); - } - - /** A missing categories section is a different failure, and must not trigger a rewrite. */ - @Test - void aFileWithNoCategoriesSectionIsNotTreatedAsLegacy() { - YamlConfiguration empty = CategoriesConfig.load(new StringReader("expiry-days: 30\n")); - - Assertions.assertFalse(CategoriesConfig.isLegacyFormat(empty)); - } - - @Test - void aLegacyFileIsReplacedAndTheModuleStarts(@TempDir Path dataFolder) throws IOException { - writeLegacy(dataFolder); - - NotificationCategoryMapper mapper = - CategoriesConfig.readMapper(CategoriesConfig.read(dataFolder, LOGGER)); - - Assertions.assertTrue(mapper.dataTypes().contains("realty.auction")); - Assertions.assertEquals("realty.auction", mapper.dataTypeFor("notification.outbid")); - } - - @Test - void theLegacyFileIsKeptAsABackup(@TempDir Path dataFolder) throws IOException { - writeLegacy(dataFolder); - - CategoriesConfig.read(dataFolder, LOGGER); - - Path backup = dataFolder.resolve( - CategoriesConfig.CATEGORIES_FILE + CategoriesConfig.LEGACY_BACKUP_SUFFIX); - Assertions.assertTrue(Files.isRegularFile(backup)); - Assertions.assertEquals(LEGACY, Files.readString(backup, StandardCharsets.UTF_8)); - } - - /** - * The replacement runs once. A second start sees a current file and leaves it alone, so an - * operator who re-edits it after the upgrade does not have their work replaced again. - */ - @Test - void aSecondStartDoesNotReplaceTheReplacement(@TempDir Path dataFolder) throws IOException { - writeLegacy(dataFolder); - CategoriesConfig.read(dataFolder, LOGGER); - Path live = dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE); - String edited = Files.readString(live, StandardCharsets.UTF_8) - .replace("Realty auctions", "Auction stuff"); - Files.writeString(live, edited, StandardCharsets.UTF_8); - - CategoriesConfig.read(dataFolder, LOGGER); - - Assertions.assertEquals(edited, Files.readString(live, StandardCharsets.UTF_8)); - } -} diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java deleted file mode 100644 index 35cb613..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationCategoryMapperTest.java +++ /dev/null @@ -1,158 +0,0 @@ -package io.github.md5sha256.realty.adapter.playernotifs; - -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; - -import java.util.List; -import java.util.Map; - -class NotificationCategoryMapperTest { - - @Test - void eachCategoryResolvesFromARepresentativeKey() { - NotificationCategoryMapper mapper = TestCategories.defaults(); - - Assertions.assertEquals("realty.auction", mapper.dataTypeFor("notification.outbid")); - Assertions.assertEquals("realty.offer", mapper.dataTypeFor("notification.offer-placed")); - Assertions.assertEquals("realty.lease", mapper.dataTypeFor("notification.leasehold-expired")); - Assertions.assertEquals("realty.agent", mapper.dataTypeFor("notification.agent-invited")); - Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.region-bought")); - } - - @Test - void anUnmappedKeyFallsBackToTheFallbackCategory() { - NotificationCategoryMapper mapper = TestCategories.defaults(); - - Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.some-future-key")); - Assertions.assertFalse(mapper.isMapped("notification.some-future-key")); - Assertions.assertTrue(mapper.isMapped("notification.region-bought")); - } - - @Test - void aConfigOverrideBeatsTheDefault() { - NotificationCategoryMapper mapper = new NotificationCategoryMapper( - List.of(TestCategories.category("realty.general", "Realty", "notification.outbid")), - Map.of(), - "realty.general"); - - Assertions.assertEquals("realty.general", mapper.dataTypeFor("notification.outbid")); - } - - @Test - void aTitleOverrideBeatsTheCategoryTitle() { - NotificationCategoryMapper mapper = new NotificationCategoryMapper( - List.of(new CategoryDefinition("realty.auction", "Realty auctions", "", - "Realty — Auction", 0, - List.of("notification.outbid", "notification.auction-won"))), - Map.of("notification.auction-won", "Realty — Auction won"), - "realty.auction"); - - Assertions.assertEquals("Realty — Auction won", mapper.titleFor("notification.auction-won")); - Assertions.assertEquals("Realty — Auction", mapper.titleFor("notification.outbid")); - } - - @Test - void anUnconfiguredTitleAndPriorityFallBack() { - NotificationCategoryMapper mapper = TestCategories.defaults(); - - Assertions.assertEquals("Realty", mapper.titleFor("notification.some-future-key")); - Assertions.assertEquals(1, mapper.priorityFor("notification.outbid")); - Assertions.assertEquals(0, mapper.priorityFor("notification.offer-placed")); - } - - /** - * A category that declares only a label still gets that label on the notification itself, rather - * than a generic "Realty" that would tell the player nothing about where it came from. - */ - @Test - void aCategoryWithNoTitleRendersUnderItsLabel() { - NotificationCategoryMapper mapper = new NotificationCategoryMapper( - List.of(TestCategories.category("realty.staff", "Staff alerts", "notification.custom")), - Map.of(), - "realty.staff"); - - Assertions.assertEquals("Staff alerts", mapper.titleFor("notification.custom")); - } - - /** - * The whole point of the config change: an operator-declared category is a first-class data type, - * so it appears in {@link NotificationCategoryMapper#dataTypes()} and therefore gets registered. - */ - @Test - void anOperatorDeclaredCategoryBecomesARegisteredDataType() { - NotificationCategoryMapper mapper = new NotificationCategoryMapper( - List.of(TestCategories.category("realty.general", "Realty"), - new CategoryDefinition("realty.staff", "Staff alerts", - "Notifications only staff care about", "Staff", 5, - List.of("notification.outbid"))), - Map.of(), - "realty.general"); - - Assertions.assertEquals(List.of("realty.general", "realty.staff"), mapper.dataTypes()); - Assertions.assertEquals("realty.staff", mapper.dataTypeFor("notification.outbid")); - Assertions.assertEquals("Staff alerts", mapper.labelFor("realty.staff")); - Assertions.assertEquals("Notifications only staff care about", - mapper.descriptionFor("realty.staff")); - Assertions.assertEquals(5, mapper.priorityFor("notification.outbid")); - } - - @Test - void declarationOrderIsPreserved() { - NotificationCategoryMapper mapper = new NotificationCategoryMapper( - List.of(TestCategories.category("z.last", "Z"), - TestCategories.category("a.first", "A"), - TestCategories.category("realty.general", "Realty")), - Map.of(), - "realty.general"); - - Assertions.assertEquals(List.of("z.last", "a.first", "realty.general"), mapper.dataTypes()); - } - - /** - * An undeclared fallback would be registered nowhere, so every unmapped notification would be - * enqueued under a data type with no serializer and no renderer, and lost silently. - */ - @Test - void anUndeclaredFallbackCategoryIsRejected() { - IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, - () -> new NotificationCategoryMapper( - List.of(TestCategories.category("realty.auction", "Realty auctions")), - Map.of(), - "realty.general")); - - Assertions.assertTrue(thrown.getMessage().contains("realty.general"), thrown.getMessage()); - } - - @Test - void aMessageKeyClaimedByTwoCategoriesIsRejected() { - IllegalArgumentException thrown = Assertions.assertThrows(IllegalArgumentException.class, - () -> new NotificationCategoryMapper( - List.of(TestCategories.category("realty.general", "Realty", "notification.outbid"), - TestCategories.category("realty.auction", "Auctions", "notification.outbid")), - Map.of(), - "realty.general")); - - Assertions.assertTrue(thrown.getMessage().contains("notification.outbid"), thrown.getMessage()); - } - - @Test - void anEmptyCategorySetIsRejected() { - Assertions.assertThrows(IllegalArgumentException.class, - () -> new NotificationCategoryMapper(List.of(), Map.of(), "realty.general")); - } - - /** - * A category may exist purely so players can be given a switch for keys the operator has not - * routed to it yet. - */ - @Test - void aCategoryThatClaimsNoKeysIsStillRegistered() { - NotificationCategoryMapper mapper = new NotificationCategoryMapper( - List.of(TestCategories.category("realty.general", "Realty"), - TestCategories.category("realty.spare", "Spare")), - Map.of(), - "realty.general"); - - Assertions.assertTrue(mapper.dataTypes().contains("realty.spare")); - } -} diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java index 3b70c4b..86b93e2 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java @@ -9,20 +9,11 @@ import java.time.Duration; import java.util.ArrayList; import java.util.List; -import java.util.Map; import java.util.UUID; import java.util.logging.Logger; class PlayerNotificationsListenerTest { - private static final NotificationCategoryMapper MAPPER = new NotificationCategoryMapper( - List.of(new CategoryDefinition("realty.auction", "Realty auctions", "", - "Realty — Auction", 3, List.of("notification.outbid")), - new CategoryDefinition("realty.general", "Realty", "", "Realty", 0, - List.of("notification.region-bought"))), - Map.of(), - "realty.general"); - private static PlayerNotificationsListener listener( List> enqueued, List overwriteFlags) { @@ -31,7 +22,6 @@ private static PlayerNotificationsListener listener( enqueued.add(notification); overwriteFlags.add(overwriteAllowed); }, - MAPPER, Duration.ofDays(30), Logger.getLogger(PlayerNotificationsListenerTest.class.getName())); } @@ -48,7 +38,8 @@ void oneEventEnqueuesExactlyOneNotification() { Assertions.assertEquals(1, enqueued.size()); TypedNotification notification = enqueued.get(0); Assertions.assertEquals("realty.auction", notification.notifPayloadType()); - Assertions.assertEquals(3, notification.notifPriority()); + // One priority for every Realty notification: ordering the inbox is PlayerNotifications' job. + Assertions.assertEquals(0, notification.notifPriority()); Assertions.assertEquals("notification.outbid", notification.notifPayload().messageKey()); } @@ -109,7 +100,7 @@ void aNullRegionYieldsNullRegionAndWorldIds() { } @Test - void anUnmappedKeyStillEnqueuesUnderGeneral() { + void anUnclaimedKeyStillEnqueuesUnderGeneral() { List> enqueued = new ArrayList<>(); List overwriteFlags = new ArrayList<>(); diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java new file mode 100644 index 0000000..77601f4 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java @@ -0,0 +1,138 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + +/** + * Covers the compiled category table: routing, its defaults, and — the part that used to be a runtime + * check on an operator's file — that it actually covers Realty's message keys. + */ +class RealtyCategoryTest { + + /** + * Realty's own {@code messages.yml}, reached relatively because the category table is this + * module's claim about that file's keys and nothing else can check it. + */ + private static final Path MESSAGES = + Path.of("..", "..", "realty-paper", "src", "main", "resources", "messages.yml"); + + @Test + void aClaimedKeyRoutesToItsCategory() { + Assertions.assertEquals(RealtyCategory.AUCTION, + RealtyCategory.forMessageKey("notification.outbid")); + Assertions.assertEquals(RealtyCategory.LEASE, + RealtyCategory.forMessageKey("notification.region-rented")); + Assertions.assertEquals(RealtyCategory.AGENT, + RealtyCategory.forMessageKey("notification.agent-invited")); + } + + /** + * A key no category claims is routed, never dropped: Realty gains message keys faster than this + * table does, and a notification it has never seen is still one a player should receive. + */ + @Test + void anUnclaimedKeyFallsBackWithoutBeingDropped() { + Assertions.assertEquals(RealtyCategory.GENERAL, + RealtyCategory.forMessageKey("notification.some-future-key")); + Assertions.assertFalse(RealtyCategory.isClaimed("notification.some-future-key")); + Assertions.assertTrue(RealtyCategory.isClaimed("notification.region-bought")); + } + + @Test + void everyCategoryHasADataTypeLabelAndDescription() { + Set dataTypes = new HashSet<>(); + for (RealtyCategory category : RealtyCategory.values()) { + Assertions.assertTrue(category.dataType().startsWith("realty."), category.dataType()); + Assertions.assertFalse(category.label().isBlank(), category.name()); + Assertions.assertFalse(category.description().isBlank(), category.name()); + Assertions.assertTrue(dataTypes.add(category.dataType()), category.dataType()); + } + } + + /** + * The replacement for the duplicate-key check the config parser used to make. Loading the enum at + * all builds the index, so a key claimed twice fails every test in this class rather than + * silently making the winning category depend on declaration order. + */ + @Test + void noMessageKeyIsClaimedTwice() { + List claimed = new ArrayList<>(); + for (RealtyCategory category : RealtyCategory.values()) { + claimed.addAll(category.messageKeys()); + } + Assertions.assertEquals(claimed.size(), Set.copyOf(claimed).size(), + "the same message key appears under two categories"); + } + + /** + * Every {@code notification.*} key Realty can fire is claimed by a category. + * + *

Uncovered keys are not broken — they fall back to {@link RealtyCategory#GENERAL} — but they + * are almost always an oversight, and a player who disables "Realty" then stops receiving them. + * Failing here is how a new notification key gets a deliberate home rather than a default one.

+ */ + @Test + void everyRealtyNotificationKeyIsClaimed() throws IOException { + Assertions.assertTrue(Files.isRegularFile(MESSAGES), + "expected Realty's messages.yml at " + MESSAGES.toAbsolutePath()); + + YamlConfiguration messages; + try (Reader reader = Files.newBufferedReader(MESSAGES)) { + messages = YamlConfiguration.loadConfiguration(reader); + } + ConfigurationSection section = messages.getConfigurationSection("notification"); + Assertions.assertNotNull(section, "messages.yml has no 'notification' section"); + + Set unclaimed = new TreeSet<>(); + for (String key : section.getKeys(false)) { + String messageKey = "notification." + key; + if (!RealtyCategory.isClaimed(messageKey)) { + unclaimed.add(messageKey); + } + } + Assertions.assertEquals(Set.of(), unclaimed, + "these message keys belong to no RealtyCategory and fall back to " + + RealtyCategory.FALLBACK.dataType()); + } + + /** + * The mirror of the above: the table must not claim keys Realty cannot fire, which would otherwise + * hide a rename behind a category that quietly claims nothing. + */ + @Test + void noCategoryClaimsAKeyRealtyDoesNotHave() throws IOException { + YamlConfiguration messages; + try (Reader reader = Files.newBufferedReader(MESSAGES)) { + messages = YamlConfiguration.loadConfiguration(reader); + } + ConfigurationSection section = messages.getConfigurationSection("notification"); + Assertions.assertNotNull(section); + + Set known = new HashSet<>(); + for (String key : section.getKeys(false)) { + known.add("notification." + key); + } + + Set stale = new TreeSet<>(); + for (RealtyCategory category : RealtyCategory.values()) { + for (String messageKey : category.messageKeys()) { + if (!known.contains(messageKey)) { + stale.add(messageKey); + } + } + } + Assertions.assertEquals(Set.of(), stale, "these claimed keys are not in messages.yml"); + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java index 877e418..0adaad0 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java @@ -1,5 +1,6 @@ package io.github.md5sha256.realty.adapter.playernotifs; +import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; @@ -9,7 +10,7 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; -import java.util.logging.Logger; +import java.time.Duration; /** * Covers the reference copy every config file must ship: a regenerated {@code defaults/} copy an @@ -17,17 +18,15 @@ */ class ReferenceCopyTest { - private static final Logger LOGGER = Logger.getLogger(ReferenceCopyTest.class.getName()); - private static Path reference(Path dataFolder) { - return dataFolder.resolve(CategoriesConfig.DEFAULTS_DIR).resolve(CategoriesConfig.REFERENCE_FILE); + return dataFolder.resolve(AdapterConfig.DEFAULTS_DIR).resolve(AdapterConfig.REFERENCE_FILE); } @Test void aFirstStartWritesBothTheLiveFileAndTheReferenceCopy(@TempDir Path dataFolder) { - CategoriesConfig.read(dataFolder, LOGGER); + AdapterConfig.read(dataFolder); - Assertions.assertTrue(Files.isRegularFile(dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE))); + Assertions.assertTrue(Files.isRegularFile(dataFolder.resolve(AdapterConfig.CONFIG_FILE))); Assertions.assertTrue(Files.isRegularFile(reference(dataFolder))); } @@ -36,15 +35,16 @@ void aFirstStartWritesBothTheLiveFileAndTheReferenceCopy(@TempDir Path dataFolde */ @Test void aLaterStartLeavesTheOperatorsFileAlone(@TempDir Path dataFolder) throws IOException { - CategoriesConfig.read(dataFolder, LOGGER); - Path live = dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE); + AdapterConfig.read(dataFolder); + Path live = dataFolder.resolve(AdapterConfig.CONFIG_FILE); String edited = Files.readString(live, StandardCharsets.UTF_8) - .replace("Realty auctions", "Auction stuff"); + .replace("expiry-days: 30", "expiry-days: 7"); Files.writeString(live, edited, StandardCharsets.UTF_8); - CategoriesConfig.read(dataFolder, LOGGER); + AdapterConfig config = AdapterConfig.read(dataFolder); Assertions.assertEquals(edited, Files.readString(live, StandardCharsets.UTF_8)); + Assertions.assertEquals(Duration.ofDays(7), config.expiry()); } /** @@ -53,25 +53,25 @@ void aLaterStartLeavesTheOperatorsFileAlone(@TempDir Path dataFolder) throws IOE */ @Test void aStaleReferenceCopyIsOverwrittenOnEveryStart(@TempDir Path dataFolder) throws IOException { - CategoriesConfig.read(dataFolder, LOGGER); + AdapterConfig.read(dataFolder); Files.writeString(reference(dataFolder), "# left over from an older version\n", StandardCharsets.UTF_8); - CategoriesConfig.read(dataFolder, LOGGER); + AdapterConfig.read(dataFolder); String refreshed = Files.readString(reference(dataFolder), StandardCharsets.UTF_8); Assertions.assertFalse(refreshed.contains("left over")); - Assertions.assertTrue(refreshed.contains("fallback-category")); + Assertions.assertTrue(refreshed.contains("expiry-days")); } /** The shipped reference must itself be loadable, or it documents a file that would not start. */ @Test void theReferenceCopyParses(@TempDir Path dataFolder) throws IOException { - CategoriesConfig.read(dataFolder, LOGGER); + AdapterConfig.read(dataFolder); try (Reader reader = Files.newBufferedReader(reference(dataFolder))) { - NotificationCategoryMapper mapper = CategoriesConfig.readMapper(CategoriesConfig.load(reader)); - Assertions.assertFalse(mapper.dataTypes().isEmpty()); + AdapterConfig config = AdapterConfig.from(YamlConfiguration.loadConfiguration(reader)); + Assertions.assertEquals(Duration.ofDays(30), config.expiry()); } } @@ -79,14 +79,22 @@ void theReferenceCopyParses(@TempDir Path dataFolder) throws IOException { void theReferenceCopyIsWrittenEvenWhenTheOperatorAlreadyHasAFile(@TempDir Path dataFolder) throws IOException { Files.createDirectories(dataFolder); - Files.writeString(dataFolder.resolve(CategoriesConfig.CATEGORIES_FILE), """ - categories: - realty.general: - label: "Realty" - """, StandardCharsets.UTF_8); + Files.writeString(dataFolder.resolve(AdapterConfig.CONFIG_FILE), "expiry-days: 1\n", + StandardCharsets.UTF_8); - CategoriesConfig.read(dataFolder, LOGGER); + AdapterConfig config = AdapterConfig.read(dataFolder); Assertions.assertTrue(Files.isRegularFile(reference(dataFolder))); + Assertions.assertEquals(Duration.ofDays(1), config.expiry()); + } + + /** A file predating {@code expiry-days} still starts, on the compiled-in default. */ + @Test + void aMissingExpiryFallsBackToTheDefault(@TempDir Path dataFolder) throws IOException { + Files.createDirectories(dataFolder); + Files.writeString(dataFolder.resolve(AdapterConfig.CONFIG_FILE), "# nothing set\n", + StandardCharsets.UTF_8); + + Assertions.assertEquals(Duration.ofDays(30), AdapterConfig.read(dataFolder).expiry()); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java index ced7755..fdb2d04 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java @@ -1,6 +1,8 @@ package io.github.md5sha256.realty.adapter.playernotifs; import io.github.md5sha256.playernotifications.api.NotificationDataTypeRegistry; +import io.github.md5sha256.playernotifications.api.category.DefaultNotificationCategoryRegistry; +import io.github.md5sha256.playernotifications.api.category.NotificationCategoryRegistry; import io.github.md5sha256.playernotifications.api.render.NotificationRenderer; import io.github.md5sha256.playernotifications.api.render.RenderableNotification; import io.github.md5sha256.playernotifications.api.serialize.PayloadSerializer; @@ -9,20 +11,19 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; -import java.util.List; -import java.util.Map; +import java.util.Optional; +import java.util.Set; import java.util.UUID; /** - * Exercises registration and unregistration against a real {@link NotificationDataTypeRegistry} — - * it is a plain concrete class with no Bukkit dependency, so no server is needed. + * Exercises registration and unregistration against a real {@link NotificationDataTypeRegistry} and + * {@link DefaultNotificationCategoryRegistry} — both are plain concrete classes with no Bukkit + * dependency, so no server is needed. */ class RegistrationLifecycleTest { - private static final NotificationCategoryMapper MAPPER = TestCategories.defaults(); - private static final NotificationRenderer RENDERER = - new RealtyNotificationRenderer(MAPPER); + new RealtyNotificationRenderer(); /** * Stands in for the reflective JSON serializer {@code registerJsonRenderable} installs; only its @@ -45,59 +46,92 @@ class RegistrationLifecycleTest { * Mirrors what {@code NotificationService.registerJsonRenderable} does to the registry: bind the * data type to the payload class, and register a serializer and renderer for that class. */ - private static NotificationDataTypeRegistry registerAll(NotificationCategoryMapper mapper) { + private static NotificationDataTypeRegistry registerAll() { NotificationDataTypeRegistry registry = new NotificationDataTypeRegistry(); - for (String dataType : mapper.dataTypes()) { - registry.registerPayloadMapping(dataType, RealtyNotificationPayload.class); + for (RealtyCategory category : RealtyCategory.values()) { + registry.registerPayloadMapping(category.dataType(), RealtyNotificationPayload.class); registry.registerSerializer(RealtyNotificationPayload.class, SERIALIZER); registry.registerRenderer(RealtyNotificationPayload.class, RENDERER); + registry.registerDisplayName(category.dataType(), category.label()); } return registry; } @Test void everyDeclaredDataTypeRegisters() { - NotificationDataTypeRegistry registry = registerAll(MAPPER); + NotificationDataTypeRegistry registry = registerAll(); - for (String dataType : MAPPER.dataTypes()) { + for (RealtyCategory category : RealtyCategory.values()) { + String dataType = category.dataType(); Assertions.assertTrue(registry.dataTypes().contains(dataType), dataType); Assertions.assertTrue(registry.getSerializer(dataType).isPresent(), dataType); Assertions.assertTrue(registry.getRenderer(dataType).isPresent(), dataType); } - Assertions.assertEquals(MAPPER.dataTypes().size(), registry.dataTypes().size()); + Assertions.assertEquals(RealtyCategory.values().length, registry.dataTypes().size()); + } + + /** + * Every data type carries a name, so the preference screens never fall through to PN title-casing + * the registry key — which would read "Realty.auction". + */ + @Test + void everyDataTypeIsRegisteredWithADisplayName() { + NotificationDataTypeRegistry registry = registerAll(); + + for (RealtyCategory category : RealtyCategory.values()) { + Assertions.assertEquals(Optional.of(category.label()), + registry.displayName(category.dataType()), category.dataType()); + } + } + + /** + * A display name is keyed by data type while the serializer and renderer are keyed by payload + * class, so it is not swept up by the payload-mapping cascade — teardown must drop it explicitly + * or a reloaded module leaves its names behind. + */ + @Test + void unregisteringDropsTheDisplayNamesToo() { + NotificationDataTypeRegistry registry = registerAll(); + + RealtyDataTypes.unregisterAll(registry); + + for (RealtyCategory category : RealtyCategory.values()) { + Assertions.assertTrue(registry.displayName(category.dataType()).isEmpty(), + category.dataType()); + } } @Test void unregisteringTheWholeSetLeavesTheRegistryClean() { - NotificationDataTypeRegistry registry = registerAll(MAPPER); + NotificationDataTypeRegistry registry = registerAll(); - RealtyDataTypes.unregisterAll(registry, MAPPER); + RealtyDataTypes.unregisterAll(registry); - Assertions.assertEquals(Map.of().keySet(), registry.dataTypes()); + Assertions.assertTrue(registry.dataTypes().isEmpty()); Assertions.assertTrue(registry.getSerializer(RealtyNotificationPayload.class).isEmpty()); Assertions.assertTrue(registry.getRenderer(RealtyNotificationPayload.class).isEmpty()); } @Test void unregisteringTheWholeSetIsIdempotent() { - NotificationDataTypeRegistry registry = registerAll(MAPPER); + NotificationDataTypeRegistry registry = registerAll(); - RealtyDataTypes.unregisterAll(registry, MAPPER); - RealtyDataTypes.unregisterAll(registry, MAPPER); + RealtyDataTypes.unregisterAll(registry); + RealtyDataTypes.unregisterAll(registry); Assertions.assertTrue(registry.dataTypes().isEmpty()); } @Test void reRegisteringOverAnExistingRegistrationIsIdempotent() { - NotificationDataTypeRegistry registry = registerAll(MAPPER); + NotificationDataTypeRegistry registry = registerAll(); - for (String dataType : MAPPER.dataTypes()) { - registry.registerPayloadMapping(dataType, RealtyNotificationPayload.class); + for (RealtyCategory category : RealtyCategory.values()) { + registry.registerPayloadMapping(category.dataType(), RealtyNotificationPayload.class); } // Plain map puts, which is what makes `reloadable: true` safe for this module. - Assertions.assertEquals(MAPPER.dataTypes().size(), registry.dataTypes().size()); + Assertions.assertEquals(RealtyCategory.values().length, registry.dataTypes().size()); Assertions.assertTrue(registry.getRenderer("realty.auction").isPresent()); } @@ -110,13 +144,14 @@ void reRegisteringOverAnExistingRegistrationIsIdempotent() { */ @Test void aPartialUnregisterSilentlyBreaksTheRemainingDataTypes() { - NotificationDataTypeRegistry registry = registerAll(MAPPER); + NotificationDataTypeRegistry registry = registerAll(); registry.unregisterPayloadMapping("realty.auction"); Assertions.assertFalse(registry.dataTypes().contains("realty.auction")); - Assertions.assertEquals(MAPPER.dataTypes().size() - 1, registry.dataTypes().size()); - for (String survivor : MAPPER.dataTypes()) { + Assertions.assertEquals(RealtyCategory.values().length - 1, registry.dataTypes().size()); + for (RealtyCategory category : RealtyCategory.values()) { + String survivor = category.dataType(); if (survivor.equals("realty.auction")) { continue; } @@ -129,27 +164,41 @@ void aPartialUnregisterSilentlyBreaksTheRemainingDataTypes() { } /** - * The reload hazard the configurable category set introduces: if teardown used a mapper rebuilt - * from an edited {@code categories.yml}, a category the operator deleted would be left - * registered, mapped to a renderer on a class loader that is about to be closed. The module - * therefore keeps the mapper it registered with — which is what this asserts. + * Each category registers with its compiled default label and description and claims exactly its + * own data type — the blocks an operator then sees in the generated + * {@code categories-defaults.yml}. */ @Test - void tearingDownWithANewerMapperOrphansARemovedCategory() { - NotificationDataTypeRegistry registry = registerAll(MAPPER); - NotificationCategoryMapper afterOperatorDeletedAuctions = new NotificationCategoryMapper( - List.of(TestCategories.category("realty.general", "Realty", "notification.region-bought")), - Map.of(), - "realty.general"); + void categoriesRegisterWithTheirDefaultsAndClaimTheirOwnDataType() { + NotificationCategoryRegistry categories = new DefaultNotificationCategoryRegistry(); - RealtyDataTypes.unregisterAll(registry, afterOperatorDeletedAuctions); + for (RealtyCategory category : RealtyCategory.values()) { + categories.registerCategory( + category.dataType(), category.label(), category.description()); + categories.claimDataType(category.dataType(), category.dataType()); + } - Assertions.assertTrue(registry.dataTypes().contains("realty.auction"), - "realty.auction was registered but the newer mapper does not know to remove it"); + for (RealtyCategory category : RealtyCategory.values()) { + String key = category.dataType(); + Assertions.assertEquals(category.label(), categories.label(key)); + Assertions.assertEquals(category.description(), categories.description(key)); + Assertions.assertEquals(Set.of(key), categories.dataTypesFor(key)); + } + } - // The mapper that registered them removes them all. - RealtyDataTypes.unregisterAll(registry, MAPPER); - Assertions.assertTrue(registry.dataTypes().isEmpty()); + @Test + void unclaimingReleasesEveryCategoryClaim() { + NotificationCategoryRegistry categories = new DefaultNotificationCategoryRegistry(); + for (RealtyCategory category : RealtyCategory.values()) { + categories.claimDataType(category.dataType(), category.dataType()); + } + + RealtyDataTypes.unclaimAll(categories); + + for (RealtyCategory category : RealtyCategory.values()) { + Assertions.assertEquals(Set.of(), categories.dataTypesFor(category.dataType()), + category.dataType()); + } } @Test @@ -161,6 +210,6 @@ void theRendererProducesATitleAndTheVerbatimBody() { RenderableNotification rendered = RENDERER.render(payload, UUID.randomUUID()); Assertions.assertEquals(message.compact(), rendered.body().compact()); - Assertions.assertEquals(Component.text("Realty — Auction"), rendered.title()); + Assertions.assertEquals(Component.text(RealtyCategory.AUCTION.label()), rendered.title()); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java deleted file mode 100644 index 4100269..0000000 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TestCategories.java +++ /dev/null @@ -1,34 +0,0 @@ -package io.github.md5sha256.realty.adapter.playernotifs; - -import java.util.List; -import java.util.Map; - -/** - * Builders for the category sets the tests exercise, so each test states only the part it cares - * about rather than repeating a full five-category declaration. - */ -final class TestCategories { - - private TestCategories() { - } - - /** A category with a label and title matching its key and no priority. */ - static CategoryDefinition category(String key, String label, String... keys) { - return new CategoryDefinition(key, label, "", "", 0, List.of(keys)); - } - - /** A representative one-key-per-category set mirroring the shipped defaults. */ - static NotificationCategoryMapper defaults() { - return new NotificationCategoryMapper( - List.of(new CategoryDefinition("realty.auction", "Realty auctions", - "Bids and outcomes", "Realty — Auction", 1, - List.of("notification.outbid")), - category("realty.offer", "Realty offers", "notification.offer-placed"), - category("realty.lease", "Realty leases", "notification.leasehold-expired"), - category("realty.agent", "Realty agents", "notification.agent-invited"), - new CategoryDefinition("realty.general", "Realty", "Everything else", - "Realty", 0, List.of("notification.region-bought"))), - Map.of(), - "realty.general"); - } -} From 6226970edf6eb832b9cc0deea1c7c9bd589af563 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:19:24 +1000 Subject: [PATCH 13/14] feat(player-notifications-adapter): title notification rows per message key PlayerNotifications lists an inbox row by its rendered title alone and reveals the body only when the row is opened, so a category-level title gave a player a screen of identical rows -- fourteen lease notifications all reading "Realty leases" -- with no way to tell a rent payment from an eviction without opening each. Each message key a category claims now carries its own short summary, and the renderer titles a notification with that, suffixed by the region when the payload names one. An unclaimed key still falls back to its category label, which remains the only honest thing to say about a key the enum has never been taught about. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xrJUKJ8gHdwjpApr9mjkJ --- .../adapter/playernotifs/RealtyCategory.java | 102 +++++++++++------- .../RealtyNotificationRenderer.java | 25 +++-- .../playernotifs/RealtyCategoryTest.java | 29 +++++ .../RealtyNotificationRendererTest.java | 78 ++++++++++++++ .../RegistrationLifecycleTest.java | 3 +- 5 files changed, 192 insertions(+), 45 deletions(-) create mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java index b0429d5..4aec88a 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java @@ -23,6 +23,13 @@ * an operator sees in {@code categories-defaults.yml} and what the preference dialogs show until they * override them.

* + *

Each claimed key also carries a title — the short summary + * {@link RealtyNotificationRenderer} puts on the notification. Unlike the label and the description + * this one is not PN's to override and is not a category-level string: PN's inbox lists a row by its + * rendered title alone and shows the body only once the row is opened, so a category-level + * title made every row in a category read identically ("Realty leases", fourteen times over) with the + * message itself invisible from the list.

+ * *

Deliberately free of PlayerNotifications and Bukkit types: the routing decision is the part worth * testing, and keeping it free of both means it can be tested without a server or a live PN install.

*/ @@ -31,47 +38,51 @@ public enum RealtyCategory { AGENT("realty.agent", "Realty agents", "Agent invitations and removals", - List.of("notification.agent-invited", - "notification.agent-invite-accepted", - "notification.agent-invite-rejected", - "notification.agent-invite-withdrawn", - "notification.agent-removed")), + Map.ofEntries( + Map.entry("notification.agent-invited", "Agent invitation"), + Map.entry("notification.agent-invite-accepted", "Agent invite accepted"), + Map.entry("notification.agent-invite-rejected", "Agent invite rejected"), + Map.entry("notification.agent-invite-withdrawn", "Agent invite withdrawn"), + Map.entry("notification.agent-removed", "Removed as agent"))), AUCTION("realty.auction", "Realty auctions", "Bids, auction outcomes and bid payment deadlines", - List.of("notification.outbid", - "notification.auction-cancelled", - "notification.auction-won", - "notification.auction-ended-no-bids", - "notification.bid-payment-expired")), + Map.ofEntries( + Map.entry("notification.outbid", "Outbid"), + Map.entry("notification.auction-cancelled", "Auction cancelled"), + Map.entry("notification.auction-won", "Auction won"), + Map.entry("notification.auction-ended-no-bids", "Auction ended without bids"), + Map.entry("notification.bid-payment-expired", "Bid payment expired"))), OFFER("realty.offer", "Realty offers", "Offers on your regions and offer payment deadlines", - List.of("notification.offer-placed", - "notification.offer-accepted", - "notification.offer-rejected", - "notification.offer-withdrawn", - "notification.offer-payment-expired")), + Map.ofEntries( + Map.entry("notification.offer-placed", "Offer received"), + Map.entry("notification.offer-accepted", "Offer accepted"), + Map.entry("notification.offer-rejected", "Offer rejected"), + Map.entry("notification.offer-withdrawn", "Offer withdrawn"), + Map.entry("notification.offer-payment-expired", "Offer payment expired"))), LEASE("realty.lease", "Realty leases", "Rent, lease expiry, terminations and modification proposals", - List.of("notification.region-rented", - "notification.region-unrented", - "notification.leasehold-expired", - "notification.leasehold-expired-landlord", - "notification.modify-proposed-landlord", - "notification.modify-proposed-tenant", - "notification.modify-accepted", - "notification.modify-rejected", - "notification.modify-withdrawn", - "notification.termination-scheduled-tenant", - "notification.termination-scheduled-landlord", - "notification.termination-cancelled", - "notification.leasehold-terminated-tenant", - "notification.leasehold-terminated-landlord")), + Map.ofEntries( + Map.entry("notification.region-rented", "Region rented"), + Map.entry("notification.region-unrented", "Region unrented"), + Map.entry("notification.leasehold-expired", "Lease expired"), + Map.entry("notification.leasehold-expired-landlord", "Tenant's lease expired"), + Map.entry("notification.modify-proposed-landlord", "New lease terms proposed"), + Map.entry("notification.modify-proposed-tenant", "New lease terms requested"), + Map.entry("notification.modify-accepted", "Lease terms accepted"), + Map.entry("notification.modify-rejected", "Lease terms rejected"), + Map.entry("notification.modify-withdrawn", "Lease proposal withdrawn"), + Map.entry("notification.termination-scheduled-tenant", "Lease termination scheduled"), + Map.entry("notification.termination-scheduled-landlord", "Tenant scheduled termination"), + Map.entry("notification.termination-cancelled", "Termination cancelled"), + Map.entry("notification.leasehold-terminated-tenant", "Lease ended"), + Map.entry("notification.leasehold-terminated-landlord", "Tenant's lease ended"))), /** * Purchases, ownership transfers, and every key no other category claims. Being the fallback is @@ -80,8 +91,9 @@ public enum RealtyCategory { GENERAL("realty.general", "Realty", "Purchases, ownership transfers and anything uncategorised", - List.of("notification.region-bought", - "notification.ownership-transferred")); + Map.ofEntries( + Map.entry("notification.region-bought", "Region sold"), + Map.entry("notification.ownership-transferred", "Ownership transferred"))); /** Where a key no category claims is routed. */ public static final RealtyCategory FALLBACK = GENERAL; @@ -91,16 +103,16 @@ public enum RealtyCategory { private final String dataType; private final String label; private final String description; - private final List messageKeys; + private final Map titlesByMessageKey; RealtyCategory(@NotNull String dataType, @NotNull String label, @NotNull String description, - @NotNull List messageKeys) { + @NotNull Map titlesByMessageKey) { this.dataType = dataType; this.label = label; this.description = description; - this.messageKeys = messageKeys; + this.titlesByMessageKey = titlesByMessageKey; } /** @@ -113,7 +125,7 @@ public enum RealtyCategory { private static @NotNull Map index() { Map index = new HashMap<>(); for (RealtyCategory category : values()) { - for (String messageKey : category.messageKeys) { + for (String messageKey : category.titlesByMessageKey.keySet()) { RealtyCategory claimedBy = index.put(messageKey, category); if (claimedBy != null) { throw new IllegalStateException("The message key '" + messageKey @@ -137,6 +149,19 @@ public enum RealtyCategory { return BY_MESSAGE_KEY.getOrDefault(messageKey, FALLBACK); } + /** + * The short summary the notification renders with, e.g. {@code "Lease expired"}. + * + *

An unclaimed key has no title of its own and falls back to its category's label — the old + * behaviour for every key, and still the only honest thing to say about a key no category has + * been taught about.

+ */ + public static @NotNull String titleFor(@NotNull String messageKey) { + Objects.requireNonNull(messageKey, "messageKey"); + RealtyCategory category = forMessageKey(messageKey); + return category.titlesByMessageKey.getOrDefault(messageKey, category.label); + } + /** * Whether any category explicitly claims the given key. Callers use this to log the fallback, * because {@link #forMessageKey} cannot distinguish an unclaimed key from one deliberately @@ -163,6 +188,11 @@ public static boolean isClaimed(@NotNull String messageKey) { /** The {@code messages.yml} keys this category claims. */ public @NotNull List messageKeys() { - return this.messageKeys; + return List.copyOf(this.titlesByMessageKey.keySet()); + } + + /** The keys this category claims, mapped to the titles they render with. */ + public @NotNull Map titlesByMessageKey() { + return this.titlesByMessageKey; } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java index a3c3693..45e5a7f 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java @@ -12,14 +12,19 @@ * PlayerNotifications fans out to whichever sinks the recipient prefers. * *

The body is the payload's component deserialized verbatim — Realty already rendered the text at - * the fire site, so there is nothing left to decide here. The title is the message key's category - * label.

+ * the fire site, so there is nothing left to decide here.

* - *

The title is the registered label, not the operator's. An operator who renames a category - * in PlayerNotifications' {@code categories.yml} changes what the preference dialogs show, but not - * this title: the merged label lives in PN's core and is not on the API this module compiles against. - * Presenting the operator's name here is PlayerNotifications' problem to solve, and when it does this - * renderer follows it rather than growing a title config of its own.

+ *

The title must identify the individual notification, not its category. PN's inbox lists a + * row by its rendered title alone and reveals the body only when the row is opened, so a title that is + * constant per category gives a player a screen of identical rows — fourteen lease notifications all + * reading "Realty leases" — with no way to tell a rent payment from an eviction without opening each. + * The title is therefore {@link RealtyCategory#titleFor} (the message key's own summary) suffixed with + * the region when the payload names one, which is what makes two notifications of the same + * kind distinguishable from each other.

+ * + *

The region is appended as its raw WorldGuard id. That is what the body already shows and what the + * player types into commands; resolving a friendlier name would need a live region the payload + * deliberately does not hold — it routinely outlives the region it describes.

* *

Rendering ignores the target: Realty's messages are already per-target (several targets means * several people get the same text), so there is nothing to personalise.

@@ -33,7 +38,11 @@ public final class RealtyNotificationRenderer implements NotificationRendererA blank or duplicated title puts the row back where this table started: PN's inbox lists rows + * by title alone, so two keys in one category sharing a title are two rows a player cannot tell + * apart. Across categories a repeat is harmless — those rows differ by category anyway.

+ */ + @Test + void everyClaimedKeyHasItsOwnTitleWithinItsCategory() { + for (RealtyCategory category : RealtyCategory.values()) { + Set titles = new HashSet<>(); + for (Map.Entry entry : category.titlesByMessageKey().entrySet()) { + Assertions.assertFalse(entry.getValue().isBlank(), entry.getKey()); + Assertions.assertTrue(titles.add(entry.getValue()), + category.name() + " reuses the title '" + entry.getValue() + "'"); + } + } + } + + /** A claimed key renders its own summary; an unclaimed one falls back to the category label. */ + @Test + void titleForFallsBackToTheCategoryLabel() { + Assertions.assertEquals("Lease expired", + RealtyCategory.titleFor("notification.leasehold-expired")); + Assertions.assertEquals(RealtyCategory.GENERAL.label(), + RealtyCategory.titleFor("notification.some-future-key")); + } + /** * The replacement for the duplicate-key check the config parser used to make. Loading the enum at * all builds the index, so a key claimed twice fails every test in this class rather than diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java new file mode 100644 index 0000000..9c2aa0d --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java @@ -0,0 +1,78 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import io.github.md5sha256.playernotifications.api.render.RenderableNotification; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +/** + * Covers the title, which is the whole reason this renderer is more than a passthrough: PlayerNotifications' + * inbox lists a row by its rendered title alone and shows the body only once the row is opened. + */ +class RealtyNotificationRendererTest { + + private static final RealtyNotificationRenderer RENDERER = new RealtyNotificationRenderer(); + private static final UUID TARGET = UUID.fromString("00000000-0000-0000-0000-000000000001"); + + private static String titleOf(RealtyNotificationPayload payload) { + RenderableNotification rendered = RENDERER.render(payload, TARGET); + return PlainTextComponentSerializer.plainText().serialize(rendered.title()); + } + + private static RealtyNotificationPayload payload(String messageKey, String regionId) { + return RealtyNotificationPayload.of(messageKey, + Component.text("the rendered message"), + regionId, + regionId == null ? null : UUID.randomUUID().toString()); + } + + /** + * The bug this renderer was changed for: two lease notifications used to render the identical title + * "Realty leases", making them indistinguishable in the inbox list. + */ + @Test + void twoKeysInOneCategoryRenderDistinctTitles() { + String expired = titleOf(payload("notification.leasehold-expired", "plot42")); + String rented = titleOf(payload("notification.region-rented", "plot42")); + + Assertions.assertNotEquals(expired, rented); + Assertions.assertEquals("Lease expired — plot42", expired); + Assertions.assertEquals("Region rented — plot42", rented); + } + + /** The region is what separates two notifications of the same kind. */ + @Test + void theRegionIsAppendedWhenThePayloadNamesOne() { + Assertions.assertNotEquals(titleOf(payload("notification.outbid", "plot42")), + titleOf(payload("notification.outbid", "plot7"))); + } + + /** + * A payload that names no region — a refund announced after the region was deleted — still renders a + * title, without a dangling separator. + */ + @Test + void aRegionlessPayloadRendersTheSummaryAlone() { + Assertions.assertEquals("Outbid", titleOf(payload("notification.outbid", null))); + Assertions.assertEquals("Outbid", titleOf(payload("notification.outbid", ""))); + } + + /** An unclaimed key keeps the old behaviour: the category label, never an empty title. */ + @Test + void anUnclaimedKeyFallsBackToTheCategoryLabel() { + Assertions.assertEquals(RealtyCategory.GENERAL.label() + " — plot42", + titleOf(payload("notification.some-future-key", "plot42"))); + } + + /** The body is the payload's component verbatim; the title change must not have touched it. */ + @Test + void theBodyIsThePayloadComponentVerbatim() { + RenderableNotification rendered = + RENDERER.render(payload("notification.leasehold-expired", "plot42"), TARGET); + Assertions.assertEquals("the rendered message", + PlainTextComponentSerializer.plainText().serialize(rendered.body())); + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java index fdb2d04..fe610ba 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java @@ -210,6 +210,7 @@ void theRendererProducesATitleAndTheVerbatimBody() { RenderableNotification rendered = RENDERER.render(payload, UUID.randomUUID()); Assertions.assertEquals(message.compact(), rendered.body().compact()); - Assertions.assertEquals(Component.text(RealtyCategory.AUCTION.label()), rendered.title()); + // The message key's own summary, not the category label — see RealtyNotificationRendererTest. + Assertions.assertEquals(Component.text("Outbid"), rendered.title()); } } From 983a957b3c2a5ef9855e1c7fd9cdf9439af76fb5 Mon Sep 17 00:00:00 2001 From: Andrew Wong <42793301+md5sha256@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:26:50 +1000 Subject: [PATCH 14/14] feat(player-notifications-adapter): let operators retitle notification rows PlayerNotifications lists an inbox row by its title alone, so the title is the only text a player reads before deciding whether to open a notification -- and it was compiled in, leaving an operator no way to reword a row for their server's vocabulary. Titles now come from a titles.yml the module seeds on first start, listing every message key Realty can fire at its current title. A key present there wins; a key absent falls back to the RealtyCategory table, so a key added by a newer Realty keeps working at its new default until the operator chooses otherwise, and deleting a line restores the default. Values are MiniMessage, so a row can be coloured; a blank value falls back rather than rendering a row a player cannot read. Titles are their own file rather than a block in config.yml because they are the one thing here edited in bulk -- expiry-days would be buried under sixty rows. Like every operator config it ships a defaults/default-titles.yml, rewritten on every start. Message keys are dotted, which Bukkit splits into nested sections at load time, so the path separator is neutralised before the document is loaded. A test asserts the bundled file and the enum claim the same keys at the same titles, in both directions -- a new notification key now fails the build until it is given a title in both places rather than silently diverging. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_011xrJUKJ8gHdwjpApr9mjkJ --- .../PlayerNotificationsAdapterModule.java | 6 +- .../RealtyNotificationRenderer.java | 23 ++- .../adapter/playernotifs/TitleConfig.java | 165 ++++++++++++++++++ .../src/main/resources/titles.yml | 63 +++++++ .../playernotifs/RealtyCategoryTest.java | 43 +++++ .../RealtyNotificationRendererTest.java | 42 ++++- .../playernotifs/ReferenceCopyTest.java | 60 +++++++ .../RegistrationLifecycleTest.java | 2 +- .../adapter/playernotifs/TitleConfigTest.java | 108 ++++++++++++ 9 files changed, 502 insertions(+), 10 deletions(-) create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfig.java create mode 100644 realty-paper-adapters/player-notifications-adapter/src/main/resources/titles.yml create mode 100644 realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfigTest.java diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java index 1091b34..6da0b9b 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java @@ -66,13 +66,15 @@ public void initialize(@NotNull Realty plugin, @NotNull Path dataFolder) { + "player-notifications-adapter cannot start"); } - // 2. Read expiry. The category set is compiled in, so nothing about it can fail here. + // 2. Read expiry and the operator's row titles. The category set is compiled in, so nothing + // about it can fail here. AdapterConfig config = AdapterConfig.read(dataFolder); + TitleConfig titles = TitleConfig.read(dataFolder); warnAboutObsoleteCategoriesFile(plugin, dataFolder); // 3. Register payload types, renderers and categories. PN's registry notifies its own // change listener, so registering this late still reaches the preference dialogs. - RealtyDataTypes.registerAll(notificationService, new RealtyNotificationRenderer()); + RealtyDataTypes.registerAll(notificationService, new RealtyNotificationRenderer(titles)); this.service = notificationService; // 4. Only now, with nothing left that can throw, does a live listener appear. diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java index 45e5a7f..d99cb0a 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java @@ -5,6 +5,7 @@ import net.kyori.adventure.text.Component; import org.jetbrains.annotations.NotNull; +import java.util.Objects; import java.util.UUID; /** @@ -18,9 +19,13 @@ * row by its rendered title alone and reveals the body only when the row is opened, so a title that is * constant per category gives a player a screen of identical rows — fourteen lease notifications all * reading "Realty leases" — with no way to tell a rent payment from an eviction without opening each. - * The title is therefore {@link RealtyCategory#titleFor} (the message key's own summary) suffixed with - * the region when the payload names one, which is what makes two notifications of the same - * kind distinguishable from each other.

+ * The title is therefore the message key's own summary suffixed with the region when the payload + * names one, which is what makes two notifications of the same kind distinguishable from + * each other.

+ * + *

Where that summary comes from is the operator's call: {@link TitleConfig} answers with their + * {@code titles.yml} override if they wrote one and {@link RealtyCategory#titleFor} otherwise, so + * this class never needs to know which.

* *

The region is appended as its raw WorldGuard id. That is what the body already shows and what the * player types into commands; resolving a friendlier name would need a live region the payload @@ -35,14 +40,20 @@ */ public final class RealtyNotificationRenderer implements NotificationRenderer { + private final TitleConfig titles; + + public RealtyNotificationRenderer(@NotNull TitleConfig titles) { + this.titles = Objects.requireNonNull(titles, "titles"); + } + @Override public @NotNull RenderableNotification render(@NotNull RealtyNotificationPayload payload, @NotNull UUID target) { - String summary = RealtyCategory.titleFor(payload.messageKey()); + Component summary = this.titles.titleFor(payload.messageKey()); String regionId = payload.regionId(); - Component title = Component.text(regionId == null || regionId.isBlank() + Component title = regionId == null || regionId.isBlank() ? summary - : summary + " — " + regionId); + : summary.append(Component.text(" — " + regionId)); return new RenderableNotification(title, payload.bodyComponent()); } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfig.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfig.java new file mode 100644 index 0000000..dd897d2 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfig.java @@ -0,0 +1,165 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.NotNull; + +import java.io.IOException; +import java.io.InputStream; +import java.io.Reader; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.Set; + +/** + * Reads the module's {@code titles.yml}: the operator's overrides for the short summary a Realty + * notification renders with. + * + *

Titles are their own file rather than a block in {@code config.yml} because they are the one + * thing here an operator edits in bulk — the bundled copy lists every message key Realty can fire, so + * a single setting like {@code expiry-days} would be buried under sixty lines of rows.

+ * + *

Overrides are sparse in effect even though the shipped file is complete. A key present + * here wins; a key absent falls back to {@link RealtyCategory#titleFor}. That is what keeps an + * operator's file from freezing the titles at the version they last copied: a key added by a newer + * Realty simply is not in their file, and renders from the compiled table until they choose otherwise.

+ * + *

Values are MiniMessage so a row can be coloured. A blank value falls back rather than rendering + * an empty row — PlayerNotifications lists a row by its title alone, so an empty title is a row a + * player cannot read at all.

+ * + *

Free of PlayerNotifications and {@code plugin-infrastructure} types for the same reason + * {@link AdapterConfig} is: only Bukkit's config classes are needed, and those run without a server.

+ */ +public final class TitleConfig { + + static final String TITLES_FILE = "titles.yml"; + /** See the project config rules: every operator config ships a regenerated reference copy. */ + static final String REFERENCE_FILE = "default-titles.yml"; + + private static final String TITLES_SECTION = "titles"; + + /** Overrides nothing; every key renders from {@link RealtyCategory}. */ + private static final TitleConfig COMPILED = new TitleConfig(Map.of()); + + /** + * Bukkit splits a dotted path into nested sections at load time. Message keys are dotted + * ({@code notification.leasehold-expired}), so the separator is neutralised before loading and + * every key stays whole. + */ + private static final char NO_PATH_SEPARATOR = '\u0000'; + + private final Map overrides; + + private TitleConfig(@NotNull Map overrides) { + this.overrides = Map.copyOf(overrides); + } + + /** + * The title a notification for the given message key renders with: the operator's override if + * they wrote one, otherwise the compiled summary from {@link RealtyCategory}. + */ + public @NotNull Component titleFor(@NotNull String messageKey) { + Objects.requireNonNull(messageKey, "messageKey"); + Component override = this.overrides.get(messageKey); + return override != null ? override : Component.text(RealtyCategory.titleFor(messageKey)); + } + + /** + * Overrides nothing — every key renders at its compiled title. Used where no operator file is in + * play, chiefly by tests asserting the defaults. + */ + public static @NotNull TitleConfig compiled() { + return COMPILED; + } + + /** The message keys this file overrides, whether or not a category claims them. */ + public @NotNull Set overriddenKeys() { + return this.overrides.keySet(); + } + + /** + * Reads the operator's {@code titles.yml}, writing the bundled default there first if they have + * none, and refreshing the reference copy beside it either way. + */ + public static @NotNull TitleConfig read(@NotNull Path dataFolder) { + Objects.requireNonNull(dataFolder, "dataFolder"); + Path file = dataFolder.resolve(TITLES_FILE); + try { + Files.createDirectories(dataFolder); + if (!Files.exists(file)) { + copyBundled(file); + } + writeReferenceCopy(dataFolder); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + return load(reader); + } + } catch (IOException ex) { + throw new UncheckedIOException("Failed to read " + TITLES_FILE, ex); + } + } + + /** + * Writes {@code defaults/default-titles.yml}, overwriting any previous copy. Rewritten on every + * start so it always shows what a current file looks like; never read back. + */ + public static void writeReferenceCopy(@NotNull Path dataFolder) throws IOException { + Path defaults = dataFolder.resolve(AdapterConfig.DEFAULTS_DIR); + Files.createDirectories(defaults); + copyBundled(defaults.resolve(REFERENCE_FILE)); + } + + private static void copyBundled(@NotNull Path target) throws IOException { + try (InputStream bundled = TitleConfig.class.getClassLoader() + .getResourceAsStream(TITLES_FILE)) { + if (bundled == null) { + throw new IllegalStateException( + "player-notifications-adapter jar is missing its bundled " + TITLES_FILE); + } + Files.copy(bundled, target, StandardCopyOption.REPLACE_EXISTING); + } + } + + /** Reads a {@code titles.yml} document, keeping its dotted message keys whole. */ + public static @NotNull TitleConfig load(@NotNull Reader reader) throws IOException { + Objects.requireNonNull(reader, "reader"); + YamlConfiguration config = new YamlConfiguration(); + config.options().pathSeparator(NO_PATH_SEPARATOR); + try { + config.load(reader); + } catch (InvalidConfigurationException ex) { + throw new IOException("Failed to parse " + TITLES_FILE, ex); + } + return from(config); + } + + /** + * Builds the overrides from a loaded document. The document must have been loaded with the path + * separator neutralised, or its message keys arrive here already split into sections. + */ + static @NotNull TitleConfig from(@NotNull YamlConfiguration config) { + Objects.requireNonNull(config, "config"); + ConfigurationSection section = config.getConfigurationSection(TITLES_SECTION); + if (section == null) { + return new TitleConfig(Map.of()); + } + Map overrides = new HashMap<>(); + for (String messageKey : section.getKeys(false)) { + String value = section.getString(messageKey); + if (value == null || value.isBlank()) { + continue; + } + overrides.put(messageKey, MiniMessage.miniMessage().deserialize(value)); + } + return new TitleConfig(overrides); + } +} diff --git a/realty-paper-adapters/player-notifications-adapter/src/main/resources/titles.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/titles.yml new file mode 100644 index 0000000..c2b421c --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/resources/titles.yml @@ -0,0 +1,63 @@ +# player-notifications-adapter -- notification row titles. +# +# PlayerNotifications lists an inbox row by its *title* alone and shows the message body only once +# the row is opened. This file is what those rows say. +# +# Every message key Realty can fire is listed below at the title the plugin compiles in, so you can +# see the whole set and edit the ones you care about in place. +# +# How it is read: +# +# * A key present here wins. A key absent falls back to the title compiled into the plugin -- so a +# key a newer Realty adds after you last touched this file keeps working, at its new default, +# until you choose otherwise. Deleting a line is how you go back to the default. +# * Values are MiniMessage, so "Auction won" colours the row. Plain text is fine. +# * A blank value falls back rather than rendering an unreadable empty row. +# * The region the notification is about, when it names one, is appended after the title -- do not +# write it into the title yourself. +# +# This file is yours: it is written once, on first start, and never rewritten. See +# defaults/default-titles.yml for a current copy regenerated on every start -- diff it against this +# file after an upgrade to see what is new. + +titles: + # Realty agents (realty.agent) -- agent invitations and removals + notification.agent-invited: Agent invitation + notification.agent-invite-accepted: Agent invite accepted + notification.agent-invite-rejected: Agent invite rejected + notification.agent-invite-withdrawn: Agent invite withdrawn + notification.agent-removed: Removed as agent + + # Realty auctions (realty.auction) -- bids, auction outcomes and bid payment deadlines + notification.outbid: Outbid + notification.auction-cancelled: Auction cancelled + notification.auction-won: Auction won + notification.auction-ended-no-bids: Auction ended without bids + notification.bid-payment-expired: Bid payment expired + + # Realty offers (realty.offer) -- offers on your regions and offer payment deadlines + notification.offer-placed: Offer received + notification.offer-accepted: Offer accepted + notification.offer-rejected: Offer rejected + notification.offer-withdrawn: Offer withdrawn + notification.offer-payment-expired: Offer payment expired + + # Realty leases (realty.lease) -- rent, lease expiry, terminations and modification proposals + notification.region-rented: Region rented + notification.region-unrented: Region unrented + notification.leasehold-expired: Lease expired + notification.leasehold-expired-landlord: Tenant's lease expired + notification.modify-proposed-landlord: New lease terms proposed + notification.modify-proposed-tenant: New lease terms requested + notification.modify-accepted: Lease terms accepted + notification.modify-rejected: Lease terms rejected + notification.modify-withdrawn: Lease proposal withdrawn + notification.termination-scheduled-tenant: Lease termination scheduled + notification.termination-scheduled-landlord: Tenant scheduled termination + notification.termination-cancelled: Termination cancelled + notification.leasehold-terminated-tenant: Lease ended + notification.leasehold-terminated-landlord: Tenant's lease ended + + # Realty (realty.general) -- purchases, ownership transfers and anything uncategorised + notification.region-bought: Region sold + notification.ownership-transferred: Ownership transferred diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java index b0afd1c..a501086 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java @@ -1,18 +1,23 @@ package io.github.md5sha256.realty.adapter.playernotifs; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; import java.io.IOException; +import java.io.InputStreamReader; import java.io.Reader; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; import java.util.HashSet; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Set; import java.util.TreeSet; @@ -164,4 +169,42 @@ void noCategoryClaimsAKeyRealtyDoesNotHave() throws IOException { } Assertions.assertEquals(Set.of(), stale, "these claimed keys are not in messages.yml"); } + + /** + * The bundled {@code titles.yml} lists exactly the keys the enum claims, at exactly the titles it + * compiles in. + * + *

The file is the operator's whole starting point, so a key missing from it is a title they + * cannot discover, and a key it lists that no category claims is a row they can edit to no effect. + * Divergent text is worse than either: the file would document a title the plugin does not use + * until they edit the line, which is the one thing a shipped default must never do.

+ */ + @Test + void theBundledTitlesFileMatchesTheCompiledTable() throws IOException { + Map compiled = new HashMap<>(); + for (RealtyCategory category : RealtyCategory.values()) { + compiled.putAll(category.titlesByMessageKey()); + } + + TitleConfig bundled; + try (Reader reader = new InputStreamReader( + Objects.requireNonNull( + RealtyCategoryTest.class.getClassLoader() + .getResourceAsStream(TitleConfig.TITLES_FILE), + "the jar ships no " + TitleConfig.TITLES_FILE), + StandardCharsets.UTF_8)) { + bundled = TitleConfig.load(reader); + } + + Set listed = new TreeSet<>(bundled.overriddenKeys()); + Assertions.assertEquals(new TreeSet<>(compiled.keySet()), listed, + "the bundled titles.yml and RealtyCategory claim different message keys"); + + for (Map.Entry entry : compiled.entrySet()) { + Assertions.assertEquals(entry.getValue(), + PlainTextComponentSerializer.plainText() + .serialize(bundled.titleFor(entry.getKey())), + "titles.yml documents a different title for " + entry.getKey()); + } + } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java index 9c2aa0d..bf2e342 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java @@ -6,6 +6,9 @@ import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; import java.util.UUID; /** @@ -14,7 +17,8 @@ */ class RealtyNotificationRendererTest { - private static final RealtyNotificationRenderer RENDERER = new RealtyNotificationRenderer(); + private static final RealtyNotificationRenderer RENDERER = + new RealtyNotificationRenderer(TitleConfig.compiled()); private static final UUID TARGET = UUID.fromString("00000000-0000-0000-0000-000000000001"); private static String titleOf(RealtyNotificationPayload payload) { @@ -67,6 +71,42 @@ void anUnclaimedKeyFallsBackToTheCategoryLabel() { titleOf(payload("notification.some-future-key", "plot42"))); } + /** An operator's override replaces the compiled summary; the region suffix is untouched by it. */ + @Test + void anOperatorOverrideReplacesTheSummary() throws IOException { + RealtyNotificationRenderer renderer; + try (Reader reader = new StringReader(""" + titles: + notification.leasehold-expired: Your lease ran out + """)) { + renderer = new RealtyNotificationRenderer(TitleConfig.load(reader)); + } + + RenderableNotification rendered = + renderer.render(payload("notification.leasehold-expired", "plot42"), TARGET); + + Assertions.assertEquals("Your lease ran out — plot42", + PlainTextComponentSerializer.plainText().serialize(rendered.title())); + } + + /** An override's colour survives into the rendered title, region suffix and all. */ + @Test + void anOverridesColourSurvivesTheRegionSuffix() throws IOException { + RealtyNotificationRenderer renderer; + try (Reader reader = new StringReader(""" + titles: + notification.outbid: "Outbid" + """)) { + renderer = new RealtyNotificationRenderer(TitleConfig.load(reader)); + } + + RenderableNotification rendered = + renderer.render(payload("notification.outbid", "plot42"), TARGET); + + Assertions.assertEquals("Outbid — plot42", + PlainTextComponentSerializer.plainText().serialize(rendered.title())); + } + /** The body is the payload's component verbatim; the title change must not have touched it. */ @Test void theBodyIsThePayloadComponentVerbatim() { diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java index 0adaad0..cdd2f73 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java @@ -1,5 +1,6 @@ package io.github.md5sha256.realty.adapter.playernotifs; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import org.bukkit.configuration.file.YamlConfiguration; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -97,4 +98,63 @@ void aMissingExpiryFallsBackToTheDefault(@TempDir Path dataFolder) throws IOExce Assertions.assertEquals(Duration.ofDays(30), AdapterConfig.read(dataFolder).expiry()); } + + private static Path titlesReference(Path dataFolder) { + return dataFolder.resolve(AdapterConfig.DEFAULTS_DIR) + .resolve(TitleConfig.REFERENCE_FILE); + } + + @Test + void aFirstStartWritesBothTheLiveTitlesFileAndItsReferenceCopy(@TempDir Path dataFolder) { + TitleConfig.read(dataFolder); + + Assertions.assertTrue(Files.isRegularFile(dataFolder.resolve(TitleConfig.TITLES_FILE))); + Assertions.assertTrue(Files.isRegularFile(titlesReference(dataFolder))); + } + + /** The operator's titles are theirs — seeded once, never rewritten. */ + @Test + void aLaterStartLeavesTheOperatorsTitlesAlone(@TempDir Path dataFolder) throws IOException { + TitleConfig.read(dataFolder); + Path live = dataFolder.resolve(TitleConfig.TITLES_FILE); + String edited = Files.readString(live, StandardCharsets.UTF_8) + .replace("Lease expired", "Your lease ran out"); + Files.writeString(live, edited, StandardCharsets.UTF_8); + + TitleConfig titles = TitleConfig.read(dataFolder); + + Assertions.assertEquals(edited, Files.readString(live, StandardCharsets.UTF_8)); + Assertions.assertEquals("Your lease ran out", PlainTextComponentSerializer.plainText() + .serialize(titles.titleFor("notification.leasehold-expired"))); + } + + @Test + void aStaleTitlesReferenceCopyIsOverwrittenOnEveryStart(@TempDir Path dataFolder) + throws IOException { + TitleConfig.read(dataFolder); + Files.writeString(titlesReference(dataFolder), "# left over from an older version\n", + StandardCharsets.UTF_8); + + TitleConfig.read(dataFolder); + + String refreshed = Files.readString(titlesReference(dataFolder), StandardCharsets.UTF_8); + Assertions.assertFalse(refreshed.contains("left over")); + Assertions.assertTrue(refreshed.contains("notification.leasehold-expired")); + } + + /** + * The shipped reference must itself load, and must load to the titles it documents — a reference + * copy whose keys were nested by the path separator would describe a file that overrides nothing. + */ + @Test + void theTitlesReferenceCopyParsesToTheCompiledTitles(@TempDir Path dataFolder) + throws IOException { + TitleConfig.read(dataFolder); + + try (Reader reader = Files.newBufferedReader(titlesReference(dataFolder))) { + TitleConfig titles = TitleConfig.load(reader); + Assertions.assertEquals("Lease expired", PlainTextComponentSerializer.plainText() + .serialize(titles.titleFor("notification.leasehold-expired"))); + } + } } diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java index fe610ba..ecd785e 100644 --- a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java @@ -23,7 +23,7 @@ class RegistrationLifecycleTest { private static final NotificationRenderer RENDERER = - new RealtyNotificationRenderer(); + new RealtyNotificationRenderer(TitleConfig.compiled()); /** * Stands in for the reflective JSON serializer {@code registerJsonRenderable} installs; only its diff --git a/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfigTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfigTest.java new file mode 100644 index 0000000..12b0265 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/TitleConfigTest.java @@ -0,0 +1,108 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; + +/** + * Covers how {@code titles.yml} is read: the override an operator writes there wins, anything they + * have not got wins from the compiled table, and the message keys survive the trip. + */ +class TitleConfigTest { + + private static TitleConfig load(String yaml) throws IOException { + try (Reader reader = new StringReader(yaml)) { + return TitleConfig.load(reader); + } + } + + private static String plain(TitleConfig titles, String messageKey) { + return PlainTextComponentSerializer.plainText().serialize(titles.titleFor(messageKey)); + } + + /** + * The point of the file. Bukkit splits a dotted path into nested sections at load time, so + * {@code notification.leasehold-expired} only survives as one key if the path separator is + * neutralised before the document is loaded — which is the failure this asserts against. + */ + @Test + void anOverrideWinsOverTheCompiledTitle() throws IOException { + TitleConfig titles = load(""" + titles: + notification.leasehold-expired: Your lease ran out + """); + + Assertions.assertEquals("Your lease ran out", plain(titles, "notification.leasehold-expired")); + } + + /** + * A key the operator's file does not mention — one a newer Realty added after they last touched + * the file — still renders, from the enum. + */ + @Test + void anAbsentKeyFallsBackToTheCompiledTitle() throws IOException { + TitleConfig titles = load(""" + titles: + notification.leasehold-expired: Your lease ran out + """); + + Assertions.assertEquals(RealtyCategory.titleFor("notification.outbid"), + plain(titles, "notification.outbid")); + } + + /** An empty or absent {@code titles} block is an operator who has overridden nothing, not an error. */ + @Test + void aFileWithNoTitlesBlockFallsBackThroughout() throws IOException { + TitleConfig titles = load("# nothing set\n"); + + Assertions.assertEquals("Lease expired", plain(titles, "notification.leasehold-expired")); + Assertions.assertEquals(RealtyCategory.GENERAL.label(), + plain(titles, "notification.some-future-key")); + } + + /** Values are MiniMessage, so an operator can colour a row without a second format setting. */ + @Test + void aValueIsParsedAsMiniMessage() throws IOException { + TitleConfig titles = load(""" + titles: + notification.outbid: "Outbid" + """); + + Assertions.assertEquals("Outbid", plain(titles, "notification.outbid")); + Assertions.assertEquals(NamedTextColor.RED, + titles.titleFor("notification.outbid").color()); + } + + /** + * A key no category claims can still be titled here: Realty gains keys over time and third-party + * fire sites use keys of their own, so the file is not restricted to what the enum knows. + */ + @Test + void anUnclaimedKeyMayStillBeTitled() throws IOException { + TitleConfig titles = load(""" + titles: + notification.some-future-key: A future thing + """); + + Assertions.assertEquals("A future thing", plain(titles, "notification.some-future-key")); + } + + /** + * A blank value is an operator halfway through an edit, not a request for an empty inbox row — + * PlayerNotifications lists a row by its title alone, so an empty one is an unreadable row. + */ + @Test + void aBlankValueFallsBackRatherThanRenderingAnEmptyRow() throws IOException { + TitleConfig titles = load(""" + titles: + notification.outbid: "" + """); + + Assertions.assertEquals("Outbid", plain(titles, "notification.outbid")); + } +}