diff --git a/README.md b/README.md index b9f6b5a..4ab258b 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,41 @@ 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/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 +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` 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 + +`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 diff --git a/buildSrc/src/main/kotlin/realty-conventions.gradle.kts b/buildSrc/src/main/kotlin/realty-conventions.gradle.kts index cb123a1..a24bedd 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 @@ -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") diff --git a/docs/superpowers/specs/2026-08-22-player-notifications-adapter-design.md b/docs/superpowers/specs/2026-08-22-player-notifications-adapter-design.md new file mode 100644 index 0000000..52e0438 --- /dev/null +++ b/docs/superpowers/specs/2026-08-22-player-notifications-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/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: + +```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: player-notifications-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 `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 `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 +`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. 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/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..4c37881 --- /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 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 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/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/player-notifications-adapter/build.gradle.kts b/realty-paper-adapters/player-notifications-adapter/build.gradle.kts new file mode 100644 index 0000000..559436e --- /dev/null +++ b/realty-paper-adapters/player-notifications-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.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.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/NotificationEnqueuer.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationEnqueuer.java new file mode 100644 index 0000000..a48e115 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/NotificationEnqueuer.java @@ -0,0 +1,18 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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/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 new file mode 100644 index 0000000..6da0b9b --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsAdapterModule.java @@ -0,0 +1,119 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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.plugin.Plugin; +import org.jetbrains.annotations.NotNull; +import org.jetbrains.annotations.Nullable; + +import java.nio.file.Files; +import java.nio.file.Path; +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; + + /** + * {@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 — 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 — " + + "player-notifications-adapter cannot start"); + } + + // 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(titles)); + this.service = notificationService; + + // 4. Only now, with nothing left that can throw, does a live listener appear. + registerListener(new PlayerNotificationsListener( + 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; + 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. 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; + } + 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 new file mode 100644 index 0000000..b91d597 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListener.java @@ -0,0 +1,90 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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'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 + * 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 { + + /** + * 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 Duration expiry; + private final Logger logger; + + /** + * @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 Duration expiry, + @NotNull Logger logger) { + this.enqueuer = Objects.requireNonNull(enqueuer, "enqueuer"); + 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 = 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 category table than a mistake, and a player still wants to be told. + this.logger.log(Level.FINE, + "Unclaimed 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, + 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 + // may replace an earlier one in the player's inbox. + this.enqueuer.enqueue(notification, false); + } +} 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..4aec88a --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategory.java @@ -0,0 +1,198 @@ +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.

+ * + *

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.

+ */ +public enum RealtyCategory { + + AGENT("realty.agent", + "Realty agents", + "Agent invitations and removals", + 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", + 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", + 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", + 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 + * why this constant must exist; see {@link #forMessageKey}. + */ + GENERAL("realty.general", + "Realty", + "Purchases, ownership transfers and anything uncategorised", + 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; + + private static final Map BY_MESSAGE_KEY = index(); + + private final String dataType; + private final String label; + private final String description; + private final Map titlesByMessageKey; + + RealtyCategory(@NotNull String dataType, + @NotNull String label, + @NotNull String description, + @NotNull Map titlesByMessageKey) { + this.dataType = dataType; + this.label = label; + this.description = description; + this.titlesByMessageKey = titlesByMessageKey; + } + + /** + * 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.titlesByMessageKey.keySet()) { + 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); + } + + /** + * 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 + * 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 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/RealtyDataTypes.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java new file mode 100644 index 0000000..563230a --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyDataTypes.java @@ -0,0 +1,96 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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; + +/** + * 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 + * 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 every other data type mapped but with no serializer and no renderer. Every + * notification they carry then fails at enqueue or render time.

+ * + *

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.

+ * + *

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 { + + private RealtyDataTypes() { + } + + /** + * 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 + * 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 (RealtyCategory category : RealtyCategory.values()) { + String dataType = category.dataType(); + service.registerJsonRenderable(dataType, RealtyNotificationPayload.class, renderer); + service.dataTypeRegistry().registerDisplayName(dataType, category.label()); + categories.registerCategory(dataType, category.label(), category.description()); + categories.claimDataType(dataType, dataType); + } + } + + /** + * 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) { + 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 + // 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 (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/RealtyNotificationPayload.java b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayload.java new file mode 100644 index 0000000..678160f --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayload.java @@ -0,0 +1,67 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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/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 new file mode 100644 index 0000000..d99cb0a --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/main/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRenderer.java @@ -0,0 +1,59 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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 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 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 + * 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.

+ * + *

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 TitleConfig titles; + + public RealtyNotificationRenderer(@NotNull TitleConfig titles) { + this.titles = Objects.requireNonNull(titles, "titles"); + } + + @Override + public @NotNull RenderableNotification render(@NotNull RealtyNotificationPayload payload, + @NotNull UUID target) { + Component summary = this.titles.titleFor(payload.messageKey()); + String regionId = payload.regionId(); + Component title = regionId == null || regionId.isBlank() + ? summary + : 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/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/main/resources/module-manifest.yml b/realty-paper-adapters/player-notifications-adapter/src/main/resources/module-manifest.yml new file mode 100644 index 0000000..633b91b --- /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.playernotifs.PlayerNotificationsAdapterModule +author: md5sha256 +expected-plugin-class: io.github.md5sha256.realty.Realty +reloadable: true 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/PlayerNotificationsListenerTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java new file mode 100644 index 0000000..86b93e2 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/PlayerNotificationsListenerTest.java @@ -0,0 +1,129 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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.UUID; +import java.util.logging.Logger; + +class PlayerNotificationsListenerTest { + + private static PlayerNotificationsListener listener( + List> enqueued, + List overwriteFlags) { + return new PlayerNotificationsListener( + (notification, overwriteAllowed) -> { + enqueued.add(notification); + overwriteFlags.add(overwriteAllowed); + }, + 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()); + // One priority for every Realty notification: ordering the inbox is PlayerNotifications' job. + Assertions.assertEquals(0, 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 anUnclaimedKeyStillEnqueuesUnderGeneral() { + 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/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..a501086 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyCategoryTest.java @@ -0,0 +1,210 @@ +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; + +/** + * 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()); + } + } + + /** + * Every claimed key carries its own title, and no category reuses one across two of its keys. + * + *

A 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 + * 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"); + } + + /** + * 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/RealtyNotificationPayloadTest.java b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayloadTest.java new file mode 100644 index 0000000..5c9c663 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationPayloadTest.java @@ -0,0 +1,62 @@ +package io.github.md5sha256.realty.adapter.playernotifs; + +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/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..bf2e342 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RealtyNotificationRendererTest.java @@ -0,0 +1,118 @@ +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.io.IOException; +import java.io.Reader; +import java.io.StringReader; +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(TitleConfig.compiled()); + 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"))); + } + + /** 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() { + 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/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..cdd2f73 --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/ReferenceCopyTest.java @@ -0,0 +1,160 @@ +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; +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; +import java.time.Duration; + +/** + * 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(AdapterConfig.DEFAULTS_DIR).resolve(AdapterConfig.REFERENCE_FILE); + } + + @Test + void aFirstStartWritesBothTheLiveFileAndTheReferenceCopy(@TempDir Path dataFolder) { + AdapterConfig.read(dataFolder); + + Assertions.assertTrue(Files.isRegularFile(dataFolder.resolve(AdapterConfig.CONFIG_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 { + AdapterConfig.read(dataFolder); + Path live = dataFolder.resolve(AdapterConfig.CONFIG_FILE); + String edited = Files.readString(live, StandardCharsets.UTF_8) + .replace("expiry-days: 30", "expiry-days: 7"); + Files.writeString(live, edited, StandardCharsets.UTF_8); + + AdapterConfig config = AdapterConfig.read(dataFolder); + + Assertions.assertEquals(edited, Files.readString(live, StandardCharsets.UTF_8)); + Assertions.assertEquals(Duration.ofDays(7), config.expiry()); + } + + /** + * 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 { + AdapterConfig.read(dataFolder); + Files.writeString(reference(dataFolder), "# left over from an older version\n", + StandardCharsets.UTF_8); + + AdapterConfig.read(dataFolder); + + String refreshed = Files.readString(reference(dataFolder), StandardCharsets.UTF_8); + Assertions.assertFalse(refreshed.contains("left over")); + 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 { + AdapterConfig.read(dataFolder); + + try (Reader reader = Files.newBufferedReader(reference(dataFolder))) { + AdapterConfig config = AdapterConfig.from(YamlConfiguration.loadConfiguration(reader)); + Assertions.assertEquals(Duration.ofDays(30), config.expiry()); + } + } + + @Test + void theReferenceCopyIsWrittenEvenWhenTheOperatorAlreadyHasAFile(@TempDir Path dataFolder) + throws IOException { + Files.createDirectories(dataFolder); + Files.writeString(dataFolder.resolve(AdapterConfig.CONFIG_FILE), "expiry-days: 1\n", + StandardCharsets.UTF_8); + + 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()); + } + + 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 new file mode 100644 index 0000000..ecd785e --- /dev/null +++ b/realty-paper-adapters/player-notifications-adapter/src/test/java/io/github/md5sha256/realty/adapter/playernotifs/RegistrationLifecycleTest.java @@ -0,0 +1,216 @@ +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; +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.Optional; +import java.util.Set; +import java.util.UUID; + +/** + * 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 NotificationRenderer RENDERER = + new RealtyNotificationRenderer(TitleConfig.compiled()); + + /** + * 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 registerAll() { + NotificationDataTypeRegistry registry = new NotificationDataTypeRegistry(); + 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(); + + 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(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(); + + RealtyDataTypes.unregisterAll(registry); + + 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(); + + RealtyDataTypes.unregisterAll(registry); + RealtyDataTypes.unregisterAll(registry); + + Assertions.assertTrue(registry.dataTypes().isEmpty()); + } + + @Test + void reRegisteringOverAnExistingRegistrationIsIdempotent() { + NotificationDataTypeRegistry registry = registerAll(); + + 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(RealtyCategory.values().length, registry.dataTypes().size()); + Assertions.assertTrue(registry.getRenderer("realty.auction").isPresent()); + } + + /** + * 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 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 aPartialUnregisterSilentlyBreaksTheRemainingDataTypes() { + NotificationDataTypeRegistry registry = registerAll(); + + registry.unregisterPayloadMapping("realty.auction"); + + Assertions.assertFalse(registry.dataTypes().contains("realty.auction")); + Assertions.assertEquals(RealtyCategory.values().length - 1, registry.dataTypes().size()); + for (RealtyCategory category : RealtyCategory.values()) { + String survivor = category.dataType(); + 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"); + } + } + + /** + * 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 categoriesRegisterWithTheirDefaultsAndClaimTheirOwnDataType() { + NotificationCategoryRegistry categories = new DefaultNotificationCategoryRegistry(); + + for (RealtyCategory category : RealtyCategory.values()) { + categories.registerCategory( + category.dataType(), category.label(), category.description()); + categories.claimDataType(category.dataType(), category.dataType()); + } + + 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)); + } + } + + @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 + 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()); + // The message key's own summary, not the category label — see RealtyNotificationRendererTest. + Assertions.assertEquals(Component.text("Outbid"), rendered.title()); + } +} 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")); + } +} 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-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 8f12c2d..fd7dc1a 100644 --- a/realty-paper/build.gradle.kts +++ b/realty-paper/build.gradle.kts @@ -157,17 +157,14 @@ 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 { 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) } @@ -185,6 +182,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 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 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 // stale: the copy that lived there was built before RealtyApi became RealtyBackend @@ -195,12 +197,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, 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) + 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/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..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()); @@ -401,12 +407,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 +433,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 +449,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 +701,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 player-notifications-adapter.jar in " + moduleDir + "."); + } else if (!this.moduleManager.getActiveModules().containsKey("chat-adapter") + && !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 " + + 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..4e82ee1 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:player-notifications-adapter")