diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml index 16f74af..d8e5ac8 100644 --- a/.github/workflows/gradle.yml +++ b/.github/workflows/gradle.yml @@ -21,10 +21,10 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - name: Set up JDK 21 + - name: Set up JDK 25 uses: actions/setup-java@v5 with: - java-version: '21' + java-version: '25' distribution: 'temurin' - name: Setup Gradle diff --git a/.gitignore b/.gitignore index f56c3ab..8985df8 100644 --- a/.gitignore +++ b/.gitignore @@ -4,9 +4,9 @@ build/ !**/src/main/**/build/ !**/src/test/**/build/ -### run-paper ### -# Working directory of the runServer task: a whole Paper install, worlds and logs. -/automessage-loader/run/ +### run-paper / run-velocity / loom ### +# Working directories of the runServer tasks: whole server installs, worlds and logs. +**/run/ ### IntelliJ IDEA ### .idea/ @@ -39,5 +39,15 @@ bin/ ### VS Code ### .vscode/ +### JVM crash and heap dumps ### +# A heap dump is written next to the working directory when a build runs out of memory, and one +# of these is 750 MB - large enough that GitHub refuses the push that carries it. +*.hprof +hs_err_pid*.log +replay_pid*.log + ### Mac OS ### -.DS_Store \ No newline at end of file +.DS_Store + +# Kotlin build sessions +**/.kotlin/ diff --git a/README.md b/README.md index 6eed8e3..6855750 100644 --- a/README.md +++ b/README.md @@ -1,123 +1,677 @@ -# 💬 AutoMessage +

+ AutoMessage - right message, right player, right moment +

+ +

+ Latest release + Build status + Java 21 or newer; Java 25 for Minestom + Minecraft 1.21 through 26.2 + Six runtime targets +

+ +

+ Modrinth downloads + SpigotMC downloads + Available on Hangar +

+ +

+ Not another chat rotator. A complete announcement engine.
+ Targeted, localized and event-aware broadcasts for modern Minecraft servers and networks. +

+ +

+ 6 runtimes  ·  8 notice surfaces  ·  8 audience rules  ·  3 event triggers  ·  3 languages shipped  ·  one render per language  ·  no database +

+ + + + + + + +
+ +**🧭 Why AutoMessage** + +- [📣 Broadcasts should earn attention](#broadcasts) +- [⚖️ More than a rotating list](#rotating-list) +- [🎯 Reach exactly who should hear it](#audience-rules) +- [⚡ Fire on an event, not a clock](#event-triggers) +- [🪶 Costs your server almost nothing](#performance) +- [🌐 One engine, six runtimes](#runtimes) + + + +**⚙️ Install and configure** + +- [📥 **Get AutoMessage**](#get-automessage) +- [🚀 Quick start](#quick-start) +- [🛠 Build your first announcement](#first-announcement) +- [🗂 Configuration model](#configuration-model) +- [📋 Platform support](#platform-support) +- [🔨 Building from source](#building-from-source) +- [💛 Support the project](#support) + + + +[**📖 Reference**](#reference) + +- [📡 Channels and selectors](#ref-channels) +- [🎯 Audience rules](#ref-audience) +- [⚡ Event triggers](#ref-triggers) +- [🌍 Languages and fallback](#ref-languages) +- [🎨 MiniMessage and placeholders](#ref-minimessage) +- [⏱ Time format](#ref-time) +- [💬 Discord webhook mirror](#ref-discord) +- [⌨️ Commands and permissions](#commands) + +
+ + + +## 📣 Broadcasts should earn attention + +Players learn to ignore announcements when every message reaches everyone. AutoMessage makes server +communication deliberate: every stream keeps its own schedule, every message chooses its own +audience, and the text arrives in the language each player actually plays in - from one shared core +running on **Bukkit, Folia, Sponge, Velocity, Fabric and Minestom**. + +

+ Three announcement surfaces in game at once: a boss bar countdown across the top of the screen, a full-screen title in the middle, and a chat line below it +

+ +

+ One announcement set, in game, on one server: a boss bar countdown, a full-screen title and a chat line. +

+ +### A complete announcement, start to finish + +Greet returning veterans three seconds after they join - never on a timer, never the newcomers, and +in their own language. Two files say all of it: -**Automatic broadcasts your players actually read — and your server never feels.** +```yaml +# scheduledMessages.yml - who gets it, and when +messages: + - name: welcome-back + trigger: + type: JOIN + delay: 3s + rules: + - type: PLAYTIME + min: 10h +``` + +```yaml +# lang/en.yml - what it says +announcements: + welcome-back: + - chat: "Welcome back, {PLAYER}." + sound: "entity.player.levelup MASTER 1.0 1.2" +``` + +And this is what the server does with them: + +

+ Two players join. Three seconds later the veteran with 240 hours played passes the playtime rule and receives the greeting with a level-up sound; the newcomer with twelve minutes played receives nothing at all. +

+ +That is the whole feature set in miniature: an event instead of a clock, an audience instead of +everyone, and text that lives beside every other translation. + + + +## ⚖️ More than a rotating list + +The tagline is not marketing. A rotator answers one question - *what do I say next?* - and sends +the answer to everyone, in one language, on one clock. + +| | A plain rotator | AutoMessage | +|:--|:--|:--| +| **Who receives it** | Everyone online | Permission, group, world, online count, playtime - composed with `ANY_OF`, `NONE_OF`, `NOT` | +| **When it fires** | One global interval | A schedule per channel, plus `JOIN`, `FIRST_JOIN` and re-arming `PLAYER_COUNT` triggers | +| **What it looks like** | A line of chat | Chat, action bar, title, subtitle, boss bar and sound, combined in one announcement | +| **What it says** | One text for everybody | The player's own language, with a per-message fallback | +| **Where it runs** | One server type | Bukkit, Folia, Sponge, Velocity, Fabric and Minestom, each a native artifact | + + +### The details underneath + +| | | | +|:--|:--|:--| +| **🔀 Four rotation strategies**

`SHUFFLE`, `SEQUENTIAL`, `RANDOM` or weighted, chosen per channel. Shuffle deals a whole deck before anything repeats. | **🧩 Configuration that fits the runtime**

A proxy never sees a world rule or a first-join trigger. Options the platform cannot honor are left out of the file, not written and ignored. | **👁 Preview before publishing**

`/automessage view ` renders the real localized message to you alone, ignoring its audience rules. | +| **↗ Beyond the server**

Mirror chat content to a Discord webhook, with player-only placeholders stripped and automatic mentions disabled. | **♻ Reload, do not restart**

`/automessage reload` applies schedules, messages and languages in place - including a translation file added while the server was running. | **🪶 Lean dispatch path**

No database. An empty server short-circuits, placeholders are scanned once per message rather than once per announcement, and everyone reading the same text in the same language is served from a single render. | + + + + +## 🎯 Reach exactly who should hear it + +Rules compose. Every top-level rule must pass, and `ANY_OF`, `NONE_OF` and `NOT` nest inside them +when the audience needs more than an AND. + +

+ Twelve players online; a rule requiring the rank.vip permission and at least ten hours of playtime selects five of them, the rest stay dark +

+ +The same rules apply to a message sent by an event, so a join greeting restricted to a permission +never reaches somebody who lacks it just because they walked through the door. + + + +## ⚡ Fire on an event, not a clock + +A message with a trigger leaves its channel rotation entirely and waits for something to happen. + +

+ Three lanes: a channel firing on a five-minute period, a join trigger firing three seconds after a player connects, and a player-count milestone firing once at one hundred online and re-arming only after the count falls below +

+ +`PLAYER_COUNT` is the one worth reading twice: it fires **once** on the way up and re-arms only +after the server drops back below the threshold, so a population hovering on the boundary does not +announce the same milestone every time somebody logs in. + + + +## 🪶 Costs your server almost nothing + +Formatting is the expensive part of an announcement. Colors, gradients, hover text and placeholders +all have to be parsed before a single character reaches anybody - and the obvious way to do that is +once for each player who receives it. Five hundred players online means the same sentence built five +hundred times to say one thing, on the main thread, while the rest of the tick waits its turn. + +AutoMessage builds it once per language and hands the finished result to everyone reading it. + +

+ One announcement reaching 500 players. Formatting once per player means 500 parses of the same text and costs 5.16 ms, about a tenth of a server tick. AutoMessage formats once per language and costs 0.34 ms. +

+ +| | Formatting per player | AutoMessage | +|:--|:--|:--| +| **Parses per announcement** | One per player, so 500 | One per language actually being read | +| **Time on the main thread** | 5.16 ms | 0.34 ms | +| **Share of a 50 ms tick** | ~10%, every announcement | ~0.7% | + +That is **15× less main-thread time per announcement**, measured on the real send path with 500 +viewers online - not on a microbenchmark of one function. What remains is handing the finished text +to each connection, which no plugin can avoid. + +The saving grows with your player count, and it costs you nothing to configure: there is no cache to +size, no setting to tune, and no behavior to give up. + +**It never trades correctness for speed.** A message that reads differently for each player - one +naming `{PLAYER}`, or carrying any PlaceholderAPI token - is still built for each of them, because +only the expansion behind that token knows whether two players would see the same thing. Grouping +applies exactly where the text is genuinely identical, and nowhere else. + +Three more places the dispatch path refuses to do needless work: + +- **Placeholders are scanned once per message, not once per announcement.** Which tokens a message + contains is decided by the text, so it is worked out when the files are read and remembered until + a reload - rather than re-derived every time the channel fires. +- **An empty server short-circuits.** Nobody online means no selection, no formatting and no work. +- **Nothing touches disk while announcing.** No database, no player records, no I/O on the hot path. + + + +## 🌐 One engine. Six runtimes + +AutoMessage uses a shared platform-neutral core and a small runtime adapter for each server type. +That means the behavior stays familiar while scheduling, viewers, permissions and lifecycle hooks +remain native to the platform underneath. + +

+ One platform-neutral core connected to six artifacts: Bukkit, Folia, Sponge, Velocity, Fabric and Minestom +

+ + + +| Artifact | Runtime | Supported line | Java | Platform-specific notes | +|:--|:--|:--|:--:|:--| +| **Bukkit** | Spigot, Paper, Purpur and other Bukkit-derived servers | Minecraft `1.21–26.2` | 21+ | Full feature set; optional PlaceholderAPI integration | +| **Folia** | Folia | Minecraft `1.21–26.2` | 21+ | Dedicated regionized scheduling; optional PlaceholderAPI integration | +| **Sponge** | Sponge API 17 | Minecraft `1.21.10` | 21+ | Full feature set except PlaceholderAPI | +| **Velocity** | Velocity `3.5.1` proxy | Proxy runtime | 21+ | No world, playtime, first-join or PlaceholderAPI capabilities | +| **Fabric** | Dedicated Fabric server | Minecraft `1.21.11` | 21+ | Server-side mod; Fabric API required; no PlaceholderAPI | +| **Minestom** | Embedded Minestom library | Protocol `1.21.11` | 25+ | Classpath library; no world, playtime, first-join or PlaceholderAPI; permission/group rules require your callback | + +> [!NOTE] +> Java 21 is the bytecode floor for the plugin artifacts, not a promise that every Minecraft +> server version runs on Java 21. Use the JVM required by your server; Minestom itself requires +> Java 25. + +Each artifact writes a platform-aware configuration. Options the runtime cannot honor are left out +instead of being generated and silently ignored. + +

+ The same generated file on Paper and on a Velocity proxy: the world rule, playtime rule, first-join trigger, world placeholder, PlaceholderAPI note and playtime example are absent from the proxy file +

+ +
+What about Quilt? + +There is no separate Quilt artifact. Quilt can read Fabric metadata, but this branch does not claim +a tested Quilt version. Treat the Fabric jar on Quilt as experimental and bring a compatible +Fabric API yourself. + +
+ + + +## 🗂 Configuration model + +AutoMessage separates **when**, **who** and **what**. Adding a language never requires copying +scheduling rules, and changing a channel interval never touches the message text. + +```mermaid +flowchart LR + schedule["config.yml
WHEN
channels · periods · selectors"] + audience["scheduledMessages.yml
WHO
rules · weights · triggers"] + content["lang/*.yml
WHAT
localized MiniMessage notices"] + minecraft["Minecraft
PLAYER OUTPUT"] + discord["discordWebhook.yml
OPTIONAL MIRROR"] + + schedule --> audience --> content --> minecraft + content -. "chat text" .-> discord + + classDef config fill:#11182d,stroke:#8b7cf6,color:#eef0ff,stroke-width:1px; + classDef output fill:#0d2028,stroke:#45d7e8,color:#ecfeff,stroke-width:1px; + class schedule,audience,content config; + class minecraft,discord output; +``` + +| File | Responsibility | +|:--|:--| +| `config.yml` | Master switch, fallback language, channels, startup delays, periods and selectors | +| `scheduledMessages.yml` | Message names, channel assignment, weights, audience rules and event triggers | +| `lang/.yml` | Localized command replies and the actual MiniMessage announcement payloads | +| `discordWebhook.yml` | Opt-in Discord webhook, display name and avatar | + + + +## 📥 Get AutoMessage + +| What you need | Where to get it | +|:--|:--| +| Current public Bukkit build | [Modrinth](https://modrinth.com/plugin/automessage) · [SpigotMC](https://www.spigotmc.org/resources/automessage.112363/) · [Hangar](https://hangar.papermc.io/imDMK/AutoMessage) | +| Release history and changelogs | [GitHub Releases](https://github.com/imDMK/AutoMessage/releases) | +| Multiplatform artifacts from this branch | [Build all six from source](#building-from-source) | + +> [!IMPORTANT] +> **Release status:** this README documents the local multiplatform branch. The current public +> listings still represent the earlier Bukkit-focused release line. Until a new GitHub release +> publishes all six artifacts, build the multiplatform jars with `./gradlew dist`. + + + +## 🚀 Quick start -[![Build Status](https://github.com/imDMK/AutoMessage/actions/workflows/gradle.yml/badge.svg)](https://github.com/imDMK/AutoMessage/actions/workflows/gradle.yml) -![JDK](https://img.shields.io/badge/JDK-21%2B-blue.svg) -![Supported versions](https://img.shields.io/badge/Minecraft-1.21--26.2-green.svg) -[![SpigotMC](https://img.shields.io/badge/SpigotMC-yellow.svg)](https://www.spigotmc.org/resources/automessage.112363/) -[![Bukkit](https://img.shields.io/badge/Bukkit-blue.svg)](https://legacy.curseforge.com/minecraft/bukkit-plugins/auto-messages) -[![PaperMC](https://img.shields.io/badge/Paper-004ee9.svg)](https://hangar.papermc.io/imDMK/AutoMessage) -[![Modrinth](https://img.shields.io/badge/Modrinth-1bd96a.svg)](https://modrinth.com/plugin/automessage) -[![Polymart](https://img.shields.io/badge/Polymart-green.svg)](https://polymart.org/product/7890/automessage-1-17-1-21-5) -[![bStats](https://img.shields.io/badge/bStats-00695c)](https://bstats.org/plugin/bukkit/AutoMessages/19487) -[![Donate](https://img.shields.io/badge/Donate-PayPal-0070ba.svg)](https://paypal.me/dominiksuliga) +1. Get the artifact built for your runtime. Platform jars are intentionally separate. +2. Put it in your platform's `plugins/` or `mods/` directory. For Minestom, add the jar to the + application classpath and use the builder shown below. +3. Start the server once. AutoMessage generates annotated configuration plus working example + announcements. +4. Edit `config.yml`, `scheduledMessages.yml` and the files inside `lang/`. +5. Run `/automessage reload`, then test a message with `/automessage view `. ---- +> [!TIP] +> Start with the generated files. They are not empty templates: every available field is explained +> next to a working example, and the contents adapt to the platform that created them. -Rotating announcements are the cheapest way to keep a server alive — vote reminders, rank perks, -Discord links, event countdowns. Done badly they spam chat and eat ticks. **AutoMessage** does them -properly: gradient-styled messages across **chat, action bar, titles, boss bars and sounds**, shown -to exactly the players who should see them, on a schedule you control down to the millisecond. + -One pass over the online players per interval. That is the entire cost. +## 🛠 Build your first announcement ---- +The example below creates an independent event stream, adds one announcement to it, and renders +multiple notice types from a single localized payload. -## ✨ Why AutoMessage +### 1. Schedule the stream -| | | -|------------------------------|------------------------------------------------------------------------------------------------------------------------------------------| -| 🎨 **Every delivery channel** | Chat, ActionBar, Title & Subtitle, BossBar (color, style, progress, duration) and sounds — mix them freely inside a single message. | -| 🎯 **Audience rules** | Send to everyone, to a permission, or to a rank. Each message chooses its own audience. | -| 🖌️ **Full MiniMessage** | Gradients, hover, click, colors — the whole [Kyori Adventure](https://github.com/KyoriPowered/adventure) syntax, no legacy colour codes. | -| ⏱️ **Timing you control** | Human-readable intervals (`30s`, `1m30s`, `2h`) with a separate startup delay. | -| 🔀 **Rotation modes** | Sequential or random — and the rotation survives a reload instead of jumping back to the first message. | -| 👁️ **Preview before you ship**| `/automessage view ` renders a message to you alone, audience rules ignored. | -| ♻️ **Reload without restart** | Every value re-reads live, the schedule included. | -| 🪶 **Genuinely lightweight** | No database, no per-tick work, no runtime reflection. | +```yaml +# config.yml +enabled: true +fallbackLanguage: en + +channels: + - name: default + enabled: true + initialDelay: 1m + period: 5m + selector: SHUFFLE + + - name: events + enabled: true + initialDelay: 30s + period: 15m + selector: SEQUENTIAL +``` + +### 2. Register the announcement + +```yaml +# scheduledMessages.yml +messages: + - name: event-night + channel: events +``` + +### 3. Design what players receive + +```yaml +# lang/en.yml +announcements: + event-night: + - chat: + - "[EVENT] The arena opens in 5 minutes." + - "Click Teleport'>here to join." + actionbar: "Prepare your loadout - the event starts soon" + title: "EVENT NIGHT" + subtitle: "The arena opens in 05:00" + times: "500ms 3s 500ms" + bossbar: + message: "Event countdown" + duration: 10s + color: PURPLE + overlay: PROGRESS + progress: 0.75 + sound: "block.note_block.pling MASTER 1.0 1.0" +``` + +

+ The same announcement on four surfaces at once: a boss bar countdown, a gradient title and subtitle, two chat lines and an action bar above the hotbar +

+ +Use only the parts that fit the message. A simple chat announcement can be a single string; a +showcase announcement can combine every surface above. + + + +## 📖 Reference + + + +
+Announcement channels and selectors ---- +Every channel has its own schedule and independent selector state: -## 🚀 Getting started +```yaml +channels: + - name: tips + enabled: true + initialDelay: 1m + period: 5m + selector: SHUFFLE + + - name: promotions + enabled: true + initialDelay: 2m + period: 20m + selector: WEIGHTED +``` -1. Drop the jar into `plugins/` and start the server. -2. Edit `scheduledMessages.yml` — a working example is generated for you. -3. Run `/automessage reload`. That is it. +| Selector | Behavior | +|:--|:--| +| `SHUFFLE` | Randomizes the order and shows every message once before anything repeats | +| `SEQUENTIAL` | Walks straight through the configured list | +| `RANDOM` | Draws independently; the same message may appear twice in a row | +| `WEIGHTED` | Draws randomly in proportion to each message's `weight` | -### 📦 Requirements +`SEQUENTIAL` and `SHUFFLE` keep their position across a normal configuration reload. A weight of +`0` parks a message only in a `WEIGHTED` channel; other selectors do not consult weights. -- **Java 21** or newer -- **Spigot / Paper 1.21 – 26.2** +
---- + -## ⏱️ Message interval +
+Audience rules -The schedule lives in `messagesDispatcher.yml`: +Top-level rules behave like logical **AND**. Nest `ANY_OF`, `NONE_OF` and `NOT` when the audience +needs more expressive logic. + +| Rule | Matches | +|:--|:--| +| `PERMISSION` | Viewers holding a permission node | +| `GROUP` | Viewers holding `group.`; this is permission-based, not a direct LuckPerms/Vault lookup | +| `WORLD` | Viewers in one of the configured worlds | +| `PLAYER_COUNT` | While the live online count is inside a configured range | +| `PLAYTIME` | Viewers whose native platform playtime is inside a configured duration range | +| `ANY_OF` | At least one nested rule | +| `NONE_OF` | None of the nested rules | +| `NOT` | The inverse of one nested rule | ```yaml -# How much time passes between two announcements. -period: 30s +messages: + - name: vip-event + channel: events + rules: + - type: ANY_OF + rules: + - type: PERMISSION + permission: rank.vip + - type: PERMISSION + permission: rank.staff + - type: PLAYER_COUNT + min: 10 + max: 100 +``` + +Rules still apply to messages sent by an event trigger. Platform-specific rules are generated only +where the runtime can supply the required data. + +
+ + + +
+Event triggers -# How long to wait after startup before the first announcement. -initialDelay: 10s +A triggered message leaves its timed channel rotation completely: + +```yaml +messages: + - name: welcome-back + trigger: + type: JOIN + delay: 3s + + - name: first-join-welcome + trigger: + type: FIRST_JOIN + delay: 3s + + - name: one-hundred-online + trigger: + type: PLAYER_COUNT + threshold: 100 ``` -Every time value carries an explicit unit: +`PLAYER_COUNT` fires once when the threshold is reached and re-arms only after the online count +drops below it. `FIRST_JOIN` is unavailable on runtimes that do not expose a persistent first-join +signal. + +> [!NOTE] +> `/automessage enable` and `/automessage disable` control scheduled channels. Event-triggered +> announcements operate independently. + +
+ + + +
+Languages and fallback behavior + +AutoMessage ships `en`, `pl` and `de`. Add another language by copying a file to +`lang/.yml`, translating it and running `/automessage reload`. + +For a player using `pt_br`, AutoMessage looks for: -| Unit | Meaning | Example | -|------|--------------|---------| +1. `lang/pt_br.yml` +2. `lang/pt.yml` +3. the `fallbackLanguage` from `config.yml` + +A missing announcement falls back individually, so a new translation does not have to be complete +before it is useful. + +
+ + + +
+MiniMessage, placeholders and notification parts + +All text uses [Kyori MiniMessage](https://docs.papermc.io/adventure/minimessage/format/), including colors, +gradients, decorations, hover text and click actions. + +Built-in placeholders: + +| Player-scoped | Server-scoped | +|:--|:--| +| `{PLAYER}` · `{DISPLAY_NAME}` · `{UUID}` · `{WORLD}` | `{ONLINE}` · `{MAX_PLAYERS}` · `{DATE}` · `{TIME}` | + +On Bukkit and Folia, an installed PlaceholderAPI is detected automatically. Standard +`%placeholder_name%` tokens containing letters, digits and underscores can then be used alongside +the built-ins. + +Available notification keys: + +| Key | Value | +|:--|:--| +| bare string / `chat` | One chat line or a list of lines | +| `actionbar` | MiniMessage text shown above the hotbar | +| `title` / `subtitle` | Title layers; either can be used independently | +| `times` | Three durations: fade in, stay, fade out | +| `hideTitle` | Explicitly clear the current title | +| `bossbar` | Message, duration, color, overlay and optional progress `0.0–1.0` | +| `sound` | Sound key, or `key source volume pitch` | + +Need a visual starting point? Try the external +[EternalCode Notification Generator](https://eternalcode.pl/notification-generator) and adapt the +generated notification to the language-file structure above. + +
+ + + +
+Time format + +Every duration accepts an explicit unit, and units can be combined: + +| Unit | Meaning | Example | +|:--:|:--|:--| | `ms` | milliseconds | `500ms` | -| `s` | seconds | `30s` | -| `m` | minutes | `5m` | -| `h` | hours | `1h` | +| `s` | seconds | `30s` | +| `m` | minutes | `5m` | +| `h` | hours | `2h` | +| `d` | days | `1d` | -Units combine (`1m30s`). A plain number is read as **seconds**, so `period: 10` means ten seconds — -the plugin rewrites it as `10s` on the next load. +`1m30s` is valid. A plain number is interpreted as seconds. Channel periods shorter than `50ms` +are normalized, and tick-based runtimes ultimately execute on tick boundaries. -Both values are applied by `/automessage reload`; a server restart is not required. +
---- + -## 🔐 Commands & permissions +
+Discord webhook mirror -| Command | Permission | Description | -|----------------------------|-------------------------------|------------------------------------------| -| `/automessage enable` | `command.automessage.enable` | Resume automatic broadcasts | -| `/automessage disable` | `command.automessage.disable` | Pause automatic broadcasts | -| `/automessage reload` | `command.automessage.reload` | Re-read every configuration file | -| `/automessage view ` | `command.automessage.view` | Preview one message, with tab-completion | +Set `enabled: true` and provide a Discord webhook URL in `discordWebhook.yml` to mirror scheduled +and triggered announcements. ---- +The integration is deliberately conservative: -## ✨ Showcase +- only chat parts are mirrored; +- MiniMessage is flattened to plain text; +- the configured fallback language is used; +- server placeholders are resolved, while player-scoped values are removed; +- automatic Discord mentions are disabled; +- the webhook must use HTTPS and a Discord webhook host/path; +- rate-limit responses are retried with backoff. -![AutoMessage Chat Preview](assets/automessage-preview.gif) +The webhook is opt-in and makes no outbound request while disabled or missing a valid URL. Restart +AutoMessage after changing the webhook's enabled state or URL so the connection is rebuilt. ---- +
-## 🔔 Configuring notifications + + +### Commands and permissions + +| Command | Permission | Description | +|:--|:--|:--| +| `/automessage enable` | `command.automessage.enable` | Resume scheduled announcement channels | +| `/automessage disable` | `command.automessage.disable` | Pause scheduled announcement channels | +| `/automessage reload` | `command.automessage.reload` | Reload schedules, message definitions and languages | +| `/automessage view ` | `command.automessage.view` | Preview one localized message; player-only, with tab completion | +| `/automessage next` | `command.automessage.next` | Show what each channel will announce next and how long until it fires, without using it up | +| `/automessage send ` | `command.automessage.send` | Send that channel's next announcement now; its interval restarts from this moment | +| `/automessage toggle` | `command.automessage.toggle` | A player turns announcements off for themselves, or back on; remembered across restarts | +| `/automessage stats` | `command.automessage.stats` | Per channel: how much it has carried, when the last one went out and when the next is due, then the same per message | + +
+Embedding AutoMessage in Minestom + +Minestom has no plugin directory. Initialize it, place AutoMessage on the classpath and enable the +library after Minestom itself is initialized: + +```java +MinecraftServer.init(); + +AutoMessageMinestom autoMessage = AutoMessageMinestom.builder() + .dataDirectory(Path.of("automessage")) + .permissions((sender, node) -> myPermissions.check(sender, node)) // optional + .enable(); + +// During your server shutdown: +autoMessage.shutdown(); +``` + +Without a permission callback, AutoMessage uses Minestom's operator level to protect its commands. +Permission and group audience rules are then omitted because the runtime cannot answer them +accurately. + +
+ + + +## 🔨 Building from source + +Building every runtime requires **JDK 25**. From the repository root, run: + +```bash +./gradlew dist +``` -Build message payloads visually with the -[EternalCode notification generator](https://eternalcode.pl/notification-generator). +The task runs all checks, builds each platform-specific jar and synchronizes the six installable +artifacts into `build/dist`: ---- +- Bukkit +- Folia +- Sponge +- Velocity +- Fabric +- Minestom -## 💛 Donate +If any verification fails, the distribution is not produced. A normal `./gradlew build` performs +the same project checks but leaves artifacts inside their individual modules. -AutoMessage is free, open source, and maintained in my own time. If it saves you work — or your -server earns from the announcements it sends — consider buying me a coffee: + -### 👉 **[paypal.me/dominiksuliga](https://paypal.me/dominiksuliga)** +## 💛 Support the project -Every contribution goes straight back into new features, faster support, and day-one compatibility -with each Minecraft release. Not in a position to donate? A ⭐ on GitHub or a review on -[SpigotMC](https://www.spigotmc.org/resources/automessage.112363/) helps just as much. +AutoMessage is free, open source and maintained independently. ---- +| Found a problem? | Want to contribute? | Want to support development? | +|:--|:--|:--| +| [Open a bug report or feature request](https://github.com/imDMK/AutoMessage/issues/new/choose) | Read the [contribution guide](.github/CONTRIBUTING.md) and open a pull request | [GitHub Sponsors](https://github.com/sponsors/imDMK) · [PayPal](https://paypal.me/dominiksuliga) | -## 🛠️ Feedback & support +Please follow the project's [Code of Conduct](.github/CODE_OF_CONDUCT.md) in all community spaces. +AutoMessage is distributed under the [GNU General Public License v3.0](LICENSE). -Found a bug? Have an idea? Want to contribute? -👉 [Open an issue](https://github.com/imDMK/AutoMessage/issues) — feedback is genuinely welcome. +

+ If AutoMessage makes your server communication better, leave the repository a star.
+ It helps other server owners discover the project. +

diff --git a/assets/automessage-preview.gif b/assets/automessage-preview.gif deleted file mode 100644 index 69bc07a..0000000 Binary files a/assets/automessage-preview.gif and /dev/null differ diff --git a/assets/readme/announcement-ingame.png b/assets/readme/announcement-ingame.png new file mode 100644 index 0000000..e4e3763 Binary files /dev/null and b/assets/readme/announcement-ingame.png differ diff --git a/assets/readme/announcement.svg b/assets/readme/announcement.svg new file mode 100644 index 0000000..f932b8e --- /dev/null +++ b/assets/readme/announcement.svg @@ -0,0 +1,71 @@ + + One announcement, as a player receives it + A game view showing the same announcement on four surfaces at once: a boss bar countdown at the top, a gradient title and subtitle in the centre, two chat lines at the bottom left, and an action bar above the hotbar. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Event countdown + + + + + EVENT NIGHT + The arena opens in 05:00 + + + + + [EVENT] The arena opens in 5 minutes. + + + Click here to join. + + + + Prepare your loadout — the event starts soon + + + + + + + + + + + + + diff --git a/assets/readme/audience.svg b/assets/readme/audience.svg new file mode 100644 index 0000000..bf3454c --- /dev/null +++ b/assets/readme/audience.svg @@ -0,0 +1,110 @@ + + Audience rules, applied to a live roster + Twelve players online. A rule requiring the rank.vip permission and at least ten hours of playtime selects 5 of them; the rest are dimmed and receive nothing. + + + + + + + + + + + + + + + + + THE RULE + + + rules: + - type: PERMISSION + permission: rank.vip + - type: PLAYTIME + min: 10h + + every rule must pass + + 12 PLAYERS ONLINE + + + S + Steve + VIP · 42h + + + + A + Alex + VIP · 3h + + + + K + Kuba + 61h + + + + M + Mira + VIP · 18h + + + + R + Ren + 9h + + + + N + Nova + VIP · 27h + + + + I + Iris + 2h + + + + O + Otto + VIP · 55h + + + + P + Pax + 12h + + + + Y + Yuki + VIP · 34h + + + + Z + Zed + VIP · 7h + + + + E + Elle + 21h + + + + 5 of 12 receive this announcement. The other 7 never see it. + + + + diff --git a/assets/readme/hero.svg b/assets/readme/hero.svg new file mode 100644 index 0000000..850b660 --- /dev/null +++ b/assets/readme/hero.svg @@ -0,0 +1,149 @@ + + AutoMessage — multiplatform Minecraft announcements + A dark product banner showing the AutoMessage name, its right message, right player, right moment tagline, and a live announcement preview. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MULTIPLATFORM ANNOUNCEMENTS + + + AutoMessage + Right message. Right player. Right moment. + Schedule, target, trigger and localize every server announcement. + + + + + + SCHEDULE + + + + + TARGET + + + + + TRIGGER + + + + + LOCALIZE + + + + BUKKIT · FOLIA · SPONGE · VELOCITY · FABRIC · MINESTOM + + + + + + + + + LIVE ANNOUNCEMENT + + + ON + + + EVENT NIGHT + The arena opens in 05:00 + + + + ! + Vote now and claim your reward + Personalized for every eligible player + + + + SERVER RESTART · 10 MINUTES + + + + CHAT + + ACTION BAR + + TITLE + + BOSS BAR + + SOUND + + + + + SHUFFLE + · + VIP + WORLD + · + EN / PL / DE + · + DISCORD + + + + + diff --git a/assets/readme/logo.png b/assets/readme/logo.png new file mode 100644 index 0000000..db3b8d8 Binary files /dev/null and b/assets/readme/logo.png differ diff --git a/assets/readme/logo.svg b/assets/readme/logo.svg new file mode 100644 index 0000000..08c5c4c --- /dev/null +++ b/assets/readme/logo.svg @@ -0,0 +1,61 @@ + + AutoMessage + A speech bubble carrying three announcement dots, with broadcast waves radiating from behind it. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/assets/readme/performance.svg b/assets/readme/performance.svg new file mode 100644 index 0000000..5ccd232 --- /dev/null +++ b/assets/readme/performance.svg @@ -0,0 +1,285 @@ + + One render for everyone reading the same text + Two lanes comparing how one announcement reaches 500 players. Formatting once per player means 500 parses of the same text and costs 5.16 milliseconds, about a tenth of a server tick. AutoMessage formats once per language and hands the finished result to everyone, costing 0.34 milliseconds. + + + + + + + + + + + + + ONE ANNOUNCEMENT, 500 PLAYERS ONLINE + + THE USUAL WAY - FORMAT ONCE PER PLAYER + + 1 message + to announce + + + + + + + + + + + + + + + + + + + + + + + + + + + 500 parses of the same text + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 500 players + 5.16 ms + + + a tenth of a 50 ms tick, every time + The same gradient and the same tags, built again from scratch for every single recipient. + + AUTOMESSAGE - FORMAT ONCE PER LANGUAGE + + 1 message + to announce + + + + EN + 1 parse, reused by all of them + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 500 players + 0.34 ms + + + 0.7% of a 50 ms tick + One extra parse only when somebody reads another language. Text that names the player is still built per player. + Measured on the real send path with 500 viewers online. What is left is handing the finished text to each connection, which nothing can avoid. + + diff --git a/assets/readme/platform-config.svg b/assets/readme/platform-config.svg new file mode 100644 index 0000000..6e5393b --- /dev/null +++ b/assets/readme/platform-config.svg @@ -0,0 +1,97 @@ + + The same plugin writes a different file on each platform + Two generated configuration files side by side. On Paper every option is written. On a Velocity proxy the world rule, the playtime rule, the first-join trigger, the world placeholder, the PlaceholderAPI note and the playtime example are absent, because the runtime cannot honour them. + + + + + + + + ONE PLUGIN · TWO PLATFORMS · TWO DIFFERENT FILES + + + scheduledMessages.yml on Paper + everything the server can honour + 119 lines + + + type: PERMISSION + + + type: GROUP + + + type: PLAYER_COUNT + + + trigger: JOIN + + + trigger: PLAYER_COUNT + + + {PLAYER} {ONLINE} + + + type: WORLD + + + type: PLAYTIME + + + trigger: FIRST_JOIN + + + {WORLD} + + + %placeholderapi% + + + newcomer-tip + + the same file on a Velocity proxy + a proxy has no worlds and keeps no playtime + 99 lines + + + type: PERMISSION + + + type: GROUP + + + type: PLAYER_COUNT + + + trigger: JOIN + + + trigger: PLAYER_COUNT + + + {PLAYER} {ONLINE} + + + type: WORLD + + + type: PLAYTIME + + + trigger: FIRST_JOIN + + + {WORLD} + + + %placeholderapi% + + + newcomer-tip + + An option a platform cannot honour is worse than a missing one: it is set, nothing happens, and nothing says why. + + + diff --git a/assets/readme/platforms.svg b/assets/readme/platforms.svg new file mode 100644 index 0000000..5c9bde3 --- /dev/null +++ b/assets/readme/platforms.svg @@ -0,0 +1,54 @@ + + One core, six runtimes + A single platform-neutral core connected to six platform artifacts: Bukkit, Folia, Sponge, Velocity, Fabric and Minestom, each with the Minecraft line it targets. + + + + + + + + + + + + + + + + + + + + + + + + + + + One core + no server API inside + + Bukkit + 1.21 – 26.2 + + Folia + 1.21 – 26.2 + + Sponge + 1.21.10 + + Velocity + proxy 3.x + + Fabric + 1.21.11 + + Minestom + 1.21.11 + shared behaviour + native scheduling, viewers, permissions and lifecycle + + + diff --git a/assets/readme/start-to-finish.svg b/assets/readme/start-to-finish.svg new file mode 100644 index 0000000..17b57d9 --- /dev/null +++ b/assets/readme/start-to-finish.svg @@ -0,0 +1,46 @@ + + What the two configuration files above actually do + Two players join the same server. Three seconds later the veteran with 240 hours played passes the playtime rule and receives the chat greeting with a level-up sound. The newcomer with twelve minutes played fails the rule and receives nothing at all. + + + + + + + + + + TWO PLAYERS JOIN THE SAME SERVER + + + + Kasia + 240 hours played + + + 3s + + PLAYTIME min 10h + passes + + + Welcome back, Kasia. + + entity.player.levelup + + + + Tomek + 12 minutes played + + + 3s + + PLAYTIME min 10h + does not pass + + + Nothing at all - and no line in chat to wonder about. + No timer fired. The greeting exists only as a reaction to joining, and only for the half of the room the rule selected. + + diff --git a/assets/readme/triggers.svg b/assets/readme/triggers.svg new file mode 100644 index 0000000..a4126d8 --- /dev/null +++ b/assets/readme/triggers.svg @@ -0,0 +1,46 @@ + + Three ways an announcement can fire + A timeline with three lanes: a channel sending on a fixed period, a JOIN trigger firing shortly after a player connects, and a PLAYER_COUNT milestone that fires once when the online count crosses one hundred and re-arms only after it falls below. + + + + + + + + + + + + THREE WAYS AN ANNOUNCEMENT CAN FIRE + + On a channel + period: 5m + + + + On a join + delay: 3s + + + player joins + + + sent + + On a milestone + threshold: 100 + + 100 online + + + fires + + re-arms below 100 + + fires again + + A message with a trigger leaves the rotation entirely — and its audience rules still apply. + + + diff --git a/automessage-api/build.gradle.kts b/automessage-api/build.gradle.kts new file mode 100644 index 0000000..5d96a78 --- /dev/null +++ b/automessage-api/build.gradle.kts @@ -0,0 +1,10 @@ +plugins { + `automessage-testing` +} + +// What the core needs from whatever server it runs on: interfaces only, and only ones a platform +// can be expected to implement. Depends on Adventure, because an audience is how a message reaches +// a reader everywhere, and on nothing else at all - no server API, not even Bukkit's. +dependencies { + api("net.kyori:adventure-api:${Versions.KYORI_ADVENTURE}") +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/Platform.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/Platform.java new file mode 100644 index 0000000..7f9935e --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/Platform.java @@ -0,0 +1,19 @@ +package com.github.imdmk.automessage.platform; + +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; + +public interface Platform { + + String name(); + + Capabilities capabilities(); + + ViewerRegistry viewers(); + + TaskScheduler scheduler(); + + PlaytimeSource playtime(); +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capabilities.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capabilities.java new file mode 100644 index 0000000..39e3c22 --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capabilities.java @@ -0,0 +1,39 @@ +package com.github.imdmk.automessage.platform.capability; + +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Arrays; +import java.util.EnumSet; +import java.util.Set; + +public record Capabilities(@Unmodifiable Set supported) { + + public Capabilities { + supported = supported.isEmpty() + ? Set.of() + : Set.copyOf(EnumSet.copyOf(supported)); + } + + public static Capabilities of(Capability... capabilities) { + return new Capabilities(capabilities.length == 0 + ? Set.of() + : EnumSet.copyOf(Arrays.asList(capabilities))); + } + + public static Capabilities all() { + return new Capabilities(EnumSet.allOf(Capability.class)); + } + + // A subtraction on purpose: a capability added later is supported by default on these + // platforms and has to be argued out, rather than remembered in. + public static Capabilities allExcept(Capability... unsupported) { + final EnumSet supported = EnumSet.allOf(Capability.class); + supported.removeAll(Arrays.asList(unsupported)); + + return new Capabilities(supported); + } + + public boolean supports(Capability capability) { + return supported.contains(capability); + } +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capability.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capability.java new file mode 100644 index 0000000..6efde4b --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/capability/Capability.java @@ -0,0 +1,24 @@ +package com.github.imdmk.automessage.platform.capability; + +public enum Capability { + + WORLD_RULE, + + PLAYTIME_RULE, + + FIRST_JOIN_TRIGGER, + + PERMISSION_RULE, + + GROUP_RULE, + + SOUND_NOTICE, + + BOSSBAR_NOTICE, + + TITLE_NOTICE, + + EXTERNAL_PLACEHOLDERS, + + METRICS +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java new file mode 100644 index 0000000..0e3928c --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java @@ -0,0 +1,45 @@ +package com.github.imdmk.automessage.platform.logger; + +import org.intellij.lang.annotations.PrintFormat; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; + +public interface PluginLogger { + + enum Level { + INFO, + WARN, + ERROR + } + + // The only method a platform implements. Formatting used to live in each of them instead, + // which is how one ended up formatting under the default locale and the others under ROOT. + void log(Level level, String message, @Nullable Throwable throwable); + + default void info(@PrintFormat String message, Object... args) { + log(Level.INFO, format(message, args), null); + } + + default void warn(@PrintFormat String message, Object... args) { + log(Level.WARN, format(message, args), null); + } + + default void warn(Throwable throwable, @PrintFormat String message, Object... args) { + log(Level.WARN, format(message, args), throwable); + } + + default void error(@PrintFormat String message, Object... args) { + log(Level.ERROR, format(message, args), null); + } + + default void error(Throwable throwable, @PrintFormat String message, Object... args) { + log(Level.ERROR, format(message, args), throwable); + } + + // Passed through untouched when there is nothing to substitute, so a message that happens to + // contain a percent sign is not read as a format specifier. + private static String format(String message, Object... args) { + return args.length == 0 ? message : String.format(Locale.ROOT, message, args); + } +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java new file mode 100644 index 0000000..9deb354 --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java @@ -0,0 +1,32 @@ +package com.github.imdmk.automessage.platform.placeholder; + +import com.github.imdmk.automessage.platform.viewer.Viewer; + +public interface ExternalPlaceholderResolver { + + String resolve(Viewer viewer, String token); + + String resolveWithoutViewer(String token); + + boolean available(); + + static ExternalPlaceholderResolver disabled() { + return new ExternalPlaceholderResolver() { + + @Override + public String resolve(Viewer viewer, String token) { + return token; + } + + @Override + public String resolveWithoutViewer(String token) { + return token; + } + + @Override + public boolean available() { + return false; + } + }; + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java similarity index 100% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java rename to automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/PluginTask.java diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskHandle.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskHandle.java new file mode 100644 index 0000000..2640b72 --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskHandle.java @@ -0,0 +1,11 @@ +package com.github.imdmk.automessage.platform.scheduler; + +@FunctionalInterface +public interface TaskHandle { + + void cancel(); + + static TaskHandle done() { + return () -> { }; + } +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java new file mode 100644 index 0000000..683556f --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java @@ -0,0 +1,17 @@ +package com.github.imdmk.automessage.platform.scheduler; + +import java.time.Duration; + +// Deliberately carries only what something actually calls. Every method here is implemented once +// per platform, on threading models that differ enough to be hard - Folia has no global scheduler +// and a proxy has no game thread - so an unused method is not free, it is six untested ones. +public interface TaskScheduler { + + TaskHandle runAsync(Runnable runnable); + + TaskHandle runLaterSync(Runnable runnable, Duration delay); + + TaskHandle runTimerSync(PluginTask task); + + void shutdown(); +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/PlaytimeSource.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/PlaytimeSource.java new file mode 100644 index 0000000..c8496ed --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/PlaytimeSource.java @@ -0,0 +1,14 @@ +package com.github.imdmk.automessage.platform.viewer; + +import java.time.Duration; +import java.util.Optional; + +@FunctionalInterface +public interface PlaytimeSource { + + Optional playtimeOf(Viewer viewer); + + static PlaytimeSource unavailable() { + return viewer -> Optional.empty(); + } +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/Viewer.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/Viewer.java new file mode 100644 index 0000000..8563fea --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/Viewer.java @@ -0,0 +1,31 @@ +package com.github.imdmk.automessage.platform.viewer; + +import net.kyori.adventure.audience.Audience; + +import java.util.Optional; +import java.util.UUID; + +public interface Viewer { + + UUID CONSOLE_ID = new UUID(0L, 0L); + + String name(); + + UUID uniqueId(); + + String displayName(); + + // A string, never a Locale: wrapping Bukkit's "pl_pl" in new Locale(...) puts the whole code + // in the language field, which is what silently sent Polish players English text. + String locale(); + + Optional world(); + + boolean hasPermission(String permission); + + boolean isOnline(); + + boolean isPlayer(); + + Audience audience(); +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerFactory.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerFactory.java new file mode 100644 index 0000000..4c4fd97 --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerFactory.java @@ -0,0 +1,7 @@ +package com.github.imdmk.automessage.platform.viewer; + +@FunctionalInterface +public interface ViewerFactory { + + Viewer of(S sender); +} diff --git a/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerRegistry.java b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerRegistry.java new file mode 100644 index 0000000..3232817 --- /dev/null +++ b/automessage-api/src/main/java/com/github/imdmk/automessage/platform/viewer/ViewerRegistry.java @@ -0,0 +1,15 @@ +package com.github.imdmk.automessage.platform.viewer; + +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Collection; + +public interface ViewerRegistry { + + @Unmodifiable + Collection online(); + + int onlineCount(); + + int maxPlayers(); +} diff --git a/automessage-api/src/test/java/com/github/imdmk/automessage/platform/logger/PluginLoggerTest.java b/automessage-api/src/test/java/com/github/imdmk/automessage/platform/logger/PluginLoggerTest.java new file mode 100644 index 0000000..2985444 --- /dev/null +++ b/automessage-api/src/test/java/com/github/imdmk/automessage/platform/logger/PluginLoggerTest.java @@ -0,0 +1,77 @@ +package com.github.imdmk.automessage.platform.logger; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class PluginLoggerTest { + + private record Entry(PluginLogger.Level level, String message, Throwable throwable) { + } + + private final List written = new ArrayList<>(); + + private final PluginLogger logger = (level, message, throwable) -> + written.add(new Entry(level, message, throwable)); + + @Test + @DisplayName("should route each level to the one method a platform implements") + void shouldRouteEveryLevel() { + logger.info("started"); + logger.warn("odd"); + logger.error("broken"); + + assertThat(written).extracting(Entry::level).containsExactly( + PluginLogger.Level.INFO, + PluginLogger.Level.WARN, + PluginLogger.Level.ERROR + ); + } + + @Test + @DisplayName("should format arguments the same way on every platform") + void shouldFormatArguments() { + logger.info("Loaded %d language(s): %s.", 2, "en, pl"); + + assertThat(written).singleElement() + .extracting(Entry::message) + .isEqualTo("Loaded 2 language(s): en, pl."); + } + + @Test + @DisplayName("should leave a message with no arguments exactly as written") + void shouldNotFormatWithoutArguments() { + // Collapsing the overloads means a call with no arguments now reaches the formatter, + // where a stray percent sign would be read as a specifier and throw. + logger.warn("Channel 'sale 50% off' is not configured."); + + assertThat(written).singleElement() + .extracting(Entry::message) + .isEqualTo("Channel 'sale 50% off' is not configured."); + } + + @Test + @DisplayName("should carry the cause through to the platform") + void shouldCarryTheCause() { + final Throwable cause = new IllegalStateException("boom"); + + logger.error(cause, "Failed to reload %s.", "config.yml"); + + assertThat(written).singleElement().satisfies(entry -> { + assertThat(entry.throwable()).isSameAs(cause); + assertThat(entry.message()).isEqualTo("Failed to reload config.yml."); + }); + } + + @Test + @DisplayName("should report no cause when there is none") + void shouldReportNoCause() { + logger.warn("just a warning"); + + assertThat(written).singleElement().extracting(Entry::throwable).isNull(); + } +} diff --git a/automessage-bukkit-common/build.gradle.kts b/automessage-bukkit-common/build.gradle.kts new file mode 100644 index 0000000..dac7a5a --- /dev/null +++ b/automessage-bukkit-common/build.gradle.kts @@ -0,0 +1,22 @@ +plugins { + `automessage-spigot-compat` + `automessage-adventure-compat` +} + +// Everything the Bukkit family shares: viewers, the event listener, metrics. +// +// A library, not a plugin. It carries no plugin.yml, because both Bukkit and Folia depend on it +// and a module with an entry point would put a second plugin.yml inside every jar that shaded it, +// leaving the server to pick one at random. +dependencies { + api(project(":automessage-core")) + + // compileOnly, not compileOnlyApi: folia-api declares the same capability because it is a + // fork of this, and exporting spigot-api would make every Folia build a capability conflict. + compileOnly("org.spigotmc:spigot-api:${Versions.SPIGOT_API}") + compileOnly("me.clip:placeholderapi:${Versions.PLACEHOLDER_API}") + + api("net.kyori:adventure-platform-bukkit:${Versions.KYORI_PLATFORM_BUKKIT}") + api("org.bstats:bstats-bukkit:${Versions.BSTATS_BUKKIT}") + api("dev.rollczi:litecommands-bukkit:${Versions.LITECOMMANDS}") +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessagePlugin.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessagePlugin.java new file mode 100644 index 0000000..2766b49 --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessagePlugin.java @@ -0,0 +1,84 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.AutoMessage; +import com.github.imdmk.automessage.bukkit.listener.MessageTriggerListener; +import com.github.imdmk.automessage.bukkit.placeholder.ExternalPlaceholderResolverFactory; +import com.github.imdmk.automessage.command.CommandRegistrar; +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import dev.rollczi.litecommands.LiteCommands; +import dev.rollczi.litecommands.LiteCommandsBuilder; +import dev.rollczi.litecommands.bukkit.LiteBukkitFactory; +import net.kyori.adventure.platform.AudienceProvider; +import net.kyori.adventure.platform.bukkit.BukkitAudiences; +import org.bukkit.Server; +import org.bukkit.command.CommandSender; +import org.bukkit.event.HandlerList; +import org.bukkit.plugin.Plugin; + +import java.util.function.Consumer; + +public final class AutoMessagePlugin { + + private final AutoMessage automessage; + private final AudienceProvider audiences; + private final MessageTriggerListener triggerListener; + private final LiteCommands liteCommands; + private final MetricsService metricsService; + + public AutoMessagePlugin(Plugin plugin, String platformName, TaskScheduler scheduler) { + this(plugin, platformName, scheduler, builder -> { }); + } + + // commands is where a fork of Bukkit adds what only it needs. Folia is the one that does: + // LiteCommands runs commands through Bukkit's global scheduler, which Folia does not have, and + // says so at startup unless its own extension is installed. That extension is Folia's + // dependency, not this module's, so it is handed in rather than reached for. + public AutoMessagePlugin( + Plugin plugin, + String platformName, + TaskScheduler scheduler, + Consumer> commands + ) { + final Server server = plugin.getServer(); + final PluginLogger logger = new BukkitPluginLogger(plugin.getLogger()); + + this.audiences = BukkitAudiences.create(plugin); + + final BukkitPlatform platform = + new BukkitPlatform(platformName, server, audiences, scheduler); + + this.automessage = new AutoMessage( + platform, + logger, + plugin.getDataFolder(), + ExternalPlaceholderResolverFactory.create(server, logger) + ); + + final BukkitViewerFactory viewers = new BukkitViewerFactory(audiences); + + this.triggerListener = new MessageTriggerListener(viewers, automessage.triggerService()); + server.getPluginManager().registerEvents(triggerListener, plugin); + + final var commandBuilder = LiteBukkitFactory.builder(AutoMessage.NAME, plugin, server); + CommandRegistrar.configure(commandBuilder, automessage, viewers); + commands.accept(commandBuilder); + + this.liteCommands = commandBuilder.build(); + + this.metricsService = new MetricsService(plugin); + } + + public void disable() { + // Bukkit clears a plugin's handlers when it is disabled, but this class is also torn down + // by the loader on its own, so the listener is detached explicitly. + HandlerList.unregisterAll(triggerListener); + + automessage.shutdown(); + + liteCommands.unregister(); + audiences.close(); + metricsService.shutdown(); + } + +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlatform.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlatform.java new file mode 100644 index 0000000..dae27ee --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlatform.java @@ -0,0 +1,49 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.platform.Platform; +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import net.kyori.adventure.platform.AudienceProvider; +import org.bukkit.Server; + +public final class BukkitPlatform implements Platform { + + private final String name; + private final TaskScheduler scheduler; + private final ViewerRegistry viewers; + private final PlaytimeSource playtime; + + public BukkitPlatform(String name, Server server, AudienceProvider audiences, TaskScheduler scheduler) { + this.name = name; + this.scheduler = scheduler; + this.viewers = new BukkitViewerRegistry(server, audiences); + this.playtime = new BukkitPlaytimeSource(); + } + + @Override + public String name() { + return name; + } + + @Override + public Capabilities capabilities() { + return Capabilities.all(); + } + + @Override + public ViewerRegistry viewers() { + return viewers; + } + + @Override + public TaskScheduler scheduler() { + return scheduler; + } + + @Override + public PlaytimeSource playtime() { + return playtime; + } +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlaytimeSource.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlaytimeSource.java new file mode 100644 index 0000000..2403ca4 --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPlaytimeSource.java @@ -0,0 +1,25 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import org.bukkit.Statistic; +import org.bukkit.entity.Player; + +import java.time.Duration; +import java.util.Optional; + +public final class BukkitPlaytimeSource implements PlaytimeSource { + + private static final long MILLIS_PER_TICK = 50L; + + @Override + public Optional playtimeOf(Viewer viewer) { + if (!(viewer instanceof BukkitViewer bukkit) || !(bukkit.sender() instanceof Player player)) { + return Optional.empty(); + } + + return Optional.of(Duration.ofMillis( + (long) player.getStatistic(Statistic.PLAY_ONE_MINUTE) * MILLIS_PER_TICK + )); + } +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPluginLogger.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPluginLogger.java new file mode 100644 index 0000000..aee2bfc --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitPluginLogger.java @@ -0,0 +1,28 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import org.jetbrains.annotations.Nullable; + +import java.util.logging.Logger; + +public final class BukkitPluginLogger implements PluginLogger { + + private final Logger logger; + + public BukkitPluginLogger(Logger logger) { + this.logger = logger; + } + + @Override + public void log(Level level, String message, @Nullable Throwable throwable) { + logger.log(toJulLevel(level), message, throwable); + } + + private static java.util.logging.Level toJulLevel(Level level) { + return switch (level) { + case INFO -> java.util.logging.Level.INFO; + case WARN -> java.util.logging.Level.WARNING; + case ERROR -> java.util.logging.Level.SEVERE; + }; + } +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitTaskScheduler.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitTaskScheduler.java new file mode 100644 index 0000000..a2f8a1f --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitTaskScheduler.java @@ -0,0 +1,66 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; + +import org.bukkit.plugin.Plugin; +import org.bukkit.scheduler.BukkitScheduler; + +import java.time.Duration; + +public final class BukkitTaskScheduler implements TaskScheduler { + + private static final long MILLIS_PER_TICK = 50L; + + private final Plugin plugin; + private final BukkitScheduler scheduler; + + public BukkitTaskScheduler(Plugin plugin, BukkitScheduler scheduler) { + this.plugin = plugin; + this.scheduler = scheduler; + } + + @Override + public TaskHandle runAsync(Runnable runnable) { + return handleOf(scheduler.runTaskAsynchronously(plugin, runnable)); + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + return handleOf(scheduler.runTaskLater(plugin, runnable, toDelayTicks(delay))); + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + return handleOf(scheduler.runTaskTimer( + plugin, + task, + toDelayTicks(task.delay()), + toPeriodTicks(task.period()) + )); + } + + @Override + public void shutdown() { + scheduler.cancelTasks(plugin); + } + + private static TaskHandle handleOf(org.bukkit.scheduler.BukkitTask task) { + return task::cancel; + } + + private static long toDelayTicks(Duration duration) { + return Math.max(0L, toTicks(duration)); + } + + // Bukkit reads a period of zero ticks as "every tick", which turns a misconfigured interval + // into a flood of messages. + private static long toPeriodTicks(Duration duration) { + return Math.max(1L, toTicks(duration)); + } + + private static long toTicks(Duration duration) { + return duration.toMillis() / MILLIS_PER_TICK; + } +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewer.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewer.java new file mode 100644 index 0000000..28b126d --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewer.java @@ -0,0 +1,87 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.platform.AudienceProvider; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; + +import java.util.Optional; +import java.util.UUID; + +public final class BukkitViewer implements Viewer { + + private static final String CONSOLE_NAME = "CONSOLE"; + + private final CommandSender sender; + private final AudienceProvider audiences; + + public BukkitViewer(CommandSender sender, AudienceProvider audiences) { + this.sender = sender; + this.audiences = audiences; + } + + public CommandSender sender() { + return sender; + } + + @Override + public String name() { + return sender instanceof Player player ? player.getName() : CONSOLE_NAME; + } + + @Override + public UUID uniqueId() { + return sender instanceof Player player ? player.getUniqueId() : CONSOLE_ID; + } + + @Override + public String displayName() { + return sender instanceof Player player ? player.getDisplayName() : CONSOLE_NAME; + } + + @Override + public String locale() { + // The raw client string, never wrapped in a Locale - see Viewer#locale. + return sender instanceof Player player ? player.getLocale() : ""; + } + + @Override + public Optional world() { + return sender instanceof Player player + ? Optional.of(player.getWorld().getName()) + : Optional.empty(); + } + + @Override + public boolean isPlayer() { + return sender instanceof Player; + } + + @Override + public boolean hasPermission(String permission) { + return sender.hasPermission(permission); + } + + @Override + public boolean isOnline() { + return !(sender instanceof Player player) || player.isOnline(); + } + + @Override + public Audience audience() { + return sender instanceof Player player + ? audiences.player(player.getUniqueId()) + : audiences.console(); + } + + @Override + public boolean equals(Object other) { + return other instanceof BukkitViewer viewer && uniqueId().equals(viewer.uniqueId()); + } + + @Override + public int hashCode() { + return uniqueId().hashCode(); + } +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerFactory.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerFactory.java new file mode 100644 index 0000000..0079617 --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerFactory.java @@ -0,0 +1,20 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import net.kyori.adventure.platform.AudienceProvider; +import org.bukkit.command.CommandSender; + +public final class BukkitViewerFactory implements ViewerFactory { + + private final AudienceProvider audiences; + + public BukkitViewerFactory(AudienceProvider audiences) { + this.audiences = audiences; + } + + @Override + public Viewer of(CommandSender sender) { + return new BukkitViewer(sender, audiences); + } +} diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerRegistry.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerRegistry.java new file mode 100644 index 0000000..1502c80 --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/BukkitViewerRegistry.java @@ -0,0 +1,44 @@ +package com.github.imdmk.automessage.bukkit; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import net.kyori.adventure.platform.AudienceProvider; +import org.bukkit.Server; +import org.bukkit.entity.Player; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public final class BukkitViewerRegistry implements ViewerRegistry { + + private final Server server; + private final AudienceProvider audiences; + + public BukkitViewerRegistry(Server server, AudienceProvider audiences) { + this.server = server; + this.audiences = audiences; + } + + @Override + public Collection online() { + final Collection players = server.getOnlinePlayers(); + final List viewers = new ArrayList<>(players.size()); + + for (final Player player : players) { + viewers.add(new BukkitViewer(player, audiences)); + } + + return List.copyOf(viewers); + } + + @Override + public int onlineCount() { + return server.getOnlinePlayers().size(); + } + + @Override + public int maxPlayers() { + return server.getMaxPlayers(); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/metrics/MetricsService.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/MetricsService.java similarity index 86% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/metrics/MetricsService.java rename to automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/MetricsService.java index d7c66b2..c3f4ff9 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/metrics/MetricsService.java +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/MetricsService.java @@ -1,4 +1,4 @@ -package com.github.imdmk.automessage.platform.metrics; +package com.github.imdmk.automessage.bukkit; import org.bstats.bukkit.Metrics; import org.bukkit.plugin.Plugin; diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerListener.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/listener/MessageTriggerListener.java similarity index 70% rename from automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerListener.java rename to automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/listener/MessageTriggerListener.java index dfd1c81..cbfa16d 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerListener.java +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/listener/MessageTriggerListener.java @@ -1,30 +1,27 @@ -package com.github.imdmk.automessage.scheduled.trigger; +package com.github.imdmk.automessage.bukkit.listener; +import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService; + +import com.github.imdmk.automessage.bukkit.BukkitViewerFactory; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; import org.bukkit.event.Listener; import org.bukkit.event.player.PlayerJoinEvent; import org.bukkit.event.player.PlayerQuitEvent; -/** - * Turns Bukkit events into trigger evaluations. - * - *

- * Both handlers run at {@link EventPriority#MONITOR} and change nothing about the event: this - * listener only observes. - *

- */ public final class MessageTriggerListener implements Listener { + private final BukkitViewerFactory viewers; private final MessageTriggerService triggerService; - public MessageTriggerListener(MessageTriggerService triggerService) { + public MessageTriggerListener(BukkitViewerFactory viewers, MessageTriggerService triggerService) { + this.viewers = viewers; this.triggerService = triggerService; } @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) public void onJoin(PlayerJoinEvent event) { - triggerService.onJoin(event.getPlayer()); + triggerService.onJoin(viewers.of(event.getPlayer()), !event.getPlayer().hasPlayedBefore()); // The joining player is already part of the server's collection by the time this fires. triggerService.onPlayerCountChanged(event.getPlayer().getServer().getOnlinePlayers().size()); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolverFactory.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/ExternalPlaceholderResolverFactory.java similarity index 63% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolverFactory.java rename to automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/ExternalPlaceholderResolverFactory.java index 82df0ba..014ea86 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolverFactory.java +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/ExternalPlaceholderResolverFactory.java @@ -1,4 +1,6 @@ -package com.github.imdmk.automessage.platform.placeholder; +package com.github.imdmk.automessage.bukkit.placeholder; + +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; import com.github.imdmk.automessage.platform.logger.PluginLogger; import org.bukkit.Server; @@ -11,15 +13,6 @@ private ExternalPlaceholderResolverFactory() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * Decides once, at startup, whether PlaceholderAPI is available. - * - *

- * The check is deliberately not repeated per message: asking the plugin manager on every - * broadcast would cost more than the lookup it guards, and a plugin appearing mid-run is - * handled by {@code /automessage reload} like every other change. - *

- */ public static ExternalPlaceholderResolver create(Server server, PluginLogger logger) { if (!server.getPluginManager().isPluginEnabled(PLACEHOLDER_API)) { return ExternalPlaceholderResolver.disabled(); diff --git a/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/PapiPlaceholderResolver.java b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/PapiPlaceholderResolver.java new file mode 100644 index 0000000..567d0e1 --- /dev/null +++ b/automessage-bukkit-common/src/main/java/com/github/imdmk/automessage/bukkit/placeholder/PapiPlaceholderResolver.java @@ -0,0 +1,50 @@ +package com.github.imdmk.automessage.bukkit.placeholder; + +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; + +import me.clip.placeholderapi.PlaceholderAPI; +import com.github.imdmk.automessage.bukkit.BukkitViewer; +import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; + +final class PapiPlaceholderResolver implements ExternalPlaceholderResolver { + + @Override + public String resolve(Viewer viewer, String token) { + // PlaceholderAPI is a Bukkit plugin and wants a Bukkit player. A viewer from any other + // platform cannot be unwrapped, and the token is handed back untouched rather than + // pretending to have resolved it. + if (!(viewer instanceof BukkitViewer bukkit) || !(bukkit.sender() instanceof Player player)) { + return token; + } + + // Guarded for the same reason as below: expansions are written by other people, and one + // that throws would otherwise take down the whole broadcast, for every player, not just + // the token it could not resolve. + try { + return PlaceholderAPI.setPlaceholders(player, token); + } catch (RuntimeException exception) { + return token; + } + } + + @Override + public String resolveWithoutViewer(String token) { + // PlaceholderAPI accepts a null player and server-scoped expansions answer anyway, but + // third-party expansions are written by other people and a player-scoped one may well + // dereference it. A broken expansion must not take an announcement down with it. + try { + // The cast is not redundant, whatever an IDE says: setPlaceholders is overloaded for + // Player and for OfflinePlayer, and a bare null only lands on the Player one because + // it happens to be the more specific of the two today. + return PlaceholderAPI.setPlaceholders((Player) null, token); + } catch (RuntimeException exception) { + return token; + } + } + + @Override + public boolean available() { + return true; + } +} diff --git a/automessage-bukkit/build.gradle.kts b/automessage-bukkit/build.gradle.kts new file mode 100644 index 0000000..6172fd1 --- /dev/null +++ b/automessage-bukkit/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + `automessage-platform` + `automessage-bukkit-plugin` + + id("xyz.jpenilla.run-paper") +} + +// Spigot, Paper, Purpur and the rest of the Bukkit family. Not Folia - that has its own module. +dependencies { + api(project(":automessage-bukkit-common")) + + compileOnly("org.spigotmc:spigot-api:${Versions.SPIGOT_API}") +} + +automessagePlatform { + runPort = 25599 + platformName = "Bukkit" +} + +bukkit { + main = "com.github.imdmk.automessage.bukkit.AutoMessageBukkitPlugin" +} + +tasks { + runServer { + minecraftVersion("26.2") + } +} diff --git a/automessage-loader/src/main/java/com/github/imdmk/automessage/AutoMessagePluginLoader.java b/automessage-bukkit/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessageBukkitPlugin.java similarity index 50% rename from automessage-loader/src/main/java/com/github/imdmk/automessage/AutoMessagePluginLoader.java rename to automessage-bukkit/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessageBukkitPlugin.java index e95d404..b4c66ee 100644 --- a/automessage-loader/src/main/java/com/github/imdmk/automessage/AutoMessagePluginLoader.java +++ b/automessage-bukkit/src/main/java/com/github/imdmk/automessage/bukkit/AutoMessageBukkitPlugin.java @@ -1,14 +1,18 @@ -package com.github.imdmk.automessage; +package com.github.imdmk.automessage.bukkit; import org.bukkit.plugin.java.JavaPlugin; -public final class AutoMessagePluginLoader extends JavaPlugin { +public final class AutoMessageBukkitPlugin extends JavaPlugin { private AutoMessagePlugin plugin; @Override public void onEnable() { - this.plugin = new AutoMessagePlugin(this); + this.plugin = new AutoMessagePlugin( + this, + "Bukkit", + new BukkitTaskScheduler(this, getServer().getScheduler()) + ); } @Override diff --git a/automessage-core/build.gradle.kts b/automessage-core/build.gradle.kts index 8ab8788..2fdcaf4 100644 --- a/automessage-core/build.gradle.kts +++ b/automessage-core/build.gradle.kts @@ -1,36 +1,24 @@ plugins { - `automessage-spigot-compat` `automessage-adventure-compat` + `automessage-testing` } dependencies { - compileOnlyApi("org.spigotmc:spigot-api:${Versions.SPIGOT_API}") + // Core decides when a message is sent and to whom; the notice module decides what and how. + api(project(":automessage-api")) + api(project(":automessage-notice")) - compileOnly("me.clip:placeholderapi:${Versions.PLACEHOLDER_API}") - - implementation("net.kyori:adventure-platform-bukkit:${Versions.KYORI_PLATFORM_BUKKIT}") implementation("net.kyori:adventure-text-minimessage:${Versions.KYORI_ADVENTURE}") // Flattens a rendered component to the words a Discord reader actually sees. implementation("net.kyori:adventure-text-serializer-plain:${Versions.KYORI_ADVENTURE}") - implementation("com.eternalcode:multification-bukkit:${Versions.MULTIFICATION}") - implementation("com.eternalcode:multification-okaeri:${Versions.MULTIFICATION}") - api("eu.okaeri:okaeri-configs-yaml-snakeyaml:${Versions.OKAERI_CONFIGS}") implementation("eu.okaeri:okaeri-configs-serdes-commons:${Versions.OKAERI_CONFIGS}") - implementation("org.bstats:bstats-bukkit:${Versions.BSTATS_BUKKIT}") - implementation("dev.rollczi:litecommands-bukkit:${Versions.LITECOMMANDS}") - implementation("dev.rollczi:litecommands-annotations:${Versions.LITECOMMANDS}") - - // compileOnlyApi keeps spigot-api out of the shaded jar, but it also keeps it off the test - // runtime classpath - and tests that touch Bukkit types need it there to load at all. - testImplementation("org.spigotmc:spigot-api:${Versions.SPIGOT_API}") - - testImplementation("org.junit.jupiter:junit-jupiter:${Versions.JUNIT}") - testImplementation("org.assertj:assertj-core:${Versions.ASSERTJ}") - testImplementation("org.mockito:mockito-core:${Versions.MOCKITO}") - testImplementation("org.mockito:mockito-junit-jupiter:${Versions.MOCKITO}") - testRuntimeOnly("org.junit.platform:junit-platform-launcher") + // The commands live here, not in the platform modules: they take a Viewer rather than a + // sender, so nothing in them names a server. Each platform adds only its own binding. + api("dev.rollczi:litecommands-annotations:${Versions.LITECOMMANDS}") + api("dev.rollczi:litecommands-framework:${Versions.LITECOMMANDS}") } + diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessage.java b/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessage.java new file mode 100644 index 0000000..3cc853e --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessage.java @@ -0,0 +1,216 @@ +package com.github.imdmk.automessage; + +import com.github.imdmk.automessage.scheduled.audience.optout.AnnouncementOptOut; +import com.github.imdmk.automessage.config.ConfigManager; +import com.github.imdmk.automessage.config.ConfigReloadService; +import com.github.imdmk.automessage.language.LanguageRegistry; +import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.Platform; +import com.github.imdmk.automessage.platform.discord.DiscordWebhookConfig; +import com.github.imdmk.automessage.platform.discord.DiscordWebhookService; +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; +import com.github.imdmk.automessage.scheduled.ScheduledMessageSender; +import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig; +import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext; +import com.github.imdmk.automessage.scheduled.dispatcher.DispatchStatistics; +import com.github.imdmk.automessage.scheduled.dispatcher.DispatchObserver; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcher; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService; +import com.github.imdmk.automessage.scheduled.dispatcher.ScheduledMessageDispatcherFactory; +import com.github.imdmk.automessage.scheduled.selector.MessageSelectorProvider; +import com.github.imdmk.automessage.scheduled.selector.MessageSelectorType; +import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService; +import com.github.imdmk.automessage.scheduled.trigger.PlayerCountMilestones; + +import java.io.File; + +public final class AutoMessage { + + public static final String NAME = "AutoMessage"; + private static final String DONATE_URL = "https://paypal.me/dominiksuliga"; + + private final PluginLogger logger; + private final Platform platform; + private final ConfigManager configManager; + private final ConfigReloadService configReloadService; + + private final LanguageRegistry languages; + private final MessageService messageService; + private final MessageDispatcherConfig dispatcherConfig; + private final ScheduledMessageRepository messageRepository; + private final ScheduledMessageSender messageSender; + private final MessageDispatcherService dispatcherService; + private final MessageTriggerService triggerService; + private final DispatchObserver dispatchObserver; + private final DispatchStatistics statistics; + private final AnnouncementOptOut optOut; + + public AutoMessage( + Platform platform, + PluginLogger logger, + File dataFolder, + ExternalPlaceholderResolver placeholders + ) { + this.platform = platform; + this.logger = logger; + + // Written for this platform only: a proxy has no worlds and keeps no playtime, so the + // options for those are left out of the file rather than written and quietly ignored. + this.configManager = new ConfigManager(logger, dataFolder, platform.capabilities()); + + // config.yml first: it names the language to fall back to. + this.dispatcherConfig = configManager.create(MessageDispatcherConfig.class); + final ScheduledMessagesConfig scheduledMessages = configManager.create(ScheduledMessagesConfig.class); + + this.languages = LanguageRegistry.load(configManager, logger, () -> dispatcherConfig.fallbackLanguage); + this.configReloadService = new ConfigReloadService(configManager); + + // Registered first, so a message rendered by a later listener already sees the languages + // this reload discovered. + configReloadService.register(languages); + + final TaskScheduler scheduler = platform.scheduler(); + final ViewerRegistry viewers = platform.viewers(); + final AudienceContext audienceContext = AudienceContext.of(viewers, platform.playtime()); + + // One filter for both the rotation and the triggers, so a player who turned announcements + // off is not greeted by one on the way in. + this.optOut = AnnouncementOptOut.load(logger, dataFolder); + final AudienceFilter audienceFilter = + AudienceFilter.notMuted(optOut).and(AudienceFilter.ruleFilter()); + + // A boss bar hides itself on the plugin's own scheduler, which is the only clock the + // notice module is given - it has no way to tell the time by itself, by design. + this.messageService = new MessageService( + languages, + (delay, action) -> scheduler.runLaterSync(action, delay) + ); + + this.messageRepository = ScheduledMessageRepository.config(scheduledMessages, configReloadService); + this.messageSender = new ScheduledMessageSender( + viewers, logger, messageService, languages, placeholders + ); + + // The scan it keeps is only valid for the text currently on disk. + configReloadService.register(messageSender); + + final DiscordWebhookConfig discordConfig = configManager.create(DiscordWebhookConfig.class); + + // One observer shared by every channel: mirroring is a property of the announcement, not + // of the stream it happened to travel on. + this.statistics = new DispatchStatistics(); + this.dispatchObserver = DispatchObserver.of( + statistics, + DiscordWebhookService.create(viewers, languages, logger, discordConfig) + ); + + final ScheduledMessageDispatcherFactory dispatcherFactory = + selector -> new MessageDispatcher( + messageSender, + selector, + audienceFilter, + audienceContext, + dispatchObserver + ); + + this.dispatcherService = new MessageDispatcherService( + logger, viewers, scheduler, dispatcherConfig, messageRepository, dispatcherFactory + ); + + dispatcherService.start(); + configReloadService.register(dispatcherService); + + // A trigger dispatches the one message its event names, so this dispatcher's selector is + // never consulted. It still gets a real one rather than a trap that would only fire if + // some later change started rotating through triggered messages. + final MessageDispatcher triggerDispatcher = dispatcherFactory.create( + new MessageSelectorProvider(() -> MessageSelectorType.SEQUENTIAL) + ); + + this.triggerService = new MessageTriggerService( + viewers, + scheduler, + messageRepository, + triggerDispatcher, + audienceFilter, + audienceContext, + new PlayerCountMilestones() + ); + + logger.info("%s enabled on %s.", NAME, platform.name()); + logger.info("Enjoying %s? You can support its development at %s - thank you!", NAME, DONATE_URL); + } + + public void shutdown() { + close("stop the dispatcher", dispatcherService::stop); + close("save who turned announcements off", optOut::save); + close("save the configuration", configManager::saveAll); + close("release the configuration", configManager::clearAll); + close("stop the scheduler", platform.scheduler()::shutdown); + close("close the Discord webhook", dispatchObserver::shutdown); + + logger.info("%s disabled successfully.", NAME); + } + + private void close(String what, Runnable step) { + try { + step.run(); + } catch (RuntimeException exception) { + logger.error(exception, "Failed to %s while disabling %s.", what, NAME); + } + } + + public PluginLogger logger() { + return logger; + } + + public LanguageRegistry languages() { + return languages; + } + + public MessageService messageService() { + return messageService; + } + + public MessageDispatcherConfig dispatcherConfig() { + return dispatcherConfig; + } + + public ScheduledMessageRepository messageRepository() { + return messageRepository; + } + + public ScheduledMessageSender messageSender() { + return messageSender; + } + + public ConfigReloadService configReloadService() { + return configReloadService; + } + + public MessageTriggerService triggerService() { + return triggerService; + } + + public MessageDispatcherService dispatcherService() { + return dispatcherService; + } + + public AnnouncementOptOut optOut() { + return optOut; + } + + public DispatchStatistics statistics() { + return statistics; + } + + public TaskScheduler scheduler() { + return platform.scheduler(); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessagePlugin.java b/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessagePlugin.java deleted file mode 100644 index b2f244a..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/AutoMessagePlugin.java +++ /dev/null @@ -1,171 +0,0 @@ -package com.github.imdmk.automessage; - -import com.eternalcode.multification.notice.Notice; -import com.github.imdmk.automessage.command.dispatcher.DisableCommand; -import com.github.imdmk.automessage.command.dispatcher.EnableCommand; -import com.github.imdmk.automessage.command.reload.ReloadCommand; -import com.github.imdmk.automessage.command.view.ViewCommand; -import com.github.imdmk.automessage.config.ConfigManager; -import com.github.imdmk.automessage.config.ConfigReloadService; -import com.github.imdmk.automessage.language.LanguageRegistry; -import com.github.imdmk.automessage.message.MessageService; -import com.github.imdmk.automessage.platform.litecommands.argument.ScheduledMessageArgument; -import com.github.imdmk.automessage.platform.litecommands.argument.UnknownScheduledMessage; -import com.github.imdmk.automessage.platform.litecommands.handler.InvalidUsageHandlerImpl; -import com.github.imdmk.automessage.platform.litecommands.handler.MissingPermissionsHandlerImpl; -import com.github.imdmk.automessage.platform.litecommands.handler.NoticeResultHandlerImpl; -import com.github.imdmk.automessage.platform.litecommands.handler.UnknownScheduledMessageHandler; -import com.github.imdmk.automessage.platform.logger.BukkitPluginLogger; -import com.github.imdmk.automessage.platform.logger.PluginLogger; -import com.github.imdmk.automessage.platform.discord.DiscordWebhookConfig; -import com.github.imdmk.automessage.platform.discord.DiscordWebhookService; -import com.github.imdmk.automessage.platform.metrics.MetricsService; -import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; -import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolverFactory; -import com.github.imdmk.automessage.platform.scheduler.BukkitTaskScheduler; -import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; -import com.github.imdmk.automessage.scheduled.ScheduledMessage; -import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; -import com.github.imdmk.automessage.scheduled.ScheduledMessageSender; -import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig; -import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter; -import com.github.imdmk.automessage.scheduled.dispatcher.DispatchObserver; -import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcher; -import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig; -import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService; -import dev.rollczi.litecommands.LiteCommands; -import dev.rollczi.litecommands.bukkit.LiteBukkitFactory; -import com.github.imdmk.automessage.scheduled.dispatcher.ScheduledMessageDispatcherFactory; -import com.github.imdmk.automessage.scheduled.selector.MessageSelectorProvider; -import com.github.imdmk.automessage.scheduled.selector.MessageSelectorType; -import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerListener; -import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService; -import com.github.imdmk.automessage.scheduled.trigger.PlayerCountMilestones; -import org.bukkit.Server; -import org.bukkit.event.HandlerList; -import org.bukkit.plugin.Plugin; - -final class AutoMessagePlugin { - - private static final String PLUGIN_PREFIX = "AutoMessage"; - private static final String DONATE_URL = "https://paypal.me/dominiksuliga"; - - private final PluginLogger logger; - private final ConfigManager configManager; - - private final MessageService messageService; - private final TaskScheduler taskScheduler; - private final MessageDispatcherService dispatcherService; - private final LiteCommands liteCommands; - private final MetricsService metricsService; - private final MessageTriggerListener triggerListener; - private final DispatchObserver dispatchObserver; - - AutoMessagePlugin(Plugin plugin) { - final Server server = plugin.getServer(); - this.logger = new BukkitPluginLogger(plugin.getLogger()); - - this.configManager = new ConfigManager(logger, plugin.getDataFolder()); - - // config.yml first: it names the languages to write out and the one to fall back to. - final MessageDispatcherConfig dispatcherConfig = configManager.create(MessageDispatcherConfig.class); - final ScheduledMessagesConfig scheduledMessagesConfig = configManager.create(ScheduledMessagesConfig.class); - - final LanguageRegistry languages = - LanguageRegistry.load(configManager, logger, dispatcherConfig.fallbackLanguage); - - final ConfigReloadService configReloadService = new ConfigReloadService(configManager); - - this.messageService = new MessageService(languages, plugin); - this.taskScheduler = new BukkitTaskScheduler(plugin, server.getScheduler()); - - final ScheduledMessageRepository messageRepository = - ScheduledMessageRepository.config(scheduledMessagesConfig, configReloadService); - final ExternalPlaceholderResolver placeholderResolver = - ExternalPlaceholderResolverFactory.create(server, logger); - - final ScheduledMessageSender messageSender = new ScheduledMessageSender( - server, logger, messageService, languages, placeholderResolver - ); - - final DiscordWebhookConfig discordConfig = configManager.create(DiscordWebhookConfig.class); - - // One observer shared by every channel: mirroring is a property of the announcement, not - // of the stream it happened to travel on. - this.dispatchObserver = DiscordWebhookService.create(server, languages, logger, discordConfig); - - final ScheduledMessageDispatcherFactory dispatcherFactory = - selector -> new MessageDispatcher( - messageSender, selector, AudienceFilter.ruleFilter(), dispatchObserver - ); - - this.dispatcherService = new MessageDispatcherService( - logger, - server, - taskScheduler, - dispatcherConfig, - messageRepository, - dispatcherFactory - ); - - dispatcherService.start(); - configReloadService.register(dispatcherService); - - // A trigger dispatches the one message its event names, so this dispatcher's selector is - // never consulted. It still gets a real one rather than a trap that would only fire if - // some later change started rotating through triggered messages. - final MessageDispatcher triggerDispatcher = dispatcherFactory.create( - new MessageSelectorProvider(() -> MessageSelectorType.SEQUENTIAL) - ); - - this.triggerListener = new MessageTriggerListener(new MessageTriggerService( - server, - taskScheduler, - messageRepository, - triggerDispatcher, - AudienceFilter.ruleFilter(), - new PlayerCountMilestones() - )); - - server.getPluginManager().registerEvents(triggerListener, plugin); - - this.liteCommands = LiteBukkitFactory.builder(PLUGIN_PREFIX, plugin, server) - .invalidUsage(new InvalidUsageHandlerImpl(messageService)) - .missingPermission(new MissingPermissionsHandlerImpl(messageService)) - .result(Notice.class, new NoticeResultHandlerImpl(messageService)) - .result(UnknownScheduledMessage.class, new UnknownScheduledMessageHandler(messageService)) - - .argument(ScheduledMessage.class, new ScheduledMessageArgument(messageRepository)) - - .commands( - new DisableCommand(dispatcherConfig, messageService), - new EnableCommand(dispatcherConfig, messageService), - new ReloadCommand(logger, configReloadService, taskScheduler, messageService), - new ViewCommand(messageSender, messageService) - ) - - .build(); - - this.metricsService = new MetricsService(plugin); - - logger.info("%s plugin enabled.", PLUGIN_PREFIX); - logger.info("Enjoying %s? You can support its development at %s - thank you!", PLUGIN_PREFIX, DONATE_URL); - } - - void disable() { - // Bukkit clears a plugin's handlers when it is disabled, but this class is also torn down - // by the loader on its own, so the listener is detached explicitly. - HandlerList.unregisterAll(triggerListener); - - dispatcherService.stop(); - configManager.saveAll(); - configManager.clearAll(); - messageService.shutdown(); - taskScheduler.shutdown(); - liteCommands.unregister(); - dispatchObserver.shutdown(); - metricsService.shutdown(); - - logger.info("%s plugin disabled successfully.", PLUGIN_PREFIX); - } -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/CommandRegistrar.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/CommandRegistrar.java new file mode 100644 index 0000000..8c035dd --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/CommandRegistrar.java @@ -0,0 +1,72 @@ +package com.github.imdmk.automessage.command; + +import com.github.imdmk.automessage.command.toggle.ToggleCommand; +import com.github.imdmk.automessage.AutoMessage; +import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; +import com.github.imdmk.automessage.command.handler.UnknownAnnouncementChannelHandler; +import com.github.imdmk.automessage.command.argument.UnknownAnnouncementChannel; +import com.github.imdmk.automessage.command.argument.AnnouncementChannelArgument; +import com.github.imdmk.automessage.command.send.SendCommand; +import com.github.imdmk.automessage.command.next.NextCommand; +import com.github.imdmk.automessage.command.stats.StatsCommand; +import com.github.imdmk.automessage.command.argument.ScheduledMessageArgument; +import com.github.imdmk.automessage.command.argument.UnknownScheduledMessage; +import com.github.imdmk.automessage.command.dispatcher.DisableCommand; +import com.github.imdmk.automessage.command.dispatcher.EnableCommand; +import com.github.imdmk.automessage.command.handler.InvalidUsageHandlerImpl; +import com.github.imdmk.automessage.command.handler.MissingPermissionsHandlerImpl; +import com.github.imdmk.automessage.command.handler.NoticeResultHandlerImpl; +import com.github.imdmk.automessage.command.handler.UnknownScheduledMessageHandler; +import com.github.imdmk.automessage.command.reload.ReloadCommand; +import com.github.imdmk.automessage.command.view.ViewCommand; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import dev.rollczi.litecommands.LiteCommandsBuilder; +import dev.rollczi.litecommands.context.ContextResult; + +public final class CommandRegistrar { + + private CommandRegistrar() { + } + + public static void configure( + LiteCommandsBuilder builder, + AutoMessage automessage, + ViewerFactory viewers + ) { + builder + .context(Viewer.class, invocation -> ContextResult.ok(() -> viewers.of(invocation.sender()))) + + .invalidUsage(new InvalidUsageHandlerImpl<>(automessage.messageService(), viewers)) + .missingPermission(new MissingPermissionsHandlerImpl<>(automessage.messageService(), viewers)) + .result(Notice.class, new NoticeResultHandlerImpl<>(automessage.messageService(), viewers)) + .result(UnknownScheduledMessage.class, + new UnknownScheduledMessageHandler<>(automessage.messageService(), viewers)) + .result(UnknownAnnouncementChannel.class, + new UnknownAnnouncementChannelHandler<>(automessage.messageService(), viewers)) + + .argument(ScheduledMessage.class, new ScheduledMessageArgument<>(automessage.messageRepository())) + .argument(AnnouncementChannel.class, + new AnnouncementChannelArgument<>(automessage.dispatcherConfig())) + + .commands( + new DisableCommand(automessage.dispatcherConfig(), automessage.messageService()), + new EnableCommand(automessage.dispatcherConfig(), automessage.messageService()), + new ReloadCommand( + automessage.logger(), + automessage.configReloadService(), + automessage.scheduler(), + automessage.messageService() + ), + new ViewCommand(automessage.messageSender(), automessage.messageService()), + new NextCommand(automessage.dispatcherService(), automessage.messageService()), + new StatsCommand(automessage.statistics(), automessage.dispatcherService(), + automessage.messageService()), + new SendCommand(automessage.dispatcherService(), automessage.messageService()), + new ToggleCommand(automessage.optOut(), automessage.scheduler(), + automessage.messageService()) + ); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/AnnouncementChannelArgument.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/AnnouncementChannelArgument.java new file mode 100644 index 0000000..9be7d65 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/AnnouncementChannelArgument.java @@ -0,0 +1,43 @@ +package com.github.imdmk.automessage.command.argument; + +import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig; +import dev.rollczi.litecommands.argument.Argument; +import dev.rollczi.litecommands.argument.parser.ParseResult; +import dev.rollczi.litecommands.argument.resolver.ArgumentResolver; +import dev.rollczi.litecommands.invocation.Invocation; +import dev.rollczi.litecommands.suggestion.SuggestionContext; +import dev.rollczi.litecommands.suggestion.SuggestionResult; + +public final class AnnouncementChannelArgument extends ArgumentResolver { + + private final MessageDispatcherConfig dispatcherConfig; + + public AnnouncementChannelArgument(MessageDispatcherConfig dispatcherConfig) { + this.dispatcherConfig = dispatcherConfig; + } + + // Read from the configuration on every invocation rather than captured once, so a channel + // added by a reload can be named without restarting the server. + @Override + protected ParseResult parse( + Invocation invocation, + Argument context, + String argument + ) { + return dispatcherConfig.channels().stream() + .filter(channel -> channel.matches(argument)) + .findFirst() + .>map(ParseResult::success) + .orElseGet(() -> ParseResult.failure(new UnknownAnnouncementChannel(argument))); + } + + @Override + public SuggestionResult suggest( + Invocation invocation, + Argument argument, + SuggestionContext context + ) { + return SuggestionResult.of(dispatcherConfig.channels().stream().map(AnnouncementChannel::name).toList()); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgument.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgument.java similarity index 66% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgument.java rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgument.java index e08f913..6b2d754 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgument.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgument.java @@ -1,4 +1,4 @@ -package com.github.imdmk.automessage.platform.litecommands.argument; +package com.github.imdmk.automessage.command.argument; import com.github.imdmk.automessage.scheduled.ScheduledMessage; import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; @@ -8,18 +8,8 @@ import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.suggestion.SuggestionContext; import dev.rollczi.litecommands.suggestion.SuggestionResult; -import org.bukkit.command.CommandSender; -/** - * Resolves a {@link ScheduledMessage} from its configured name and suggests the names currently - * present in {@code scheduledMessages.yml}. - * - *

- * Suggestions are read on every keystroke, so messages added by an administrator become - * available right after {@code /automessage reload}. - *

- */ -public final class ScheduledMessageArgument extends ArgumentResolver { +public final class ScheduledMessageArgument extends ArgumentResolver { private final ScheduledMessageRepository repository; @@ -29,7 +19,7 @@ public ScheduledMessageArgument(ScheduledMessageRepository repository) { @Override protected ParseResult parse( - Invocation invocation, + Invocation invocation, Argument context, String argument ) { @@ -40,7 +30,7 @@ protected ParseResult parse( @Override public SuggestionResult suggest( - Invocation invocation, + Invocation invocation, Argument argument, SuggestionContext context ) { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownAnnouncementChannel.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownAnnouncementChannel.java new file mode 100644 index 0000000..a1cd302 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownAnnouncementChannel.java @@ -0,0 +1,4 @@ +package com.github.imdmk.automessage.command.argument; + +public record UnknownAnnouncementChannel(String name) { +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownScheduledMessage.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownScheduledMessage.java new file mode 100644 index 0000000..96cb80a --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/argument/UnknownScheduledMessage.java @@ -0,0 +1,4 @@ +package com.github.imdmk.automessage.command.argument; + +public record UnknownScheduledMessage(String name) { +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java index 669fcf3..b70b5fc 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/DisableCommand.java @@ -1,12 +1,12 @@ package com.github.imdmk.automessage.command.dispatcher; import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.viewer.Viewer; import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; @Command(name = "automessage disable") @Permission("command.automessage.disable") @@ -15,24 +15,21 @@ public final class DisableCommand { private final MessageDispatcherConfig dispatcherConfig; private final MessageService messageService; - public DisableCommand( - MessageDispatcherConfig dispatcherConfig, - MessageService messageService - ) { + public DisableCommand(MessageDispatcherConfig dispatcherConfig, MessageService messageService) { this.dispatcherConfig = dispatcherConfig; this.messageService = messageService; } @Execute - void disable(@Context CommandSender sender) { + void disable(@Context Viewer viewer) { if (!dispatcherConfig.isEnabled()) { - messageService.send(sender, n -> n.commands.dispatcherAlreadyDisabled); + messageService.send(viewer, n -> n.commands.dispatcherAlreadyDisabled); return; } dispatcherConfig.setEnabled(false); dispatcherConfig.save(); - messageService.send(sender, n -> n.commands.dispatcherDisabled); + messageService.send(viewer, n -> n.commands.dispatcherDisabled); } -} \ No newline at end of file +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java index dedae3a..4ca9886 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/dispatcher/EnableCommand.java @@ -1,12 +1,12 @@ package com.github.imdmk.automessage.command.dispatcher; import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.viewer.Viewer; import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; @Command(name = "automessage enable") @Permission("command.automessage.enable") @@ -15,24 +15,21 @@ public final class EnableCommand { private final MessageDispatcherConfig dispatcherConfig; private final MessageService messageService; - public EnableCommand( - MessageDispatcherConfig dispatcherConfig, - MessageService messageService - ) { + public EnableCommand(MessageDispatcherConfig dispatcherConfig, MessageService messageService) { this.dispatcherConfig = dispatcherConfig; this.messageService = messageService; } @Execute - void enable(@Context CommandSender sender) { + void enable(@Context Viewer viewer) { if (dispatcherConfig.isEnabled()) { - messageService.send(sender, n -> n.commands.dispatcherAlreadyEnabled); + messageService.send(viewer, n -> n.commands.dispatcherAlreadyEnabled); return; } dispatcherConfig.setEnabled(true); dispatcherConfig.save(); - messageService.send(sender, n -> n.commands.dispatcherEnabled); + messageService.send(viewer, n -> n.commands.dispatcherEnabled); } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/InvalidUsageHandlerImpl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/InvalidUsageHandlerImpl.java similarity index 66% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/InvalidUsageHandlerImpl.java rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/InvalidUsageHandlerImpl.java index 8c0ba75..65abd02 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/InvalidUsageHandlerImpl.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/InvalidUsageHandlerImpl.java @@ -1,33 +1,36 @@ -package com.github.imdmk.automessage.platform.litecommands.handler; +package com.github.imdmk.automessage.command.handler; import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; import dev.rollczi.litecommands.handler.result.ResultHandlerChain; import dev.rollczi.litecommands.invalidusage.InvalidUsage; import dev.rollczi.litecommands.invalidusage.InvalidUsageHandler; import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.schematic.Schematic; -import org.bukkit.command.CommandSender; -public final class InvalidUsageHandlerImpl implements InvalidUsageHandler { +public final class InvalidUsageHandlerImpl implements InvalidUsageHandler { private final MessageService messageService; + private final ViewerFactory viewers; - public InvalidUsageHandlerImpl(MessageService messageService) { + public InvalidUsageHandlerImpl(MessageService messageService, ViewerFactory viewers) { this.messageService = messageService; + this.viewers = viewers; } @Override public void handle( - Invocation invocation, - InvalidUsage result, - ResultHandlerChain chain + Invocation invocation, + InvalidUsage result, + ResultHandlerChain chain ) { - final CommandSender sender = invocation.sender(); + final Viewer viewer = viewers.of(invocation.sender()); final Schematic schematic = result.getSchematic(); if (schematic.isOnlyFirst()) { messageService.create() - .viewer(sender) + .viewer(viewer) .notice(notice -> notice.commands.usageInvalid) .placeholder("{USAGE}", schematic.first()) .send(); @@ -35,13 +38,13 @@ public void handle( } messageService.create() - .viewer(sender) + .viewer(viewer) .notice(notice -> notice.commands.usageHeader) .send(); for (final String sch : schematic.all()) { messageService.create() - .viewer(sender) + .viewer(viewer) .notice(notice -> notice.commands.usageEntry) .placeholder("{USAGE}", sch) .send(); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/MissingPermissionsHandlerImpl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/MissingPermissionsHandlerImpl.java similarity index 63% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/MissingPermissionsHandlerImpl.java rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/MissingPermissionsHandlerImpl.java index 1750d94..39127a8 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/MissingPermissionsHandlerImpl.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/MissingPermissionsHandlerImpl.java @@ -1,28 +1,30 @@ -package com.github.imdmk.automessage.platform.litecommands.handler; +package com.github.imdmk.automessage.command.handler; import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; import dev.rollczi.litecommands.handler.result.ResultHandlerChain; import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.permission.MissingPermissions; import dev.rollczi.litecommands.permission.MissingPermissionsHandler; -import org.bukkit.command.CommandSender; -public final class MissingPermissionsHandlerImpl implements MissingPermissionsHandler { +public final class MissingPermissionsHandlerImpl implements MissingPermissionsHandler { private final MessageService messageService; + private final ViewerFactory viewers; - public MissingPermissionsHandlerImpl(MessageService messageService) { + public MissingPermissionsHandlerImpl(MessageService messageService, ViewerFactory viewers) { this.messageService = messageService; + this.viewers = viewers; } @Override public void handle( - Invocation invocation, + Invocation invocation, MissingPermissions permissions, - ResultHandlerChain chain + ResultHandlerChain chain ) { messageService.create() - .viewer(invocation.sender()) + .viewer(viewers.of(invocation.sender())) .notice(n -> n.commands.permissionMissing) .placeholder("{PERMISSIONS}", String.join(", ", permissions.getPermissions())) .send(); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/NoticeResultHandlerImpl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/NoticeResultHandlerImpl.java new file mode 100644 index 0000000..b538f01 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/NoticeResultHandlerImpl.java @@ -0,0 +1,28 @@ +package com.github.imdmk.automessage.command.handler; + +import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import dev.rollczi.litecommands.handler.result.ResultHandler; +import dev.rollczi.litecommands.handler.result.ResultHandlerChain; +import dev.rollczi.litecommands.invocation.Invocation; + +public final class NoticeResultHandlerImpl implements ResultHandler { + + private final MessageService messageService; + private final ViewerFactory viewers; + + public NoticeResultHandlerImpl(MessageService messageService, ViewerFactory viewers) { + this.messageService = messageService; + this.viewers = viewers; + } + + @Override + public void handle( + Invocation invocation, + Notice notice, + ResultHandlerChain chain + ) { + messageService.send(viewers.of(invocation.sender()), n -> notice); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownAnnouncementChannelHandler.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownAnnouncementChannelHandler.java new file mode 100644 index 0000000..793f862 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownAnnouncementChannelHandler.java @@ -0,0 +1,32 @@ +package com.github.imdmk.automessage.command.handler; + +import com.github.imdmk.automessage.command.argument.UnknownAnnouncementChannel; +import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import dev.rollczi.litecommands.handler.result.ResultHandler; +import dev.rollczi.litecommands.handler.result.ResultHandlerChain; +import dev.rollczi.litecommands.invocation.Invocation; + +public final class UnknownAnnouncementChannelHandler implements ResultHandler { + + private final MessageService messageService; + private final ViewerFactory viewers; + + public UnknownAnnouncementChannelHandler(MessageService messageService, ViewerFactory viewers) { + this.messageService = messageService; + this.viewers = viewers; + } + + @Override + public void handle( + Invocation invocation, + UnknownAnnouncementChannel result, + ResultHandlerChain chain + ) { + messageService.create() + .viewer(viewers.of(invocation.sender())) + .notice(notice -> notice.commands.channelNotFound) + .placeholder("{CHANNEL}", result.name()) + .send(); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/UnknownScheduledMessageHandler.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownScheduledMessageHandler.java similarity index 55% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/UnknownScheduledMessageHandler.java rename to automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownScheduledMessageHandler.java index 173612c..ae63c34 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/UnknownScheduledMessageHandler.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/handler/UnknownScheduledMessageHandler.java @@ -1,28 +1,30 @@ -package com.github.imdmk.automessage.platform.litecommands.handler; +package com.github.imdmk.automessage.command.handler; +import com.github.imdmk.automessage.command.argument.UnknownScheduledMessage; import com.github.imdmk.automessage.message.MessageService; -import com.github.imdmk.automessage.platform.litecommands.argument.UnknownScheduledMessage; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; import dev.rollczi.litecommands.handler.result.ResultHandler; import dev.rollczi.litecommands.handler.result.ResultHandlerChain; import dev.rollczi.litecommands.invocation.Invocation; -import org.bukkit.command.CommandSender; -public final class UnknownScheduledMessageHandler implements ResultHandler { +public final class UnknownScheduledMessageHandler implements ResultHandler { private final MessageService messageService; + private final ViewerFactory viewers; - public UnknownScheduledMessageHandler(MessageService messageService) { + public UnknownScheduledMessageHandler(MessageService messageService, ViewerFactory viewers) { this.messageService = messageService; + this.viewers = viewers; } @Override public void handle( - Invocation invocation, + Invocation invocation, UnknownScheduledMessage result, - ResultHandlerChain chain + ResultHandlerChain chain ) { messageService.create() - .viewer(invocation.sender()) + .viewer(viewers.of(invocation.sender())) .notice(notice -> notice.commands.messageNotFound) .placeholder("{MESSAGE}", result.name()) .send(); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/next/NextCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/next/NextCommand.java new file mode 100644 index 0000000..743cccb --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/next/NextCommand.java @@ -0,0 +1,50 @@ +package com.github.imdmk.automessage.command.next; + +import com.github.imdmk.automessage.language.CommandMessages; +import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.notice.time.DurationFormatter; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.scheduled.dispatcher.ChannelPreview; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; + +@Command(name = "automessage next") +@Permission("command.automessage.next") +public final class NextCommand { + + private final MessageDispatcherService dispatcher; + private final MessageService messageService; + + public NextCommand(MessageDispatcherService dispatcher, MessageService messageService) { + this.dispatcher = dispatcher; + this.messageService = messageService; + } + + @Execute + void next(@Context Viewer viewer) { + messageService.send(viewer, notice -> notice.commands.nextHeader); + + for (final ChannelPreview preview : dispatcher.upcoming()) { + messageService.create() + .viewer(viewer) + .notice(notice -> lineFor(notice.commands, preview)) + .placeholder("{CHANNEL}", preview.channel()) + .placeholder("{MESSAGE}", preview.message() == null ? "" : preview.message()) + .placeholder("{DELAY}", preview.due() == null ? "" : DurationFormatter.formatReadable(preview.due())) + .send(); + } + } + + private static Notice lineFor(CommandMessages messages, ChannelPreview preview) { + return switch (preview.kind()) { + case NEXT -> messages.nextEntry; + case UNPREDICTABLE -> messages.nextUnpredictable; + case DISABLED -> messages.nextDisabled; + case EMPTY -> messages.nextEmpty; + }; + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java index 0a0baf8..7b34993 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/reload/ReloadCommand.java @@ -4,11 +4,11 @@ import com.github.imdmk.automessage.message.MessageService; import com.github.imdmk.automessage.platform.logger.PluginLogger; import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.Viewer; import dev.rollczi.litecommands.annotations.command.Command; import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; @Command(name = "automessage reload") @Permission("command.automessage.reload") @@ -32,14 +32,14 @@ public ReloadCommand( } @Execute - void reload(@Context CommandSender sender) { + void reload(@Context Viewer viewer) { taskScheduler.runAsync(() -> { try { configReloadService.reload(); - messageService.send(sender, n -> n.commands.configReloadedSuccess); + messageService.send(viewer, n -> n.commands.configReloadedSuccess); } catch (Exception e) { logger.error(e, "Failed to reload plugin config"); - messageService.send(sender, n -> n.commands.configReloadFailed); + messageService.send(viewer, n -> n.commands.configReloadFailed); } }); } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/send/SendCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/send/SendCommand.java new file mode 100644 index 0000000..9935e0f --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/send/SendCommand.java @@ -0,0 +1,54 @@ +package com.github.imdmk.automessage.command.send; + +import com.github.imdmk.automessage.language.CommandMessages; +import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.notice.time.DurationFormatter; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; +import com.github.imdmk.automessage.scheduled.dispatcher.ForcedSend; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService; +import dev.rollczi.litecommands.annotations.argument.Arg; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; + +// Deliberately its own command rather than an argument of /automessage next. That one answers a +// question; this one broadcasts to everybody online, and the two should not be separated by a +// single word somebody could tab-complete by accident. +@Command(name = "automessage send") +@Permission("command.automessage.send") +public final class SendCommand { + + private final MessageDispatcherService dispatcher; + private final MessageService messageService; + + public SendCommand(MessageDispatcherService dispatcher, MessageService messageService) { + this.dispatcher = dispatcher; + this.messageService = messageService; + } + + @Execute + void send(@Context Viewer viewer, @Arg("channel") AnnouncementChannel channel) { + final ForcedSend result = dispatcher.forceNext(channel); + + messageService.create() + .viewer(viewer) + .notice(notice -> lineFor(notice.commands, result)) + .placeholder("{CHANNEL}", channel.name()) + .placeholder("{MESSAGE}", result.message().map(ScheduledMessage::name).orElse("")) + .placeholder("{DELAY}", result.nextIn() == null ? "" : DurationFormatter.formatReadable(result.nextIn())) + .send(); + } + + private static Notice lineFor(CommandMessages messages, ForcedSend result) { + return switch (result.kind()) { + case SENT -> messages.sendDone; + case NOBODY_ONLINE -> messages.sendNobodyOnline; + case NO_MESSAGES -> messages.sendNoMessages; + case DISABLED -> messages.sendDisabled; + }; + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/stats/StatsCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/stats/StatsCommand.java new file mode 100644 index 0000000..b5a6933 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/stats/StatsCommand.java @@ -0,0 +1,112 @@ +package com.github.imdmk.automessage.command.stats; + +import com.github.imdmk.automessage.language.CommandMessages; +import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.notice.time.DurationFormatter; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.scheduled.dispatcher.ChannelPreview; +import com.github.imdmk.automessage.scheduled.dispatcher.DispatchStatistics; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; + +import java.util.List; +import java.util.Optional; + +@Command(name = "automessage stats") +@Permission("command.automessage.stats") +public final class StatsCommand { + + private final DispatchStatistics statistics; + private final MessageDispatcherService dispatcher; + private final MessageService messageService; + + public StatsCommand( + DispatchStatistics statistics, + MessageDispatcherService dispatcher, + MessageService messageService + ) { + this.statistics = statistics; + this.dispatcher = dispatcher; + this.messageService = messageService; + } + + @Execute + void stats(@Context Viewer viewer) { + final long total = statistics.total(); + + messageService.create() + .viewer(viewer) + .notice(notice -> total == 0 ? notice.commands.statsEmpty : notice.commands.statsHeader) + .placeholder("{TOTAL}", Long.toString(total)) + .send(); + + sendChannels(viewer); + sendMessages(viewer); + } + + // Two labelled lists rather than one mixed one. A channel row and a message row used to look + // alike, and a reader had no way to tell which kind of name they were looking at. + private void sendChannels(Viewer viewer) { + final List channels = dispatcher.upcoming(); + + if (channels.isEmpty()) { + return; + } + + messageService.send(viewer, notice -> notice.commands.statsChannelsHeader); + + for (final ChannelPreview preview : channels) { + final Optional counted = statistics.channel(preview.channel()); + final long count = counted.map(DispatchStatistics.Entry::count).orElse(0L); + + messageService.create() + .viewer(viewer) + .notice(notice -> channelLine(notice.commands, preview, count)) + .placeholder("{CHANNEL}", preview.channel()) + .placeholder("{COUNT}", Long.toString(count)) + .placeholder("{AGO}", counted + .map(entry -> DurationFormatter.formatReadable(entry.since())) + .orElse("")) + .placeholder("{DELAY}", preview.due() == null + ? "" + : DurationFormatter.formatReadable(preview.due())) + .send(); + } + } + + private void sendMessages(Viewer viewer) { + final List entries = statistics.snapshot(); + + if (entries.isEmpty()) { + return; + } + + messageService.send(viewer, notice -> notice.commands.statsMessagesHeader); + + for (final DispatchStatistics.Entry entry : entries) { + messageService.create() + .viewer(viewer) + .notice(notice -> notice.commands.statsEntry) + .placeholder("{MESSAGE}", entry.name()) + .placeholder("{CHANNEL}", entry.channel() == null ? "" : entry.channel()) + .placeholder("{COUNT}", Long.toString(entry.count())) + .placeholder("{AGO}", DurationFormatter.formatReadable(entry.since())) + .send(); + } + } + + // A channel that is not counting down says why here rather than leaving the reader to run + // /automessage next to find out - "not counting down" described the missing timer, which is + // this command's problem and not the reader's. + private static Notice channelLine(CommandMessages messages, ChannelPreview preview, long count) { + return switch (preview.kind()) { + case DISABLED -> messages.statsChannelDisabled; + case EMPTY -> messages.statsChannelEmpty; + case NEXT, UNPREDICTABLE -> count == 0 ? messages.statsChannelPending : messages.statsChannel; + }; + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/toggle/ToggleCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/toggle/ToggleCommand.java new file mode 100644 index 0000000..533094c --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/toggle/ToggleCommand.java @@ -0,0 +1,45 @@ +package com.github.imdmk.automessage.command.toggle; + +import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.scheduled.audience.optout.AnnouncementOptOut; +import dev.rollczi.litecommands.annotations.command.Command; +import dev.rollczi.litecommands.annotations.context.Context; +import dev.rollczi.litecommands.annotations.execute.Execute; +import dev.rollczi.litecommands.annotations.permission.Permission; + +@Command(name = "automessage toggle") +@Permission("command.automessage.toggle") +public final class ToggleCommand { + + private final AnnouncementOptOut optOut; + private final TaskScheduler scheduler; + private final MessageService messageService; + + public ToggleCommand(AnnouncementOptOut optOut, TaskScheduler scheduler, MessageService messageService) { + this.optOut = optOut; + this.scheduler = scheduler; + this.messageService = messageService; + } + + @Execute + void toggle(@Context Viewer viewer) { + // The console receives announcements from nowhere, and a preference stored against its + // fixed id would be a preference nobody set. + if (!viewer.isPlayer()) { + messageService.send(viewer, notice -> notice.commands.togglePlayerOnly); + return; + } + + final boolean muted = optOut.toggle(viewer.uniqueId()); + + // Answered from memory the moment it is toggled; the file only has to be there after a + // restart, so writing it waits for a thread that is allowed to touch a disk. + scheduler.runAsync(optOut::save); + + messageService.send(viewer, notice -> muted + ? notice.commands.toggleOff + : notice.commands.toggleOn); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java b/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java index eb8c9b8..36e162f 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/command/view/ViewCommand.java @@ -1,6 +1,7 @@ package com.github.imdmk.automessage.command.view; import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.platform.viewer.Viewer; import com.github.imdmk.automessage.scheduled.ScheduledMessage; import com.github.imdmk.automessage.scheduled.ScheduledMessageSender; import dev.rollczi.litecommands.annotations.argument.Arg; @@ -8,18 +9,7 @@ import dev.rollczi.litecommands.annotations.context.Context; import dev.rollczi.litecommands.annotations.execute.Execute; import dev.rollczi.litecommands.annotations.permission.Permission; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -/** - * Shows a single scheduled message to the player who ran the command, exactly as the dispatcher - * would render it. - * - *

- * The preview deliberately ignores the audience rules of the message, so staff can check how a - * message restricted to a permission or a group looks without holding that permission. - *

- */ @Command(name = "automessage view") @Permission("command.automessage.view") public final class ViewCommand { @@ -27,28 +17,24 @@ public final class ViewCommand { private final ScheduledMessageSender sender; private final MessageService messageService; - public ViewCommand( - ScheduledMessageSender sender, - MessageService messageService - ) { + public ViewCommand(ScheduledMessageSender sender, MessageService messageService) { this.sender = sender; this.messageService = messageService; } @Execute - void view( - @Context CommandSender commandSender, - @Arg("message") ScheduledMessage message - ) { - if (!(commandSender instanceof Player player)) { - messageService.send(commandSender, n -> n.commands.viewPlayerOnly); + void view(@Context Viewer viewer, @Arg("message") ScheduledMessage message) { + // A title or a boss bar has nowhere to go on a console, so the preview would be a + // half-truth at best. + if (!viewer.isPlayer()) { + messageService.send(viewer, n -> n.commands.viewPlayerOnly); return; } - sender.send(player, message); + sender.send(viewer, message); messageService.create() - .viewer(player) + .viewer(viewer) .notice(notice -> notice.commands.messagePreviewed) .placeholder("{MESSAGE}", message.name()) .send(); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/CapabilityFilter.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CapabilityFilter.java new file mode 100644 index 0000000..ed51ad1 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CapabilityFilter.java @@ -0,0 +1,40 @@ +package com.github.imdmk.automessage.config; + +import com.github.imdmk.automessage.platform.capability.Capabilities; +import eu.okaeri.configs.schema.ConfigDeclaration; +import eu.okaeri.configs.schema.FieldDeclaration; + +import java.lang.reflect.Field; +import java.util.Map; + +final class CapabilityFilter { + + private final Capabilities capabilities; + + CapabilityFilter(Capabilities capabilities) { + this.capabilities = capabilities; + } + + void apply(ConfigSection config) { + final ConfigDeclaration declaration = config.getDeclaration(); + final Map fields = declaration.getFieldMap(); + + fields.values().removeIf(this::unsupported); + + // The options are only half of the file; the comments around them are the half an + // administrator reads, and they document rules and triggers too. + declaration.setHeader(CommentDirectives.apply(declaration.getHeader(), capabilities)); + fields.values().forEach(field -> + field.setComment(CommentDirectives.apply(field.getComment(), capabilities)) + ); + + config.applyCapabilities(capabilities); + } + + private boolean unsupported(FieldDeclaration declaration) { + final Field field = declaration.getField(); + final RequiresCapability required = field.getAnnotation(RequiresCapability.class); + + return required != null && !capabilities.supports(required.value()); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/CommentDirectives.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CommentDirectives.java new file mode 100644 index 0000000..4b96b4a --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/CommentDirectives.java @@ -0,0 +1,91 @@ +package com.github.imdmk.automessage.config; + +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +final class CommentDirectives { + + private static final String REQUIRES = "@requires"; + private static final String END = "@end"; + + private CommentDirectives() { + } + + static String[] apply(String[] lines, Capabilities capabilities) { + if (lines == null || !hasDirective(lines)) { + return lines; + } + + final List kept = new ArrayList<>(lines.length); + + Capability required = null; + + for (final String line : lines) { + final String directive = line.strip(); + + if (directive.startsWith(REQUIRES)) { + if (required != null) { + throw new IllegalStateException( + "Nested " + REQUIRES + " in a configuration comment: " + directive + ); + } + + required = capabilityOf(directive); + continue; + } + + if (directive.equals(END)) { + if (required == null) { + throw new IllegalStateException( + END + " without a matching " + REQUIRES + " in a configuration comment" + ); + } + + required = null; + continue; + } + + if (required == null || capabilities.supports(required)) { + kept.add(line); + } + } + + if (required != null) { + throw new IllegalStateException( + REQUIRES + " " + required + " was never closed with " + END + ); + } + + return kept.toArray(String[]::new); + } + + private static boolean hasDirective(String[] lines) { + for (final String line : lines) { + final String directive = line.strip(); + + if (directive.startsWith(REQUIRES) || directive.equals(END)) { + return true; + } + } + + return false; + } + + private static Capability capabilityOf(String directive) { + final String name = directive.substring(REQUIRES.length()).strip().toUpperCase(Locale.ROOT); + + try { + return Capability.valueOf(name); + } catch (IllegalArgumentException exception) { + // A typo here would silently keep documentation nobody can act on, which is the exact + // failure this class exists to prevent - so it is an error, not a fallback. + throw new IllegalStateException( + "Unknown capability '" + name + "' in a configuration comment", exception + ); + } + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java index 04a2ef9..e7ec9ef 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigManager.java @@ -1,51 +1,44 @@ package com.github.imdmk.automessage.config; +import com.github.imdmk.automessage.platform.capability.Capabilities; import com.github.imdmk.automessage.platform.logger.PluginLogger; -import org.jetbrains.annotations.Unmodifiable; import java.io.File; -import java.util.Collections; -import java.util.List; -import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; public final class ConfigManager { private final Set configs = ConcurrentHashMap.newKeySet(); - private final Map, ConfigSection> byType = new ConcurrentHashMap<>(); private final File dataFolder; private final ConfigFactory factory; private final ConfigBinder binder; private final ConfigLifecycle lifecycle; + private final CapabilityFilter capabilityFilter; - public ConfigManager(PluginLogger logger, File dataFolder) { + public ConfigManager(PluginLogger logger, File dataFolder, Capabilities capabilities) { this.dataFolder = dataFolder; this.factory = new ConfigFactory(); this.binder = new ConfigBinder(); this.lifecycle = new ConfigLifecycle(logger); + this.capabilityFilter = new CapabilityFilter(capabilities); + } + + public ConfigManager(PluginLogger logger, File dataFolder) { + this(logger, dataFolder, Capabilities.all()); } public C create(Class type) { final C config = factory.create(type); initialize(config); - byType.put(type, config); return config; } - /** - * Initializes a configuration that was constructed by the caller. - * - *

- * Language files all share one class and differ only in the file they are bound to, so there - * is nothing to look them up by type with - the caller holds them instead. - *

- */ public C create(C config) { initialize(config); return config; @@ -55,34 +48,19 @@ private void initialize(ConfigSection config) { final File file = new File(dataFolder, config.getFileName()); binder.bind(config, file); + + // After binding, which is what builds the declaration, and before the file is written. + capabilityFilter.apply(config); + lifecycle.initialize(config); configs.add(config); } - /** @return the plugin's data folder, so callers can look for files it does not know about */ public File dataFolder() { return dataFolder; } - public void createAll(List> types) { - types.forEach(this::create); - } - - @SuppressWarnings("unchecked") - public C get(Class type) { - return (C) byType.get(type); - } - - public C require(Class type) { - final C config = get(type); - if (config == null) { - throw new IllegalStateException("Config not created: " + type.getName()); - } - - return config; - } - public void loadAll() { configs.forEach(lifecycle::load); } @@ -91,14 +69,8 @@ public void saveAll() { configs.forEach(lifecycle::save); } - @Unmodifiable - public Set getConfigs() { - return Collections.unmodifiableSet(configs); - } - public void clearAll() { configs.clear(); - byType.clear(); } } \ No newline at end of file diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java index ec40a87..622285e 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadListener.java @@ -1,13 +1,5 @@ package com.github.imdmk.automessage.config; -/** - * Notified after every successful reload of the plugin configuration. - * - *

- * Implement this to re-apply settings that are read once, outside of the request path — - * for example a scheduled task whose interval is fixed at scheduling time. - *

- */ @FunctionalInterface public interface ConfigReloadListener { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java index 8497811..1b6c15b 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigReloadService.java @@ -3,17 +3,6 @@ import java.util.List; import java.util.concurrent.CopyOnWriteArrayList; -/** - * Reloads every registered configuration file and lets the rest of the plugin re-apply - * the freshly loaded values. - * - *

- * Reading a configuration field on every use is enough for most settings, but not for values - * consumed once — such as the dispatcher interval, which Bukkit captures when the task is - * scheduled. Listeners registered here close that gap, so {@code /automessage reload} applies - * the whole configuration instead of only part of it. - *

- */ public final class ConfigReloadService { private final ConfigManager configManager; @@ -27,12 +16,6 @@ public void register(ConfigReloadListener listener) { listeners.add(listener); } - /** - * Loads every configuration file from disk and notifies all listeners. - * - * @throws ConfigAccessException when any configuration file could not be loaded; - * listeners are not notified in that case - */ public void reload() { configManager.loadAll(); listeners.forEach(ConfigReloadListener::onConfigReload); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java index 0fa0b35..7e3bd48 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigRepresenter.java @@ -59,6 +59,7 @@ public Node representData(Object data) { } private class RepresentMap implements Represent { + @Override public Node representData(Object data) { return representMapping(Tag.MAP, (Map) data, DumperOptions.FlowStyle.BLOCK); } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java index 4c0bf32..841ccaa 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/ConfigSection.java @@ -1,36 +1,15 @@ package com.github.imdmk.automessage.config; +import com.github.imdmk.automessage.platform.capability.Capabilities; import eu.okaeri.configs.OkaeriConfig; import eu.okaeri.configs.serdes.OkaeriSerdesPack; -/** - * Abstract base class for configuration sections. - * - *

- * Extends {@link OkaeriConfig} to provide a reusable foundation for plugin - * configuration sections. Subclasses are required to specify the - * serialization/deserialization pack and the configuration file name. - *

- * - *

- * Supports automatic recursive loading of nested {@link ConfigSection} - * subclasses declared as fields inside this class. - *

- */ public abstract class ConfigSection extends OkaeriConfig { - /** - * Returns the {@link OkaeriSerdesPack} instance used for serializing and deserializing - * this configuration section. - * - * @return non-null serialization/deserialization pack - */ + public void applyCapabilities(Capabilities capabilities) { + } + public abstract OkaeriSerdesPack getSerdesPack(); - /** - * Returns the filename (including extension) used to persist this configuration section. - * - * @return non-null configuration file name - */ public abstract String getFileName(); } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/config/RequiresCapability.java b/automessage-core/src/main/java/com/github/imdmk/automessage/config/RequiresCapability.java new file mode 100644 index 0000000..8e9ff48 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/config/RequiresCapability.java @@ -0,0 +1,17 @@ +package com.github.imdmk.automessage.config; + +import com.github.imdmk.automessage.platform.capability.Capability; + +import java.lang.annotation.Documented; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Documented +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.FIELD) +public @interface RequiresCapability { + + Capability value(); +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java index 41380a4..c789c87 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/CommandMessages.java @@ -1,17 +1,9 @@ package com.github.imdmk.automessage.language; -import com.eternalcode.multification.notice.Notice; +import com.github.imdmk.automessage.notice.Notice; import eu.okaeri.configs.OkaeriConfig; import eu.okaeri.configs.annotation.Comment; -/** - * Everything the plugin says in reply to a command. - * - *

- * One flat section rather than four nested ones: there are thirteen messages in total, and a - * translator reading the file should not have to remember which subsection a string lives in. - *

- */ public class CommandMessages extends OkaeriConfig { @Comment({"#", "# {PERMISSIONS} - the permissions the player is missing.", "#"}) @@ -60,14 +52,110 @@ public class CommandMessages extends OkaeriConfig { @Comment({"#", "# /automessage view. {MESSAGE} - the message name.", "#"}) public Notice messagePreviewed = Notice.chat( - "Previewed scheduled message {MESSAGE}." + "Previewed scheduled message {MESSAGE}." ); public Notice messageNotFound = Notice.chat( - "No scheduled message named {MESSAGE} exists in scheduledMessages.yml." + "No scheduled message named {MESSAGE} exists in scheduledMessages.yml." ); public Notice viewPlayerOnly = Notice.chat( "Only players can preview scheduled messages." ); + + @Comment({"#", "# /automessage next. {CHANNEL} - the channel, {MESSAGE} - the message name.", "#"}) + @Comment({"#", "# /automessage next. {CHANNEL}, {MESSAGE} and {DELAY} until it fires.", "#"}) + public Notice nextHeader = Notice.chat("» Next up on each channel"); + + public Notice nextEntry = Notice.chat( + "{CHANNEL} · {MESSAGE} · in {DELAY}" + ); + + public Notice nextUnpredictable = Notice.chat( + "{CHANNEL} · drawn at random · in {DELAY}" + ); + + public Notice nextDisabled = Notice.chat( + "{CHANNEL} · disabled in config.yml" + ); + + public Notice nextEmpty = Notice.chat( + "{CHANNEL} · no messages assigned" + ); + + @Comment({ + "#", + "# /automessage stats. {TOTAL}, {CHANNEL}, {MESSAGE}, {COUNT} and {AGO}.", + "# Channels are listed apart from messages, so a name is never ambiguous: a channel", + "# row says when the next one is due, a message row names the channel it goes out on.", + "#" + }) + public Notice statsHeader = Notice.chat( + "» AutoMessage · sent {TOTAL} since startup" + ); + + public Notice statsEmpty = Notice.chat("» AutoMessage · nothing announced yet"); + + public Notice statsChannelsHeader = Notice.chat("» Channels"); + + public Notice statsChannel = Notice.chat( + "{CHANNEL} · {COUNT}x · next in {DELAY}" + ); + + public Notice statsChannelPending = Notice.chat( + "{CHANNEL} · nothing yet · next in {DELAY}" + ); + + public Notice statsChannelDisabled = Notice.chat( + "{CHANNEL} · {COUNT}x · " + + "disabled in config.yml" + ); + + public Notice statsChannelEmpty = Notice.chat( + "{CHANNEL} · {COUNT}x · " + + "no messages assigned" + ); + + public Notice statsMessagesHeader = Notice.chat("» Messages"); + + public Notice statsEntry = Notice.chat( + "{MESSAGE} ({CHANNEL}) · " + + "{COUNT}x · {AGO} ago" + ); + + @Comment({"#", "# /automessage send. {CHANNEL}, {MESSAGE} and {DELAY} until the next one.", "#"}) + public Notice sendDone = Notice.chat( + "Sent {MESSAGE} on {CHANNEL}. " + + "Next one in {DELAY}." + ); + + public Notice sendNobodyOnline = Notice.chat( + "Nobody is online, so nothing was sent on {CHANNEL}. " + + "Its schedule was left alone." + ); + + public Notice sendNoMessages = Notice.chat( + "Channel {CHANNEL} has no messages assigned to it." + ); + + public Notice sendDisabled = Notice.chat( + "Channel {CHANNEL} is disabled in config.yml." + ); + + @Comment({"#", "# /automessage toggle. A player turning announcements off for themselves.", "#"}) + public Notice toggleOff = Notice.chat( + "Announcements are now off for you" + ); + + public Notice toggleOn = Notice.chat( + "Announcements are on for you again" + ); + + public Notice togglePlayerOnly = Notice.chat( + "Only players can turn announcements off" + ); + + public Notice channelNotFound = Notice.chat( + "No channel named {CHANNEL} exists in config.yml." + ); } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java index 4bd8648..14170e7 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageCode.java @@ -2,24 +2,12 @@ import java.util.Locale; -/** - * Normalises the several shapes a language code arrives in. - * - *

- * Bukkit reports {@code pl_pl}. Multification wraps that in {@code new Locale("pl_pl")}, which - * puts the whole string in the language field - so {@link Locale#getLanguage()} answers - * {@code pl_pl}, not {@code pl}. People write {@code pl}, {@code PL} or {@code pl-PL} in file - * names and configuration. Everything is folded to lower case with an underscore here so the - * lookup cannot miss over punctuation, which is exactly how the first version of this went wrong. - *

- */ public final class LanguageCode { private LanguageCode() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** @return the code in lower case with an underscore separator, e.g. {@code pt_br} */ public static String normalize(String code) { if (code == null || code.isBlank()) { return ""; @@ -28,20 +16,13 @@ public static String normalize(String code) { return code.trim().toLowerCase(Locale.ROOT).replace('-', '_'); } - /** - * Reads a code out of a {@link Locale} without trusting its accessors. - * - *

- * {@code getLanguage()} cannot be relied on here: for the locales Multification builds from - * Bukkit it returns the whole {@code pl_pl} string. {@code toString()} gives the same text in - * both cases, so it is the one to normalise. - *

- */ + // Built from toString(), never from getLanguage(). Bukkit reports "pl_pl" and Multification + // wraps it in new Locale("pl_pl"), which puts the whole string in the language field - so + // getLanguage() answers "pl_pl", never "pl", and every Polish player silently gets English. public static String of(Locale locale) { return locale == null ? "" : normalize(locale.toString()); } - /** @return just the language part: {@code pt} for {@code pt_br} */ public static String language(String code) { final String normalized = normalize(code); final int separator = normalized.indexOf('_'); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java index 66cad46..833a2b7 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageConfig.java @@ -1,8 +1,7 @@ package com.github.imdmk.automessage.language; -import com.eternalcode.multification.notice.Notice; -import com.eternalcode.multification.notice.resolver.NoticeResolverDefaults; -import com.eternalcode.multification.okaeri.MultificationSerdesPack; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.notice.NoticeSerializer; import com.github.imdmk.automessage.config.ConfigSection; import eu.okaeri.configs.annotation.Comment; import eu.okaeri.configs.annotation.Header; @@ -13,15 +12,6 @@ import java.util.List; import java.util.Map; -/** - * One language: every piece of text the plugin can show, in that language. - * - *

- * All languages share this one class and differ only in the file they are bound to, which is what - * makes adding a language a matter of copying a file rather than writing code. The file name is - * therefore a property of the instance, not of the class. - *

- */ @Header({ "# ============================================================================", "# AutoMessage - language file", @@ -72,24 +62,20 @@ public final class LanguageConfig extends ConfigSection { "# announcements:", "# vote-reminder:", "# - \"Vote for the server!\"", + "@requires SOUND_NOTICE", "# - sound: \"entity.experience_orb.pickup MASTER 1.0 1.0\"", + "@end", + "@requires TITLE_NOTICE", "# event-announcement:", "# - title: \"EVENT\"", "# subtitle: \"Starting soon!\"", + "@end", "#" }) public Map> announcements = new LinkedHashMap<>(); - /** - * File this instance is bound to, relative to the plugin folder. - * - *

- * Transient so okaeri does not try to write it into the file it names. - *

- */ private transient String fileName = "lang/en.yml"; - /** Language code this file provides, lower case, e.g. {@code pl} or {@code pt_br}. */ private transient String code = "en"; public LanguageConfig() { @@ -108,10 +94,6 @@ public String code() { return code; } - /** - * @param name message name from scheduledMessages.yml - * @return the text for that announcement, or null when this language does not translate it - */ @Nullable public List announcement(String name) { final List notices = announcements.get(name); @@ -122,9 +104,7 @@ public List announcement(String name) { @Override public OkaeriSerdesPack getSerdesPack() { - return registry -> registry.register( - new MultificationSerdesPack(NoticeResolverDefaults.createRegistry()) - ); + return registry -> registry.register(new NoticeSerializer()); } @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java index 82a8022..1b63239 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/LanguageRegistry.java @@ -1,7 +1,8 @@ package com.github.imdmk.automessage.language; -import com.eternalcode.multification.notice.Notice; +import com.github.imdmk.automessage.notice.Notice; import com.github.imdmk.automessage.config.ConfigManager; +import com.github.imdmk.automessage.config.ConfigReloadListener; import com.github.imdmk.automessage.platform.logger.PluginLogger; import org.jetbrains.annotations.Nullable; import org.jetbrains.annotations.Unmodifiable; @@ -12,77 +13,82 @@ import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.function.Supplier; -/** - * Every language the server has, and the rules for choosing between them. - * - *

- * Languages are found by looking in the {@code lang/} folder, not by being listed in code. An - * administrator adds French by dropping in {@code lang/fr.yml}; nothing is compiled and nothing - * else has to be told about it. - *

- */ -public final class LanguageRegistry { +public final class LanguageRegistry implements ConfigReloadListener { private static final String FOLDER = "lang"; private static final String EXTENSION = ".yml"; private final PluginLogger logger; + private final ConfigManager configManager; + private final Supplier fallbackCode; - /** Keyed by normalised code: {@code en}, {@code pl}, {@code pt_br}. */ - private final Map byCode = new LinkedHashMap<>(); + private volatile Languages languages; - private LanguageConfig fallback; - - private LanguageRegistry(PluginLogger logger) { + private LanguageRegistry(PluginLogger logger, ConfigManager configManager, Supplier fallbackCode) { this.logger = logger; + this.configManager = configManager; + this.fallbackCode = fallbackCode; } - /** - * @param fallbackCode language served to players whose own has no file - */ public static LanguageRegistry load( ConfigManager configManager, PluginLogger logger, - String fallbackCode + Supplier fallbackCode ) { - final LanguageRegistry registry = new LanguageRegistry(logger); + final LanguageRegistry registry = new LanguageRegistry(logger, configManager, fallbackCode); + registry.languages = registry.read(Map.of()); + + return registry; + } + + @Override + public void onConfigReload() { + this.languages = read(languages.byCode()); + } + + private Languages read(Map existing) { + final Map byCode = new LinkedHashMap<>(existing); // Written out first so a fresh install is not an empty folder; discovery below cannot // create a file that does not exist yet. for (final String code : ShippedLanguages.CODES) { - registry.open(configManager, code); + open(byCode, code); } // Anything the administrator added themselves. Loading these after the shipped ones means // a hand-written lang/en.yml is already open and is not opened twice. - for (final String code : registry.discover(configManager.dataFolder())) { - registry.open(configManager, code); + for (final String code : discover(configManager.dataFolder())) { + open(byCode, code); } - if (registry.byCode.isEmpty()) { + if (byCode.isEmpty()) { throw new IllegalStateException( "No language files could be loaded from lang/ - AutoMessage has nothing to say." ); } - registry.fallback = registry.byCode.get(LanguageCode.normalize(fallbackCode)); + final String wanted = fallbackCode.get(); + LanguageConfig fallback = byCode.get(LanguageCode.normalize(wanted)); - if (registry.fallback == null) { - registry.fallback = registry.byCode.values().iterator().next(); + if (fallback == null) { + fallback = byCode.values().iterator().next(); logger.warn( "Fallback language '%s' has no file in lang/, using '%s' instead.", - fallbackCode, - registry.fallback.code() + wanted, + fallback.code() ); } - logger.info("Loaded %d language(s): %s.", registry.byCode.size(), String.join(", ", registry.byCode.keySet())); + if (byCode.size() != existing.size()) { + logger.info("Loaded %d language(s): %s.", byCode.size(), String.join(", ", byCode.keySet())); + } - return registry; + return new Languages(Map.copyOf(byCode), fallback); } - private void open(ConfigManager configManager, String code) { + private void open(Map byCode, String code) { final String normalized = LanguageCode.normalize(code); if (normalized.isEmpty() || byCode.containsKey(normalized)) { @@ -98,7 +104,6 @@ private void open(ConfigManager configManager, String code) { byCode.put(normalized, configManager.create(config)); } - /** @return the codes of every {@code lang/*.yml} already on disk */ private List discover(File dataFolder) { final File folder = new File(dataFolder, FOLDER); final File[] files = folder.listFiles(); @@ -120,52 +125,51 @@ private List discover(File dataFolder) { return codes; } - /** - * Picks the file for a viewer. - * - *

- * A full code wins over a bare language, so a server that adds {@code pt_br} serves it to - * Brazilian clients while Portuguese ones still get {@code pt}. - *

- */ public LanguageConfig provide(Locale locale) { return provide(LanguageCode.of(locale)); } public LanguageConfig provide(String rawCode) { + final Languages current = languages; final String code = LanguageCode.normalize(rawCode); - final LanguageConfig exact = byCode.get(code); + final LanguageConfig exact = current.byCode().get(code); if (exact != null) { return exact; } - final LanguageConfig language = byCode.get(LanguageCode.language(code)); + final LanguageConfig language = current.byCode().get(LanguageCode.language(code)); - return language != null ? language : fallback; + return language != null ? language : current.fallback(); } - /** - * @return the announcement text for this viewer, falling back to the fallback language and - * then to null when nobody translates it - */ @Nullable public List announcement(String messageName, String rawCode) { - final List translated = provide(rawCode).announcement(messageName); + return announcement(messageName, provide(rawCode)); + } + + // Takes the language already resolved, so a caller serving many players at once resolves it + // once for the whole group rather than once per player. + @Nullable + public List announcement(String messageName, LanguageConfig language) { + final List translated = language.announcement(messageName); if (translated != null) { return translated; } - return fallback.announcement(messageName); + return languages.fallback().announcement(messageName); } public LanguageConfig fallback() { - return fallback; + return languages.fallback(); } @Unmodifiable public List all() { - return List.copyOf(byCode.values()); + return List.copyOf(languages.byCode().values()); + } + + private record Languages(Map byCode, LanguageConfig fallback) { } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/language/ShippedLanguages.java b/automessage-core/src/main/java/com/github/imdmk/automessage/language/ShippedLanguages.java index 2fc5db9..e29f421 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/language/ShippedLanguages.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/language/ShippedLanguages.java @@ -1,6 +1,6 @@ package com.github.imdmk.automessage.language; -import com.eternalcode.multification.notice.Notice; +import com.github.imdmk.automessage.notice.Notice; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; @@ -10,295 +10,291 @@ import java.util.List; import java.util.Map; -/** - * The text a fresh install starts with, for the languages the plugin ships. - * - *

- * Only used when a language file does not exist yet. Everything here is written to disk once and - * then belongs to the server owner - editing lang/en.yml is the supported way to change any of it, - * and nothing in this class overwrites a file that is already there. - *

- * - *

- * A language the plugin does not ship needs no entry here at all: an administrator copies a file, - * translates it, and the registry finds it. This class is a convenience, not a registry. - *

- */ +// Seeds the text a language file is written with. Not something okaeri could do on its own: every +// language shares one LanguageConfig class, so saveDefaults() writes that class's field defaults +// into every file - English under a Polish name, and an empty announcements map even in English. +// +// The shape of a language is declared once here and each one supplies only its own strings, so a +// language cannot be missing an announcement the others have or carry one nobody schedules. final class ShippedLanguages { - /** - * The languages this build carries translations for. - * - *

- * Not a setting: it is a fact about the jar. Anything else is added by dropping a file into - * lang/, which the registry finds on its own - so exposing this as a config option would only - * offer a choice that changes nothing, while inviting a code nobody has translated. - *

- */ static final List CODES = List.of("en", "pl", "de"); + private static final Key VOTE_SOUND = Key.key("entity.experience_orb.pickup"); + private static final String EVENT_TITLE = "EVENT"; + private static final String SHOP_URL = "shop.example.com"; + private ShippedLanguages() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * Fills in a language's defaults, leaving anything already loaded from disk untouched. - */ static void applyDefaults(LanguageConfig config) { switch (config.code()) { - case "pl" -> polish(config); - case "de" -> german(config); - default -> english(config); - } - } - - private static void english(LanguageConfig config) { - // English is the CommandMessages defaults; nothing to override. - - config.announcements = announcementsEnglish(); - } - - private static void polish(LanguageConfig config) { - config.commands.permissionMissing = Notice.chat( - "Nie masz wymaganych uprawnień {PERMISSIONS}, aby użyć tej komendy." - ); - - config.commands.usageInvalid = Notice.chat( - "Niepoprawne użycie komendy! Poprawna składnia: {USAGE}." - ); - - config.commands.usageHeader = Notice.chat( - "Poprawne warianty użycia:" - ); - - config.commands.usageEntry = Notice.chat( - "{USAGE}" - ); - - config.commands.dispatcherEnabled = Notice.chat( - "Automatyczne wiadomości zostały włączone." - ); - - config.commands.dispatcherAlreadyEnabled = Notice.chat( - "Automatyczne wiadomości są już włączone." - ); - - config.commands.dispatcherDisabled = Notice.chat( - "Automatyczne wiadomości zostały wyłączone." - ); - - config.commands.dispatcherAlreadyDisabled = Notice.chat( - "Automatyczne wiadomości są już wyłączone." - ); - - config.commands.configReloadedSuccess = Notice.chat( - "Konfiguracja AutoMessage została przeładowana pomyślnie." - ); + case "pl" -> apply(config, POLISH_COMMANDS, POLISH_ANNOUNCEMENTS); + case "de" -> apply(config, GERMAN_COMMANDS, GERMAN_ANNOUNCEMENTS); - config.commands.configReloadFailed = Notice.chat( - "Nie udało się przeładować plików konfiguracyjnych AutoMessage. Wyłącz plugin i sprawdź swoją konfigurację." - ); - - config.commands.messagePreviewed = Notice.chat( - "Podgląd zaplanowanej wiadomości {MESSAGE}." - ); - - config.commands.messageNotFound = Notice.chat( - "Wiadomość o nazwie {MESSAGE} nie istnieje w scheduledMessages.yml." - ); - - config.commands.viewPlayerOnly = Notice.chat( - "Tylko gracze mogą oglądać podgląd zaplanowanych wiadomości." - ); - - config.announcements = announcementsPolish(); + // English is what CommandMessages already defaults to; only the announcements are new. + default -> config.announcements = announcements(ENGLISH_ANNOUNCEMENTS); + } } - private static void german(LanguageConfig config) { - config.commands.permissionMissing = Notice.chat( - "Dir fehlen die nötigen Rechte {PERMISSIONS}, um diesen Befehl zu nutzen." - ); - - config.commands.usageInvalid = Notice.chat( - "Falsche Verwendung! Richtige Syntax: {USAGE}." - ); - - config.commands.usageHeader = Notice.chat( - "Mögliche Verwendungen:" - ); - - config.commands.usageEntry = Notice.chat( - "{USAGE}" - ); - - config.commands.dispatcherEnabled = Notice.chat( - "Automatische Nachrichten wurden aktiviert." - ); - - config.commands.dispatcherAlreadyEnabled = Notice.chat( - "Automatische Nachrichten sind bereits aktiviert." - ); - - config.commands.dispatcherDisabled = Notice.chat( - "Automatische Nachrichten wurden deaktiviert." - ); - - config.commands.dispatcherAlreadyDisabled = Notice.chat( - "Automatische Nachrichten sind bereits deaktiviert." - ); - - config.commands.configReloadedSuccess = Notice.chat( - "Die AutoMessage-Konfiguration wurde erfolgreich neu geladen." - ); - - config.commands.configReloadFailed = Notice.chat( - "Die AutoMessage-Konfigurationsdateien konnten nicht neu geladen werden. Deaktiviere das Plugin und prüfe deine Konfiguration." - ); - - config.commands.messagePreviewed = Notice.chat( - "Vorschau der geplanten Nachricht {MESSAGE}." - ); - - config.commands.messageNotFound = Notice.chat( - "Es gibt keine geplante Nachricht namens {MESSAGE} in scheduledMessages.yml." - ); - - config.commands.viewPlayerOnly = Notice.chat( - "Nur Spieler können geplante Nachrichten in der Vorschau ansehen." - ); - - config.announcements = announcementsGerman(); + private static void apply(LanguageConfig config, Commands commands, Announcements announcements) { + config.commands.permissionMissing = Notice.chat(commands.permissionMissing()); + config.commands.usageInvalid = Notice.chat(commands.usageInvalid()); + config.commands.usageHeader = Notice.chat(commands.usageHeader()); + config.commands.usageEntry = Notice.chat(commands.usageEntry()); + config.commands.dispatcherEnabled = Notice.chat(commands.dispatcherEnabled()); + config.commands.dispatcherAlreadyEnabled = Notice.chat(commands.dispatcherAlreadyEnabled()); + config.commands.dispatcherDisabled = Notice.chat(commands.dispatcherDisabled()); + config.commands.dispatcherAlreadyDisabled = Notice.chat(commands.dispatcherAlreadyDisabled()); + config.commands.configReloadedSuccess = Notice.chat(commands.configReloadedSuccess()); + config.commands.configReloadFailed = Notice.chat(commands.configReloadFailed()); + config.commands.messagePreviewed = Notice.chat(commands.messagePreviewed()); + config.commands.messageNotFound = Notice.chat(commands.messageNotFound()); + config.commands.viewPlayerOnly = Notice.chat(commands.viewPlayerOnly()); + config.commands.nextHeader = Notice.chat(commands.nextHeader()); + config.commands.nextEntry = Notice.chat(commands.nextEntry()); + config.commands.nextUnpredictable = Notice.chat(commands.nextUnpredictable()); + config.commands.nextDisabled = Notice.chat(commands.nextDisabled()); + config.commands.nextEmpty = Notice.chat(commands.nextEmpty()); + config.commands.statsHeader = Notice.chat(commands.statsHeader()); + config.commands.statsEmpty = Notice.chat(commands.statsEmpty()); + config.commands.statsChannelsHeader = Notice.chat(commands.statsChannelsHeader()); + config.commands.statsChannel = Notice.chat(commands.statsChannel()); + config.commands.statsChannelPending = Notice.chat(commands.statsChannelPending()); + config.commands.statsChannelDisabled = Notice.chat(commands.statsChannelDisabled()); + config.commands.statsChannelEmpty = Notice.chat(commands.statsChannelEmpty()); + config.commands.statsMessagesHeader = Notice.chat(commands.statsMessagesHeader()); + config.commands.statsEntry = Notice.chat(commands.statsEntry()); + config.commands.sendDone = Notice.chat(commands.sendDone()); + config.commands.sendNobodyOnline = Notice.chat(commands.sendNobodyOnline()); + config.commands.sendNoMessages = Notice.chat(commands.sendNoMessages()); + config.commands.sendDisabled = Notice.chat(commands.sendDisabled()); + config.commands.toggleOff = Notice.chat(commands.toggleOff()); + config.commands.toggleOn = Notice.chat(commands.toggleOn()); + config.commands.togglePlayerOnly = Notice.chat(commands.togglePlayerOnly()); + config.commands.channelNotFound = Notice.chat(commands.channelNotFound()); + + config.announcements = announcements(announcements); } - private static Map> announcementsEnglish() { + // The keys here are the ones scheduledMessages.yml ships, and the notice kinds are what each + // example is meant to demonstrate - one of every kind the plugin can render. + private static Map> announcements(Announcements text) { final Map> announcements = new LinkedHashMap<>(); - announcements.put("vote-reminder", List.of( - Notice.chat("[!] Enjoying the server? Vote for us and claim your reward!"), - Notice.sound(Key.key("entity.experience_orb.pickup"), Sound.Source.MASTER, 1.0F, 1.0F) - )); + announcements.put("vote-reminder", List.of( + Notice.chat(text.voteReminder()), + Notice.sound(VOTE_SOUND, Sound.Source.MASTER, 1.0F, 1.0F) + )); - announcements.put("discord-invite", List.of( - Notice.actionbar("[!] Join our Discord: discord.gg/example") - )); + announcements.put("discord-invite", List.of( + Notice.actionBar(text.discordInvite()) + )); - announcements.put("server-status", List.of( - Notice.chat("[!] There are {ONLINE}/{MAX_PLAYERS} players online right now.") - )); + announcements.put("server-status", List.of( + Notice.chat(text.serverStatus()) + )); - announcements.put("event-announcement", List.of( - Notice.title("EVENT", "Starting at the arena in 5 minutes!") - )); + announcements.put("event-announcement", List.of( + Notice.title(EVENT_TITLE, text.eventSubtitle()) + )); - announcements.put("restart-warning", List.of( - Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(5), "The server restarts every night at 04:00") - )); + announcements.put("restart-warning", List.of( + Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(5), text.restartWarning()) + )); - announcements.put("shop-advert", List.of( - Notice.chat("[!] Support the server at shop.example.com"), - Notice.actionbar("shop.example.com") - )); + announcements.put("shop-advert", List.of( + Notice.chat(text.shopAdvert()), + Notice.actionBar(SHOP_URL) + )); - announcements.put("vip-perk-reminder", List.of( - Notice.chat("[!] VIP tip: use /kit vip once every 12 hours.") - )); + announcements.put("vip-perk-reminder", List.of( + Notice.chat(text.vipPerkReminder()) + )); - announcements.put("newcomer-tip", List.of( - Notice.chat("[!] New here? Type /help to get started.") - )); + announcements.put("newcomer-tip", List.of( + Notice.chat(text.newcomerTip()) + )); - announcements.put("first-join-welcome", List.of( - Notice.chat("[!] Welcome to the server, {PLAYER}! You are player number {ONLINE} online.") - )); + announcements.put("first-join-welcome", List.of( + Notice.chat(text.firstJoinWelcome()) + )); return announcements; } - private static Map> announcementsPolish() { - final Map> announcements = new LinkedHashMap<>(); - - announcements.put("vote-reminder", List.of( - Notice.chat("[!] Podoba Ci się serwer? Zagłosuj i odbierz nagrodę!"), - Notice.sound(Key.key("entity.experience_orb.pickup"), Sound.Source.MASTER, 1.0F, 1.0F) - )); - - announcements.put("discord-invite", List.of( - Notice.actionbar("[!] Dołącz na nasz Discord: discord.gg/example") - )); - - announcements.put("server-status", List.of( - Notice.chat("[!] Aktualnie online: {ONLINE}/{MAX_PLAYERS} graczy.") - )); - - announcements.put("event-announcement", List.of( - Notice.title("EVENT", "Start na arenie za 5 minut!") - )); - - announcements.put("restart-warning", List.of( - Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(5), "Serwer restartuje się codziennie o 04:00") - )); - - announcements.put("shop-advert", List.of( - Notice.chat("[!] Wesprzyj serwer na shop.example.com"), - Notice.actionbar("shop.example.com") - )); - - announcements.put("vip-perk-reminder", List.of( - Notice.chat("[!] Wskazówka VIP: użyj /kit vip raz na 12 godzin.") - )); - - announcements.put("newcomer-tip", List.of( - Notice.chat("[!] Nowy? Wpisz /help, aby zacząć.") - )); - - announcements.put("first-join-welcome", List.of( - Notice.chat("[!] Witaj na serwerze, {PLAYER}! Jesteś {ONLINE} graczem online.") - )); - - return announcements; + private record Commands( + String permissionMissing, + String usageInvalid, + String usageHeader, + String usageEntry, + String dispatcherEnabled, + String dispatcherAlreadyEnabled, + String dispatcherDisabled, + String dispatcherAlreadyDisabled, + String configReloadedSuccess, + String configReloadFailed, + String messagePreviewed, + String messageNotFound, + String viewPlayerOnly, + String nextHeader, + String nextEntry, + String nextUnpredictable, + String nextDisabled, + String nextEmpty, + String statsHeader, + String statsEmpty, + String statsChannelsHeader, + String statsChannel, + String statsChannelPending, + String statsChannelDisabled, + String statsChannelEmpty, + String statsMessagesHeader, + String statsEntry, + String sendDone, + String sendNobodyOnline, + String sendNoMessages, + String sendDisabled, + String toggleOff, + String toggleOn, + String togglePlayerOnly, + String channelNotFound + ) { } - private static Map> announcementsGerman() { - final Map> announcements = new LinkedHashMap<>(); - - announcements.put("vote-reminder", List.of( - Notice.chat("[!] Gefällt dir der Server? Stimme für uns und hol dir deine Belohnung!"), - Notice.sound(Key.key("entity.experience_orb.pickup"), Sound.Source.MASTER, 1.0F, 1.0F) - )); - - announcements.put("discord-invite", List.of( - Notice.actionbar("[!] Tritt unserem Discord bei: discord.gg/example") - )); - - announcements.put("server-status", List.of( - Notice.chat("[!] Aktuell online: {ONLINE}/{MAX_PLAYERS} Spieler.") - )); - - announcements.put("event-announcement", List.of( - Notice.title("EVENT", "Start in der Arena in 5 Minuten!") - )); - - announcements.put("restart-warning", List.of( - Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(5), "Der Server startet täglich um 04:00 Uhr neu") - )); - - announcements.put("shop-advert", List.of( - Notice.chat("[!] Unterstütze den Server auf shop.example.com"), - Notice.actionbar("shop.example.com") - )); - - announcements.put("vip-perk-reminder", List.of( - Notice.chat("[!] VIP-Tipp: Nutze /kit vip alle 12 Stunden.") - )); - - announcements.put("newcomer-tip", List.of( - Notice.chat("[!] Neu hier? Tippe /help, um zu starten.") - )); - - announcements.put("first-join-welcome", List.of( - Notice.chat("[!] Willkommen auf dem Server, {PLAYER}! Du bist Spieler Nummer {ONLINE} online.") - )); - - return announcements; + private record Announcements( + String voteReminder, + String discordInvite, + String serverStatus, + String eventSubtitle, + String restartWarning, + String shopAdvert, + String vipPerkReminder, + String newcomerTip, + String firstJoinWelcome + ) { } + + private static final Announcements ENGLISH_ANNOUNCEMENTS = new Announcements( + "[!] Enjoying the server? Vote for us and claim your reward!", + "[!] Join our Discord: discord.gg/example", + "[!] There are {ONLINE}/{MAX_PLAYERS} players online right now.", + "Starting at the arena in 5 minutes!", + "The server restarts every night at 04:00", + "[!] Support the server at shop.example.com", + "[!] VIP tip: use /kit vip once every 12 hours.", + "[!] New here? Type /help to get started.", + "[!] Welcome to the server, {PLAYER}! You are player number {ONLINE} online." + ); + + private static final Commands POLISH_COMMANDS = new Commands( + "Nie masz wymaganych uprawnień {PERMISSIONS}, aby użyć tej komendy.", + "Niepoprawne użycie komendy! Poprawna składnia: {USAGE}.", + "Poprawne warianty użycia:", + "{USAGE}", + "Automatyczne wiadomości zostały włączone.", + "Automatyczne wiadomości są już włączone.", + "Automatyczne wiadomości zostały wyłączone.", + "Automatyczne wiadomości są już wyłączone.", + "Konfiguracja AutoMessage została przeładowana pomyślnie.", + "Nie udało się przeładować plików konfiguracyjnych AutoMessage. Wyłącz plugin i sprawdź swoją konfigurację.", + "Podgląd zaplanowanej wiadomości {MESSAGE}.", + "Wiadomość o nazwie {MESSAGE} nie istnieje w scheduledMessages.yml.", + "Tylko gracze mogą oglądać podgląd zaplanowanych wiadomości.", + "» Następna na każdym kanale", + "{CHANNEL} · {MESSAGE} · za {DELAY}", + "{CHANNEL} · losowana · za {DELAY}", + "{CHANNEL} · wyłączony w config.yml", + "{CHANNEL} · brak przypisanych wiadomości", + "» AutoMessage · ogłoszeń: {TOTAL} od uruchomienia", + "» AutoMessage · nic jeszcze nie ogłoszono", + "» Kanały", + "{CHANNEL} · {COUNT}x · następna za {DELAY}", + "{CHANNEL} · jeszcze nic · następna za {DELAY}", + "{CHANNEL} · {COUNT}x · " + + "wyłączony w config.yml", + "{CHANNEL} · {COUNT}x · " + + "brak przypisanych wiadomości", + "» Wiadomości", + "{MESSAGE} ({CHANNEL}) · " + + "{COUNT}x · {AGO} temu", + "Wysłano {MESSAGE} na kanale {CHANNEL}. " + + "Następna za {DELAY}.", + "Nikt nie jest online, więc nic nie wysłano na kanale {CHANNEL}. " + + "Harmonogram pozostał bez zmian.", + "Kanał {CHANNEL} nie ma przypisanych żadnych wiadomości.", + "Kanał {CHANNEL} jest wyłączony w config.yml.", + "Ogłoszenia są teraz dla Ciebie wyłączone", + "Ogłoszenia są znów dla Ciebie włączone", + "Tylko gracze mogą wyłączyć ogłoszenia", + "Kanał o nazwie {CHANNEL} nie istnieje w config.yml." + ); + + private static final Announcements POLISH_ANNOUNCEMENTS = new Announcements( + "[!] Podoba Ci się serwer? Zagłosuj i odbierz nagrodę!", + "[!] Dołącz na nasz Discord: discord.gg/example", + "[!] Aktualnie online: {ONLINE}/{MAX_PLAYERS} graczy.", + "Start na arenie za 5 minut!", + "Serwer restartuje się codziennie o 04:00", + "[!] Wesprzyj serwer na shop.example.com", + "[!] Wskazówka VIP: użyj /kit vip raz na 12 godzin.", + "[!] Nowy? Wpisz /help, aby zacząć.", + "[!] Witaj na serwerze, {PLAYER}! Jesteś {ONLINE} graczem online." + ); + + private static final Commands GERMAN_COMMANDS = new Commands( + "Dir fehlen die nötigen Rechte {PERMISSIONS}, um diesen Befehl zu nutzen.", + "Falsche Verwendung! Richtige Syntax: {USAGE}.", + "Mögliche Verwendungen:", + "{USAGE}", + "Automatische Nachrichten wurden aktiviert.", + "Automatische Nachrichten sind bereits aktiviert.", + "Automatische Nachrichten wurden deaktiviert.", + "Automatische Nachrichten sind bereits deaktiviert.", + "Die AutoMessage-Konfiguration wurde erfolgreich neu geladen.", + "Die AutoMessage-Konfigurationsdateien konnten nicht neu geladen werden. Deaktiviere das Plugin und prüfe deine Konfiguration.", + "Vorschau der geplanten Nachricht {MESSAGE}.", + "Es gibt keine geplante Nachricht namens {MESSAGE} in scheduledMessages.yml.", + "Nur Spieler können geplante Nachrichten in der Vorschau ansehen.", + "» Nächste pro Kanal", + "{CHANNEL} · {MESSAGE} · in {DELAY}", + "{CHANNEL} · zufällig gezogen · in {DELAY}", + "{CHANNEL} · in config.yml deaktiviert", + "{CHANNEL} · keine Nachrichten zugewiesen", + "» AutoMessage · Ankündigungen: {TOTAL} seit dem Start", + "» AutoMessage · noch nichts angekündigt", + "» Kanäle", + "{CHANNEL} · {COUNT}x · nächste in {DELAY}", + "{CHANNEL} · noch nichts · nächste in {DELAY}", + "{CHANNEL} · {COUNT}x · " + + "in config.yml deaktiviert", + "{CHANNEL} · {COUNT}x · " + + "keine Nachrichten zugewiesen", + "» Nachrichten", + "{MESSAGE} ({CHANNEL}) · " + + "{COUNT}x · vor {AGO}", + "Hat {MESSAGE} auf {CHANNEL} gesendet. " + + "Die nächste in {DELAY}.", + "Niemand ist online, daher wurde auf {CHANNEL} nichts gesendet. " + + "Der Zeitplan bleibt unberührt.", + "Dem Kanal {CHANNEL} sind keine Nachrichten zugewiesen.", + "Kanal {CHANNEL} ist in config.yml deaktiviert.", + "Ankündigungen sind für dich jetzt aus", + "Ankündigungen sind für dich wieder an", + "Nur Spieler können Ankündigungen abschalten", + "Es gibt keinen Kanal namens {CHANNEL} in config.yml." + ); + + private static final Announcements GERMAN_ANNOUNCEMENTS = new Announcements( + "[!] Gefällt dir der Server? Stimme für uns und hol dir deine Belohnung!", + "[!] Tritt unserem Discord bei: discord.gg/example", + "[!] Aktuell online: {ONLINE}/{MAX_PLAYERS} Spieler.", + "Start in der Arena in 5 Minuten!", + "Der Server startet täglich um 04:00 Uhr neu", + "[!] Unterstütze den Server auf shop.example.com", + "[!] VIP-Tipp: Nutze /kit vip alle 12 Stunden.", + "[!] Neu hier? Tippe /help, um zu starten.", + "[!] Willkommen auf dem Server, {PLAYER}! Du bist Spieler Nummer {ONLINE} online." + ); } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/message/AudienceGroup.java b/automessage-core/src/main/java/com/github/imdmk/automessage/message/AudienceGroup.java new file mode 100644 index 0000000..c1ea344 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/message/AudienceGroup.java @@ -0,0 +1,37 @@ +package com.github.imdmk.automessage.message; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.audience.ForwardingAudience; +import net.kyori.adventure.title.Title; + +import java.util.Collection; + +final class AudienceGroup implements ForwardingAudience { + + private final Collection members; + + private AudienceGroup(Collection members) { + this.members = members; + } + + static Audience of(Collection members) { + // One recipient is not a group, and wrapping it would only add a hop between the + // renderer and the player. + return members.size() == 1 ? members.iterator().next() : new AudienceGroup(members); + } + + @Override + public Iterable audiences() { + return members; + } + + // Adventure's own forwarding takes showTitle apart into three sendTitlePart calls, which an + // audience that implements showTitle and not the parts never sees - its titles would simply + // stop arriving. Forwarded whole, a group behaves exactly like sending to each in turn. + @Override + public void showTitle(Title title) { + for (final Audience member : members) { + member.showTitle(title); + } + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/message/MessageBroadcast.java b/automessage-core/src/main/java/com/github/imdmk/automessage/message/MessageBroadcast.java new file mode 100644 index 0000000..9f02874 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/message/MessageBroadcast.java @@ -0,0 +1,56 @@ +package com.github.imdmk.automessage.message; + +import com.github.imdmk.automessage.language.LanguageConfig; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.platform.viewer.Viewer; + +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; + +public final class MessageBroadcast { + + private final MessageService service; + private final Map placeholders = new LinkedHashMap<>(); + + private Viewer viewer; + private Function message; + + MessageBroadcast(MessageService service) { + this.service = service; + } + + public MessageBroadcast viewer(Viewer viewer) { + this.viewer = viewer; + return this; + } + + public MessageBroadcast notice(Function message) { + this.message = message; + return this; + } + + public MessageBroadcast notice(Notice notice) { + this.message = language -> notice; + return this; + } + + public MessageBroadcast placeholder(String key, String value) { + this.placeholders.put(key, value); + return this; + } + + public void send() { + Objects.requireNonNull(viewer, "viewer"); + Objects.requireNonNull(message, "message"); + + final Notice notice = message.apply(service.languageOf(viewer)); + + if (notice == null) { + return; + } + + service.render(viewer, notice, TextSubstitution.of(placeholders)); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/message/MessageService.java b/automessage-core/src/main/java/com/github/imdmk/automessage/message/MessageService.java index d469c4c..bfb04af 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/message/MessageService.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/message/MessageService.java @@ -1,64 +1,49 @@ package com.github.imdmk.automessage.message; -import com.eternalcode.multification.adventure.AudienceConverter; -import com.eternalcode.multification.bukkit.BukkitMultification; -import com.eternalcode.multification.notice.provider.NoticeProvider; -import com.eternalcode.multification.translation.TranslationProvider; +import java.util.Collection; +import net.kyori.adventure.audience.Audience; import com.github.imdmk.automessage.language.LanguageConfig; import com.github.imdmk.automessage.language.LanguageRegistry; -import net.kyori.adventure.platform.AudienceProvider; -import net.kyori.adventure.platform.bukkit.BukkitAudiences; -import net.kyori.adventure.text.Component; -import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.serializer.ComponentSerializer; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; -import org.bukkit.plugin.Plugin; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.notice.NoticeDelayer; +import com.github.imdmk.automessage.notice.NoticeRenderer; +import com.github.imdmk.automessage.platform.viewer.Viewer; -public final class MessageService extends BukkitMultification { +import java.util.function.Function; +import java.util.function.UnaryOperator; - private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); +public final class MessageService { private final LanguageRegistry languages; - private final AudienceProvider audienceProvider; + private final NoticeRenderer renderer; - public MessageService( - LanguageRegistry languages, - Plugin plugin - ) { + public MessageService(LanguageRegistry languages, NoticeDelayer delayer) { this.languages = languages; - this.audienceProvider = BukkitAudiences.create(plugin); + this.renderer = NoticeRenderer.miniMessage(delayer); } - /** - * Multification hands the viewer's locale here, which is what lets two players in the same - * chat read the same reply in different languages. - */ - @Override - protected TranslationProvider translationProvider() { - return languages::provide; + public MessageBroadcast create() { + return new MessageBroadcast(this); } - @Override - protected ComponentSerializer serializer() { - return MINI_MESSAGE; + public void send(Viewer viewer, Function message) { + create().viewer(viewer).notice(message).send(); } - @Override - protected AudienceConverter audienceConverter() { - return sender -> { - if (sender instanceof Player player) { - return audienceProvider.player(player.getUniqueId()); - } - return audienceProvider.console(); - }; + void render(Viewer viewer, Notice notice, UnaryOperator placeholders) { + renderer.render(notice, viewer.audience(), placeholders); } - public void send(CommandSender sender, NoticeProvider notice) { - create().viewer(sender).notice(notice).send(); + // One parse for however many players are listening, rather than one parse per player. + public void render(Collection audiences, Notice notice, UnaryOperator placeholders) { + if (audiences.isEmpty()) { + return; + } + + renderer.render(notice, AudienceGroup.of(audiences), placeholders); } - public void shutdown() { - audienceProvider.close(); + LanguageConfig languageOf(Viewer viewer) { + return languages.provide(viewer.locale()); } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/message/TextSubstitution.java b/automessage-core/src/main/java/com/github/imdmk/automessage/message/TextSubstitution.java new file mode 100644 index 0000000..138a600 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/message/TextSubstitution.java @@ -0,0 +1,25 @@ +package com.github.imdmk.automessage.message; + +import java.util.Map; +import java.util.function.UnaryOperator; + +public final class TextSubstitution { + + private TextSubstitution() { + } + + // Handed to the renderer rather than applied to the text first, because a notice is several + // strings and only the renderer knows which of them a given part is made of. + public static UnaryOperator of(Map placeholders) { + if (placeholders.isEmpty()) { + return UnaryOperator.identity(); + } + return text -> { + String substituted = text; + for (final Map.Entry placeholder : placeholders.entrySet()) { + substituted = substituted.replace(placeholder.getKey(), placeholder.getValue()); + } + return substituted; + }; + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordMessageRenderer.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordMessageRenderer.java index 9fbcdc3..7211e65 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordMessageRenderer.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordMessageRenderer.java @@ -1,8 +1,6 @@ package com.github.imdmk.automessage.platform.discord; -import com.eternalcode.multification.notice.Notice; -import com.eternalcode.multification.notice.NoticePart; -import com.eternalcode.multification.notice.resolver.chat.ChatContent; +import com.github.imdmk.automessage.notice.Notice; import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; @@ -10,22 +8,6 @@ import java.util.List; import java.util.Map; -/** - * Renders a scheduled message into something worth posting in Discord. - * - *

- * Only the chat lines are taken. A title, an action bar, a boss bar or a sound has no counterpart - * in a text channel, and mirroring them would post the same sentence two or three times whenever a - * message uses several channels to say one thing. - *

- * - *

- * The fallback language is used rather than any translation: a Discord channel has no single - * reader whose client language could be consulted. Placeholders describing the server are substituted - * normally; the ones describing a viewer have no value here and are removed rather than posted as - * raw tokens. - *

- */ public final class DiscordMessageRenderer { private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); @@ -35,26 +17,17 @@ private DiscordMessageRenderer() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * @param notices the announcement in the fallback language - * @param placeholders values to substitute, already resolved for a reader-less destination - * @return the message as plain text, or empty when it has nothing a text channel can show - */ public static String render(List notices, Map placeholders) { final List lines = new ArrayList<>(); for (final Notice notice : notices) { - for (final NoticePart part : notice.parts()) { - if (part.content() instanceof ChatContent chat) { - for (final String line : chat.contents()) { - // Substituted after flattening: the tokens are plain text either way, and - // a value containing a MiniMessage tag must not be parsed as markup. - final String plain = substitute(toPlainText(line), placeholders).strip(); + for (final String line : notice.chatTexts()) { + // Substituted after flattening: the tokens are plain text either way, and a value + // containing a MiniMessage tag must not be parsed as markup. + final String plain = substitute(toPlainText(line), placeholders).strip(); - if (!plain.isBlank()) { - lines.add(plain); - } - } + if (!plain.isBlank()) { + lines.add(plain); } } } @@ -62,10 +35,6 @@ public static String render(List notices, Map placeholde return String.join("\n", lines); } - /** - * Gradients, hover text and click actions mean nothing in Discord, so the MiniMessage is - * parsed and then flattened to the words a reader actually sees. - */ static String toPlainText(String miniMessage) { return PLAIN_TEXT.serialize(MINI_MESSAGE.deserialize(miniMessage)); } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordPayload.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordPayload.java index 6b626a2..15d31b5 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordPayload.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordPayload.java @@ -1,17 +1,7 @@ package com.github.imdmk.automessage.platform.discord; -/** - * Builds the JSON body of a webhook request. - * - *

- * Written by hand rather than with a JSON library: the payload has three string fields, and the - * plugin would otherwise shade a dependency for them. Escaping is the part that matters, since the - * content is arbitrary text taken from a configuration file. - *

- */ public final class DiscordPayload { - /** Discord rejects a message body longer than this. */ public static final int CONTENT_LIMIT = 2000; private DiscordPayload() { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookClient.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookClient.java index 6380b5b..cbc06f1 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookClient.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookClient.java @@ -9,20 +9,11 @@ import java.time.Duration; import java.util.concurrent.atomic.AtomicLong; -/** - * Posts a message body to a Discord webhook. - * - *

- * Every request is asynchronous. The main thread must never wait on a network round trip, and even - * the plugin's async pool should not sit blocked on a Discord outage. - *

- */ public final class DiscordWebhookClient { private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(10); - /** How long to stay quiet after Discord asks us to slow down, when it gives no figure. */ private static final long DEFAULT_BACKOFF_MILLIS = 60_000L; private static final int TOO_MANY_REQUESTS = 429; @@ -31,14 +22,6 @@ public final class DiscordWebhookClient { private final HttpClient httpClient; private final URI endpoint; - /** - * Wall-clock time before which no request is attempted. - * - *

- * Discord answers a flood with 429 and a retry hint. Ignoring it earns a longer ban for the - * webhook, so a rejected request silences the rest until the window passes. - *

- */ private final AtomicLong retryAfter = new AtomicLong(0L); public DiscordWebhookClient(PluginLogger logger, URI endpoint) { @@ -77,7 +60,9 @@ private void handle(int statusCode, HttpResponse response, long now) { .map(DiscordWebhookClient::parseSeconds) .orElse(DEFAULT_BACKOFF_MILLIS); - retryAfter.set(now + backoff); + // Never backwards: two responses can land out of order, and the later-expiring + // window is the one Discord actually asked for. + retryAfter.accumulateAndGet(now + backoff, Math::max); logger.warn( "Discord asked AutoMessage to slow down; mirroring is paused for %d seconds.", @@ -92,18 +77,8 @@ private void handle(int statusCode, HttpResponse response, long now) { } } - /** - * Releases the client's selector thread and executor. - * - *

- * HttpClient is AutoCloseable and keeps threads of its own. They outlive a plugin disable - * otherwise, and because they were created under the plugin's class loader they keep it - and - * everything it loaded - from ever being collected. That is the classic leak behind a server - * that runs out of memory after a few /reload cycles. - *

- */ public void close() { - httpClient.close(); + httpClient.shutdownNow(); } private static long parseSeconds(String value) { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookService.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookService.java index 4439202..c77dff0 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookService.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookService.java @@ -1,50 +1,38 @@ package com.github.imdmk.automessage.platform.discord; -import com.eternalcode.multification.notice.Notice; +import com.github.imdmk.automessage.notice.Notice; import com.github.imdmk.automessage.language.LanguageRegistry; import com.github.imdmk.automessage.platform.logger.PluginLogger; import com.github.imdmk.automessage.scheduled.ScheduledMessage; import com.github.imdmk.automessage.scheduled.dispatcher.DispatchObserver; import com.github.imdmk.automessage.scheduled.placeholder.MessagePlaceholders; -import org.bukkit.Server; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; import java.net.URI; import java.util.List; import java.util.Optional; -/** - * Mirrors each dispatched announcement to a Discord channel. - * - *

- * Observes the dispatcher rather than sitting inside it, so a Discord outage cannot affect what - * players on the server receive. - *

- */ public final class DiscordWebhookService implements DispatchObserver { - private final Server server; + private final ViewerRegistry viewers; private final LanguageRegistry languages; private final DiscordWebhookConfig config; private final DiscordWebhookClient client; private DiscordWebhookService( - Server server, + ViewerRegistry viewers, LanguageRegistry languages, DiscordWebhookConfig config, DiscordWebhookClient client ) { - this.server = server; + this.viewers = viewers; this.languages = languages; this.config = config; this.client = client; } - /** - * @return a service that posts, or {@link DispatchObserver#none()} when Discord mirroring is - * switched off or configured with a URL that is not a Discord webhook - */ public static DispatchObserver create( - Server server, + ViewerRegistry viewers, LanguageRegistry languages, PluginLogger logger, DiscordWebhookConfig config @@ -64,7 +52,7 @@ public static DispatchObserver create( } logger.info("Discord mirroring is enabled."); - return new DiscordWebhookService(server, languages, config, new DiscordWebhookClient(logger, endpoint.get())); + return new DiscordWebhookService(viewers, languages, config, new DiscordWebhookClient(logger, endpoint.get())); } @Override @@ -77,7 +65,7 @@ public void onDispatched(ScheduledMessage message, MessagePlaceholders placehold final String content = DiscordMessageRenderer.render( notices, - placeholders.resolveWithoutViewer(server) + placeholders.resolveWithoutViewer(viewers) ); // A message made only of a title or a sound has nothing to show in a text channel. diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookUrl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookUrl.java index d925ddc..25aa57f 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookUrl.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookUrl.java @@ -4,15 +4,6 @@ import java.util.Locale; import java.util.Optional; -/** - * Validates that a configured webhook URL really points at Discord. - * - *

- * The URL decides where the server's announcements are sent. A typo in it does not fail loudly — - * it quietly posts to whoever owns the address instead — so it is checked once, on startup, rather - * than trusted. - *

- */ public final class DiscordWebhookUrl { private static final String HTTPS = "https"; @@ -28,9 +19,6 @@ private DiscordWebhookUrl() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * @return the parsed URI, or empty when the value is blank or not a Discord webhook over HTTPS - */ public static Optional parse(String url) { if (url == null || url.isBlank()) { return Optional.empty(); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/UnknownScheduledMessage.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/UnknownScheduledMessage.java deleted file mode 100644 index 739b3bd..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/argument/UnknownScheduledMessage.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.github.imdmk.automessage.platform.litecommands.argument; - -/** - * Result produced when a command argument does not match any configured scheduled message. - * - * @param name the name that was typed - */ -public record UnknownScheduledMessage(String name) { -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/NoticeResultHandlerImpl.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/NoticeResultHandlerImpl.java deleted file mode 100644 index eed61bf..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/litecommands/handler/NoticeResultHandlerImpl.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.github.imdmk.automessage.platform.litecommands.handler; - -import com.eternalcode.multification.notice.Notice; -import com.github.imdmk.automessage.message.MessageService; -import dev.rollczi.litecommands.handler.result.ResultHandler; -import dev.rollczi.litecommands.handler.result.ResultHandlerChain; -import dev.rollczi.litecommands.invocation.Invocation; -import org.bukkit.command.CommandSender; - -public final class NoticeResultHandlerImpl implements ResultHandler { - - private final MessageService messageService; - - public NoticeResultHandlerImpl(MessageService messageService) { - this.messageService = messageService; - } - - @Override - public void handle( - Invocation invocation, - Notice notice, - ResultHandlerChain chain - ) { - messageService.send(invocation.sender(), n -> notice); - } -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/logger/BukkitPluginLogger.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/logger/BukkitPluginLogger.java deleted file mode 100644 index a5bcd48..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/logger/BukkitPluginLogger.java +++ /dev/null @@ -1,73 +0,0 @@ -package com.github.imdmk.automessage.platform.logger; - -import java.util.Locale; -import java.util.logging.Level; -import java.util.logging.Logger; - -public final class BukkitPluginLogger implements PluginLogger { - - private final Logger logger; - - public BukkitPluginLogger(Logger logger) { - this.logger = logger; - } - - @Override - public void info(String message) { - logger.info(message); - } - - @Override - public void info(String message, Object... args) { - logger.log(Level.INFO, format(message, args)); - } - - @Override - public void warn(String message) { - logger.warning(message); - } - - @Override - public void warn(Throwable throwable) { - logger.log(Level.WARNING, "A warning occurred, see stack trace for details.", throwable); - } - - @Override - public void warn(Throwable throwable, String message, Object... args) { - logger.log(Level.WARNING, format(message, args), throwable); - } - - @Override - public void warn(String message, Object... args) { - logger.log(Level.WARNING, format(message, args)); - } - - @Override - public void error(Throwable throwable) { - logger.log(Level.SEVERE, "An error occurred, see stack trace for details.", throwable); - } - - @Override - public void error(String message) { - logger.log(Level.SEVERE, message); - } - - @Override - public void error(String message, Object... args) { - logger.log(Level.SEVERE, format(message, args)); - } - - @Override - public void error(Throwable throwable, String message) { - logger.log(Level.SEVERE, message, throwable); - } - - @Override - public void error(Throwable throwable, String message, Object... args) { - logger.log(Level.SEVERE, format(message, args), throwable); - } - - private static String format(String message, Object... args) { - return String.format(Locale.ROOT, message, args); - } -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java deleted file mode 100644 index 93ae8ab..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/logger/PluginLogger.java +++ /dev/null @@ -1,20 +0,0 @@ -package com.github.imdmk.automessage.platform.logger; - -import org.intellij.lang.annotations.PrintFormat; - -public interface PluginLogger { - - void info(String message); - void info(@PrintFormat String message, Object... args); - - void warn(String message); - void warn(@PrintFormat String message, Object... args); - void warn(Throwable throwable); - void warn(Throwable throwable, @PrintFormat String message, Object... args); - - void error(String message); - void error(@PrintFormat String message, Object... args); - void error(Throwable throwable); - void error(Throwable throwable, String message); - void error(Throwable throwable, @PrintFormat String message, Object... args); -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java deleted file mode 100644 index 98b330d..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/ExternalPlaceholderResolver.java +++ /dev/null @@ -1,49 +0,0 @@ -package com.github.imdmk.automessage.platform.placeholder; - -import org.bukkit.entity.Player; - -/** - * Resolves the {@code %...%} tokens AutoMessage does not own itself. - */ -public interface ExternalPlaceholderResolver { - - /** - * @return the resolved value, or the token itself when nothing can resolve it - */ - String resolve(Player viewer, String token); - - /** - * Resolves a token for a destination with no single reader. - * - *

- * Some expansions describe the server rather than a player and answer perfectly well without - * one; the rest cannot, and say so by handing the token back unchanged. - *

- * - * @return the resolved value, or the token itself when it needs a viewer - */ - String resolveWithoutViewer(String token); - - boolean available(); - - /** Used when PlaceholderAPI is not installed; leaves every token exactly as written. */ - static ExternalPlaceholderResolver disabled() { - return new ExternalPlaceholderResolver() { - - @Override - public String resolve(Player viewer, String token) { - return token; - } - - @Override - public String resolveWithoutViewer(String token) { - return token; - } - - @Override - public boolean available() { - return false; - } - }; - } -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/PapiPlaceholderResolver.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/PapiPlaceholderResolver.java deleted file mode 100644 index 8a7767a..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/placeholder/PapiPlaceholderResolver.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.github.imdmk.automessage.platform.placeholder; - -import me.clip.placeholderapi.PlaceholderAPI; -import org.bukkit.entity.Player; - -/** - * Bridges to PlaceholderAPI. - * - *

- * PlaceholderAPI is a soft dependency: this class is only ever loaded once - * {@link ExternalPlaceholderResolverFactory} has seen the plugin enabled, so a server without it - * never touches the {@code me.clip} classes and never sees a NoClassDefFoundError. - *

- */ -final class PapiPlaceholderResolver implements ExternalPlaceholderResolver { - - @Override - public String resolve(Player viewer, String token) { - return PlaceholderAPI.setPlaceholders(viewer, token); - } - - @Override - public String resolveWithoutViewer(String token) { - // PlaceholderAPI accepts a null player and server-scoped expansions answer anyway, but - // third-party expansions are written by other people and a player-scoped one may well - // dereference it. A broken expansion must not take an announcement down with it. - try { - return PlaceholderAPI.setPlaceholders((Player) null, token); - } catch (RuntimeException e) { - return token; - } - } - - @Override - public boolean available() { - return true; - } -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/BukkitTaskScheduler.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/BukkitTaskScheduler.java deleted file mode 100644 index 6dc04b6..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/BukkitTaskScheduler.java +++ /dev/null @@ -1,94 +0,0 @@ -package com.github.imdmk.automessage.platform.scheduler; - -import org.bukkit.plugin.Plugin; -import org.bukkit.scheduler.BukkitScheduler; -import org.bukkit.scheduler.BukkitTask; - -import java.time.Duration; - -public final class BukkitTaskScheduler implements TaskScheduler { - - private static final long MILLIS_PER_TICK = 50L; - - private final Plugin plugin; - private final BukkitScheduler scheduler; - - public BukkitTaskScheduler(Plugin plugin, BukkitScheduler scheduler) { - this.plugin = plugin; - this.scheduler = scheduler; - } - - @Override - public BukkitTask runSync(Runnable runnable) { - return scheduler.runTask(plugin, runnable); - } - - @Override - public BukkitTask runAsync(Runnable runnable) { - return scheduler.runTaskAsynchronously(plugin, runnable); - } - - @Override - public BukkitTask runLaterAsync(Runnable runnable, Duration delay) { - return scheduler.runTaskLaterAsynchronously(plugin, runnable, toDelayTicks(delay)); - } - - @Override - public BukkitTask runLaterSync(Runnable runnable, Duration delay) { - return scheduler.runTaskLater(plugin, runnable, toDelayTicks(delay)); - } - - @Override - public BukkitTask runTimerSync( - Runnable runnable, - Duration delay, - Duration period - ) { - return scheduler.runTaskTimer(plugin, runnable, toDelayTicks(delay), toPeriodTicks(period)); - } - - @Override - public BukkitTask runTimerSync(PluginTask task) { - return runTimerSync(task, task.delay(), task.period()); - } - - @Override - public BukkitTask runTimerAsync( - Runnable runnable, - Duration delay, - Duration period - ) { - return scheduler.runTaskTimerAsynchronously(plugin, runnable, toDelayTicks(delay), toPeriodTicks(period)); - } - - @Override - public BukkitTask runTimerAsync(PluginTask task) { - return runTimerAsync(task, task.delay(), task.period()); - } - - @Override - public void cancelTask(int taskId) { - scheduler.cancelTask(taskId); - } - - @Override - public void shutdown() { - scheduler.cancelTasks(plugin); - } - - private static long toDelayTicks(Duration duration) { - return Math.max(0L, toTicks(duration)); - } - - /** - * Bukkit treats a period of zero ticks as "every tick", which would turn a misconfigured - * interval into a flood of messages. One tick is the shortest period the server can honour. - */ - private static long toPeriodTicks(Duration duration) { - return Math.max(1L, toTicks(duration)); - } - - private static long toTicks(Duration duration) { - return duration.toMillis() / MILLIS_PER_TICK; - } -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java b/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java deleted file mode 100644 index bbd23cc..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/scheduler/TaskScheduler.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.github.imdmk.automessage.platform.scheduler; - -import org.bukkit.scheduler.BukkitTask; - -import java.time.Duration; - -public interface TaskScheduler { - - BukkitTask runSync(Runnable runnable); - BukkitTask runAsync(Runnable runnable); - - BukkitTask runLaterAsync(Runnable runnable, Duration delay); - BukkitTask runLaterSync(Runnable runnable, Duration delay); - - BukkitTask runTimerSync(Runnable runnable, Duration delay, Duration period); - BukkitTask runTimerSync(PluginTask task); - - BukkitTask runTimerAsync(Runnable runnable, Duration delay, Duration period); - BukkitTask runTimerAsync(PluginTask task); - - void cancelTask(int taskId); - void shutdown(); - -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/CapabilityRequirements.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/CapabilityRequirements.java new file mode 100644 index 0000000..c646772 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/CapabilityRequirements.java @@ -0,0 +1,58 @@ +package com.github.imdmk.automessage.scheduled; + +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceAnyOfRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceGroupRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceNoneOfRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceNotRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudiencePermissionRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudiencePlayTimeRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudiencePlayerCountRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceRule; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceWorldRule; +import com.github.imdmk.automessage.scheduled.trigger.JoinTrigger; + +import java.util.EnumSet; +import java.util.Set; + +public final class CapabilityRequirements { + + private CapabilityRequirements() { + } + + public static boolean satisfiedBy(ScheduledMessage message, Capabilities capabilities) { + return requiredBy(message).stream().allMatch(capabilities::supports); + } + + public static Set requiredBy(ScheduledMessage message) { + final Set required = EnumSet.noneOf(Capability.class); + + message.rules().forEach(rule -> collect(rule, required)); + + // Only the first-join variant needs anything special: a plain join is something every + // platform can see happen. + if (message.trigger() instanceof JoinTrigger join && join.firstJoinOnly()) { + required.add(Capability.FIRST_JOIN_TRIGGER); + } + + return required; + } + + private static void collect(AudienceRule rule, Set required) { + switch (rule) { + case AudienceWorldRule ignored -> required.add(Capability.WORLD_RULE); + case AudiencePlayTimeRule ignored -> required.add(Capability.PLAYTIME_RULE); + case AudienceGroupRule ignored -> required.add(Capability.GROUP_RULE); + case AudiencePermissionRule ignored -> required.add(Capability.PERMISSION_RULE); + + // The online count is something every platform with players can answer. + case AudiencePlayerCountRule ignored -> { + } + + case AudienceAnyOfRule anyOf -> anyOf.rules().forEach(nested -> collect(nested, required)); + case AudienceNoneOfRule noneOf -> noneOf.rules().forEach(nested -> collect(nested, required)); + case AudienceNotRule not -> collect(not.rule(), required); + } + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ConfigScheduledMessageRepository.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ConfigScheduledMessageRepository.java index 956e254..3d9891b 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ConfigScheduledMessageRepository.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ConfigScheduledMessageRepository.java @@ -10,29 +10,14 @@ import java.util.Map; import java.util.Optional; -/** - * {@link ScheduledMessageRepository} reading from {@link ScheduledMessagesConfig}. - * - *

- * The configuration only changes on reload, so the views callers ask for are derived once and - * kept until then. That matters because these are not cold paths any more: every player join asks - * for the join triggers, the first-join triggers and the player-count triggers, and every quit - * asks again. Deriving each of those from scratch meant three full copies of the message list and - * three stream pipelines per login, on the main thread. - *

- */ final class ConfigScheduledMessageRepository implements ScheduledMessageRepository, ConfigReloadListener { private final ScheduledMessagesConfig config; - /** - * Derived views, replaced wholesale on reload. - * - *

- * Volatile rather than synchronized: this is read from the main thread and from the async - * reload task, and a reader either sees the old index or the new one, never a half-built one. - *

- */ + // Volatile rather than synchronized: read from the dispatch thread and replaced from the + // async reload task, and a reader sees either the old index or the new one, never a half-built + // one. The views are derived once because every join asks for three of them, on the main + // thread. private volatile Index index; ConfigScheduledMessageRepository(ScheduledMessagesConfig config) { @@ -81,11 +66,9 @@ public void onConfigReload() { this.index = null; } - /** - * The list instance is compared as well as the cached index: a reload that bypasses - * {@link #onConfigReload} still replaces {@code config.messages}, and serving the previous - * index then would announce messages the administrator has already deleted. - */ + // The list instance is compared as well as the cached index: a reload that bypasses + // onConfigReload still replaces config.messages, and serving the previous index then would + // announce messages the administrator has already deleted. private Index index() { final List messages = config.messages; final Index current = this.index; @@ -104,11 +87,6 @@ private static String normalize(String name) { return name.toLowerCase(Locale.ROOT); } - /** - * Every view of the message list, derived together so they cannot disagree. - * - * @param source the {@code config.messages} instance this was derived from - */ private record Index( List source, List all, diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessage.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessage.java index 96d2208..601b8ee 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessage.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessage.java @@ -16,14 +16,6 @@ public record ScheduledMessage( String channel, @Nullable MessageTrigger trigger) { - /** - * Relative weight of a message that does not configure one. - * - *

- * Every message sharing the default is what makes {@code WEIGHTED} behave exactly like - * {@code RANDOM} until somebody actually tunes a weight. - *

- */ public static final int DEFAULT_WEIGHT = 1; public ScheduledMessage { @@ -40,10 +32,6 @@ public record ScheduledMessage( rules = List.copyOf(rules); } - /** - * A message that configures nothing weighs the default, joins the default channel and takes - * part in the timed rotation. - */ public ScheduledMessage(String name, List rules) { this(name, rules, DEFAULT_WEIGHT, AnnouncementChannel.DEFAULT_NAME, null); } @@ -52,10 +40,6 @@ public boolean belongsTo(AnnouncementChannel announcementChannel) { return announcementChannel.matches(channel); } - /** - * A triggered message fires on its event and never through the rotation; leaving it in both - * would announce it at random moments as well as the one it was written for. - */ public boolean isScheduled() { return trigger == null; } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepository.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepository.java index c4154bf..dddb025 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepository.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepository.java @@ -8,72 +8,25 @@ import java.util.List; import java.util.Optional; -/** - * Read access to the scheduled messages currently configured in {@code scheduledMessages.yml}. - * - *

- * Implementations must reflect the configuration as it is at the moment of the call, so callers - * keep working after {@code /automessage reload} without being re-created. - *

- */ public interface ScheduledMessageRepository { - /** - * @return every configured message, in configuration order - */ @Unmodifiable List findAll(); - /** - * @return the messages that take part in the timed rotation, in configuration order - */ @Unmodifiable List findScheduled(); - /** - * @param type trigger type to look for - * @return the messages fired by that kind of event, in configuration order - */ @Unmodifiable List findByTrigger(MessageTrigger.Type type); - /** - * Finds a message by its configured name, ignoring case. - * - * @param name name to look for, may be null - * @return the matching message, or empty when no message uses that name - */ Optional findByName(String name); - /** - * @param channel channel to read - * @return the rotating messages that joined the given channel, in configuration order. - * Triggered messages are excluded: they fire on their event, and a channel picking - * one up would also announce it at an arbitrary moment. - */ @Unmodifiable List findByChannel(AnnouncementChannel channel); - /** - * @return the names of every configured message, in configuration order - */ @Unmodifiable List names(); - /** - * Creates a repository backed by the given configuration file. - * - *

- * The repository derives its views once and keeps them until the configuration changes, so it - * registers itself with the reload service rather than leaving the caller to remember. Passing - * the service in - instead of testing the returned interface for a listener - keeps that - * requirement visible in the signature. - *

- * - * @param config configuration holding the scheduled messages - * @param reloadService service notifying the repository that its views are stale - * @return repository reading straight from the configuration - */ static ScheduledMessageRepository config( ScheduledMessagesConfig config, ConfigReloadService reloadService diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageSender.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageSender.java index 5f7dd6c..4209a08 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageSender.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessageSender.java @@ -1,149 +1,161 @@ package com.github.imdmk.automessage.scheduled; -import com.eternalcode.multification.notice.Notice; -import com.eternalcode.multification.notice.NoticeBroadcast; +import com.github.imdmk.automessage.config.ConfigReloadListener; import com.github.imdmk.automessage.language.LanguageConfig; import com.github.imdmk.automessage.language.LanguageRegistry; import com.github.imdmk.automessage.message.MessageService; +import com.github.imdmk.automessage.message.TextSubstitution; +import com.github.imdmk.automessage.notice.Notice; import com.github.imdmk.automessage.platform.logger.PluginLogger; import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; import com.github.imdmk.automessage.scheduled.placeholder.MessagePlaceholders; -import org.bukkit.Server; -import org.bukkit.command.CommandSender; -import org.bukkit.entity.Player; +import net.kyori.adventure.audience.Audience; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashMap; import java.util.List; -import java.util.Objects; import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.UnaryOperator; + +public final class ScheduledMessageSender implements ConfigReloadListener { -/** - * Turns a {@link ScheduledMessage} into the notices a single player actually receives. - * - *

- * The message itself carries no text: scheduledMessages.yml says when a message is sent and to - * whom, and the language files say what it says. This is where the two meet, in the language the - * viewer's client is running. - *

- */ -public final class ScheduledMessageSender { - - private final Server server; + private final ViewerRegistry viewers; private final PluginLogger logger; private final MessageService messageService; private final LanguageRegistry languages; private final ExternalPlaceholderResolver externalPlaceholderResolver; + // Which placeholders a message contains is decided by the text, and the text only changes + // when the files do - so the scan is kept until a reload rather than repeated on every + // announcement, which walked every translation of every language each time. + private final Map scanned = new ConcurrentHashMap<>(); + public ScheduledMessageSender( - Server server, + ViewerRegistry viewers, PluginLogger logger, MessageService messageService, LanguageRegistry languages, ExternalPlaceholderResolver externalPlaceholderResolver ) { - this.server = server; + this.viewers = viewers; this.logger = logger; this.messageService = messageService; this.languages = languages; this.externalPlaceholderResolver = externalPlaceholderResolver; } - /** - * Scans the message once so a broadcast does not repeat the work for every player. - * - *

- * Every language is scanned, not just the fallback: a placeholder used only in the Polish - * text still has to be resolved for the players reading it. - *

- */ + @Override + public void onConfigReload() { + this.scanned.clear(); + } + public MessagePlaceholders placeholdersOf(ScheduledMessage message) { - return MessagePlaceholders.scan(textOf(message), externalPlaceholderResolver); + return scanned.computeIfAbsent( + message.name(), + name -> MessagePlaceholders.scan(textOf(name), externalPlaceholderResolver) + ); } - private List> textOf(ScheduledMessage message) { + private List> textOf(String messageName) { return languages.all().stream() - .map(language -> language.announcement(message.name())) + .map(language -> language.announcement(messageName)) .filter(Objects::nonNull) .toList(); } - public void send(Player viewer, ScheduledMessage message) { + public void send(Viewer viewer, ScheduledMessage message) { send(viewer, message, placeholdersOf(message)); } public void send( - Player viewer, + Viewer viewer, ScheduledMessage message, MessagePlaceholders placeholders ) { - dispatch(viewer, message, placeholders, false); + final List notices = languages.announcement(message.name(), viewer.locale()); + + if (notices == null) { + warnAboutMissingText(message); + return; + } + + render(List.of(viewer.audience()), notices, placeholders.resolveFor(viewers, viewer)); } /** - * Sends every notice of the message off the calling thread. - * - *

- * Placeholder values are resolved before the hand-off, on the caller's thread, because the - * server state they read is only safe to touch there. - *

+ * Sends one announcement to a whole audience at once. */ - public void sendAsync(Player viewer, ScheduledMessage message) { - sendAsync(viewer, message, placeholdersOf(message)); - } - - public void sendAsync( - Player viewer, + public void sendAll( + Collection recipients, ScheduledMessage message, MessagePlaceholders placeholders ) { - dispatch(viewer, message, placeholders, true); - } - - private void dispatch( - Player viewer, - ScheduledMessage message, - MessagePlaceholders placeholders, - boolean async - ) { - final List notices = languages.announcement(message.name(), viewer.getLocale()); + if (recipients.isEmpty()) { + return; + } - // A message named in scheduledMessages.yml with no text in any language cannot be sent. - // Saying so once beats a player quietly receiving nothing. - if (notices == null) { - logger.warn( - "Message '%s' has no text in any language file - add it under 'announcements' in lang/%s.yml.", - message.name(), - languages.fallback().code() - ); + // A message that reads differently for each player has to be built for each of them. + // Anything else is the same text for everyone sharing a language, so it is parsed once + // and handed to all of them together. + if (placeholders.viewerScoped()) { + for (final Viewer viewer : recipients) { + send(viewer, message, placeholders); + } return; } - final Map resolved = placeholders.resolveFor(server, viewer); + final Map resolved = placeholders.resolveWithoutViewer(viewers); + boolean warned = false; - for (final Notice notice : notices) { - final NoticeBroadcast broadcast = - broadcast(viewer, notice, resolved); + for (final Map.Entry> group : groupByLanguage(recipients).entrySet()) { + final List notices = languages.announcement(message.name(), group.getKey()); - if (async) { - broadcast.sendAsync(); - } else { - broadcast.send(); + if (notices == null) { + if (!warned) { + warnAboutMissingText(message); + warned = true; + } + continue; } + + render(group.getValue(), notices, resolved); } } - private NoticeBroadcast broadcast( - Player viewer, - Notice notice, - Map placeholders - ) { - NoticeBroadcast broadcast = messageService.create() - .viewer(viewer) - .notice(notice); + private Map> groupByLanguage(Collection recipients) { + // Keyed by the language itself rather than by its code: the registry hands out one + // instance per language, and two codes that resolve to it belong in the same group. + final Map> byLanguage = new LinkedHashMap<>(); + + for (final Viewer viewer : recipients) { + byLanguage + .computeIfAbsent(languages.provide(viewer.locale()), language -> new ArrayList<>()) + .add(viewer.audience()); + } + + return byLanguage; + } + + private void render(List audiences, List notices, Map placeholders) { + final UnaryOperator substitution = TextSubstitution.of(placeholders); - for (final Map.Entry placeholder : placeholders.entrySet()) { - broadcast = broadcast.placeholder(placeholder.getKey(), placeholder.getValue()); + for (final Notice notice : notices) { + messageService.render(audiences, notice, substitution); } + } - return broadcast; + // A message named in scheduledMessages.yml with no text in any language cannot be sent. + // Saying so once beats a player quietly receiving nothing. + private void warnAboutMissingText(ScheduledMessage message) { + logger.warn( + "Message '%s' has no text in any language file - add it under 'announcements' in lang/%s.yml.", + message.name(), + languages.fallback().code() + ); } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessagesConfig.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessagesConfig.java index 6e2bc54..b526982 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessagesConfig.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/ScheduledMessagesConfig.java @@ -7,6 +7,7 @@ import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerSerializer; import eu.okaeri.configs.annotation.Comment; import eu.okaeri.configs.annotation.Header; +import com.github.imdmk.automessage.platform.capability.Capabilities; import eu.okaeri.configs.serdes.OkaeriSerdesPack; import java.time.Duration; @@ -70,21 +71,30 @@ public final class ScheduledMessagesConfig extends ConfigSection { "# rules Who receives it. All rules must pass. Omit to send to everyone.", "#", "# rules:", + "@requires PERMISSION_RULE", "# - type: PERMISSION", "# permission: rank.vip", + "@end", + "@requires GROUP_RULE", "# - type: GROUP", "# group: vip", + "@end", + "@requires WORLD_RULE", "# - type: WORLD", "# worlds: [world, world_nether]", + "@end", "# - type: PLAYER_COUNT # while 1-10 players are online", "# min: 1", "# max: 10", + "@requires PLAYTIME_RULE", "# - type: PLAYTIME # players with under 2h played", "# max: 2h", + "@end", "#", "# ANY_OF, NONE_OF and NOT nest the rules above - use them for", "# anything AND cannot say, such as \"VIP or moderator\":", "#", + "@requires PERMISSION_RULE", "# rules:", "# - type: ANY_OF", "# rules:", @@ -93,24 +103,36 @@ public final class ScheduledMessagesConfig extends ConfigSection { "# - type: PERMISSION", "# permission: rank.mod", "#", + "@end", "# trigger Sends it on an event instead of on the timetable. A message", "# with a trigger leaves the rotation entirely; rules still apply.", "#", "# trigger:", - "# type: JOIN # or FIRST_JOIN", + "# type: JOIN", "# delay: 3s # let the join spam settle first", "#", + "@requires FIRST_JOIN_TRIGGER", + "# trigger:", + "# type: FIRST_JOIN # a player's very first connection", + "# delay: 3s", + "#", + "@end", "# trigger:", "# type: PLAYER_COUNT # fires once on reaching 100 online", "# threshold: 100", "#", "# PLACEHOLDERS - write these in the text, in the language files", "#", - "# {PLAYER} {DISPLAY_NAME} {UUID} {WORLD} about the reader", + "# {PLAYER} {DISPLAY_NAME} {UUID} about the reader", + "@requires WORLD_RULE", + "# {WORLD} the world they are in", + "@end", "# {ONLINE} {MAX_PLAYERS} {DATE} {TIME} about the server", "#", + "@requires EXTERNAL_PLACEHOLDERS", "# With PlaceholderAPI installed, %any_placeholder% works too.", - "#" + "#", + "@end" }) public List messages = List.of( ScheduledMessageBuilder.create().name("vote-reminder").build(), @@ -139,6 +161,13 @@ public final class ScheduledMessagesConfig extends ConfigSection { .build() ); + @Override + public void applyCapabilities(Capabilities capabilities) { + this.messages = this.messages.stream() + .filter(message -> CapabilityRequirements.satisfiedBy(message, capabilities)) + .toList(); + } + @Override public OkaeriSerdesPack getSerdesPack() { return registry -> { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/AudienceFilter.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/AudienceFilter.java index 13140a7..98fd48b 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/AudienceFilter.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/AudienceFilter.java @@ -1,13 +1,26 @@ package com.github.imdmk.automessage.scheduled.audience.filter; +import com.github.imdmk.automessage.scheduled.audience.optout.AnnouncementOptOut; import com.github.imdmk.automessage.scheduled.ScheduledMessage; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext; public interface AudienceFilter { - boolean allows(Player player, ScheduledMessage message); + boolean allows(Viewer viewer, ScheduledMessage message, AudienceContext context); static AudienceFilter ruleFilter() { return new RuleAudienceFilter(); } + + // A set lookup that rejects the whole player, so it belongs in front of any rule rather than + // behind them: a muted veteran should not cost a playtime lookup to be turned away. + static AudienceFilter notMuted(AnnouncementOptOut optOut) { + return (viewer, message, context) -> !optOut.isMuted(viewer.uniqueId()); + } + + default AudienceFilter and(AudienceFilter other) { + return (viewer, message, context) -> + allows(viewer, message, context) && other.allows(viewer, message, context); + } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/RuleAudienceFilter.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/RuleAudienceFilter.java index 09ae1a1..43c10e1 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/RuleAudienceFilter.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/filter/RuleAudienceFilter.java @@ -1,21 +1,22 @@ package com.github.imdmk.automessage.scheduled.audience.filter; import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext; import com.github.imdmk.automessage.scheduled.audience.rule.AudienceRule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; import java.util.List; final class RuleAudienceFilter implements AudienceFilter { @Override - public boolean allows(Player player, ScheduledMessage message) { + public boolean allows(Viewer viewer, ScheduledMessage message, AudienceContext context) { // Called once per online player per broadcast; an indexed loop keeps that free of the // stream and lambda capture a stream pipeline would allocate on every call. final List rules = message.rules(); for (int i = 0; i < rules.size(); i++) { - if (!rules.get(i).test(player)) { + if (!rules.get(i).test(viewer, context)) { return false; } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/optout/AnnouncementOptOut.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/optout/AnnouncementOptOut.java new file mode 100644 index 0000000..1a4e449 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/optout/AnnouncementOptOut.java @@ -0,0 +1,142 @@ +package com.github.imdmk.automessage.scheduled.audience.optout; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The players who asked not to receive announcements. + */ +// State rather than configuration, so it is kept out of the okaeri files on purpose: a reload +// must not touch it, nobody edits a list of UUIDs by hand, and it grows with the server rather +// than with the settings. A plain file is also the only store all six platforms already have - +// Bukkit's persistent data container would cover two of them, Minestom saves no player data at +// all, and the other four would need this file anyway. +public final class AnnouncementOptOut { + + private static final String FOLDER = "data"; + private static final String FILE = "muted.txt"; + + private final PluginLogger logger; + private final Path file; + + // Read once per player per announcement, so it is answered from memory. + private final Set muted = ConcurrentHashMap.newKeySet(); + + // Nothing has changed until somebody asks for quiet, and a server where nobody ever did + // should not grow a file to say so. + private volatile boolean unsaved; + + private AnnouncementOptOut(PluginLogger logger, Path file) { + this.logger = logger; + this.file = file; + } + + public static AnnouncementOptOut load(PluginLogger logger, File dataFolder) { + final AnnouncementOptOut optOut = + new AnnouncementOptOut(logger, new File(new File(dataFolder, FOLDER), FILE).toPath()); + + optOut.read(); + + return optOut; + } + + public boolean isMuted(UUID player) { + return muted.contains(player); + } + + /** + * Turns announcements off for a player, or back on, and answers with the state they end in. + */ + public boolean toggle(UUID player) { + final boolean nowMuted = !muted.remove(player); + + if (nowMuted) { + muted.add(player); + } + + this.unsaved = true; + + return nowMuted; + } + + public int mutedCount() { + return muted.size(); + } + + private void read() { + if (!Files.isRegularFile(file)) { + return; + } + + int unreadable = 0; + + try { + for (final String line : Files.readAllLines(file, StandardCharsets.UTF_8)) { + final String trimmed = line.trim(); + + if (trimmed.isEmpty()) { + continue; + } + + try { + muted.add(UUID.fromString(trimmed)); + } catch (IllegalArgumentException exception) { + unreadable++; + } + } + } catch (IOException exception) { + logger.error(exception, "Failed to read %s - no player is muted until it can be read.", file); + return; + } + + if (unreadable > 0) { + // Kept rather than dropped: rewriting the file would throw away lines somebody may + // have put there, and a bad line only costs one player their preference. + logger.warn("%d line(s) in %s are not player ids and were skipped.", unreadable, file); + } + + if (!muted.isEmpty()) { + logger.info("%d player(s) have announcements turned off.", muted.size()); + } + } + + /** + * Writes the list out. Cheap enough to call on every change: it happens at human pace. + */ + // Written beside the real file and moved over it, so a server that dies mid-write leaves the + // previous list intact rather than a half of one. + public synchronized void save() { + if (!unsaved) { + return; + } + + // Cleared before the write rather than after: a change made while this one is in flight + // marks it again and is picked up by the next save, at the latest on shutdown. + this.unsaved = false; + + final List lines = new ArrayList<>(muted.size()); + muted.forEach(player -> lines.add(player.toString())); + + try { + Files.createDirectories(file.getParent()); + + final Path pending = file.resolveSibling(FILE + ".tmp"); + Files.write(pending, lines, StandardCharsets.UTF_8); + Files.move(pending, file, StandardCopyOption.REPLACE_EXISTING); + } catch (IOException exception) { + this.unsaved = true; + logger.error(exception, "Failed to write %s - the change is applied but will not survive a restart.", file); + } + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceAnyOfRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceAnyOfRule.java index 84a906e..c606496 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceAnyOfRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceAnyOfRule.java @@ -1,18 +1,10 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; import org.jetbrains.annotations.Unmodifiable; import java.util.List; -/** - * Passes when at least one nested rule passes. - * - *

- * The rules listed on a message are combined with AND, which cannot express "VIP or moderator". - * Splitting that into two near-identical messages is the workaround this removes. - *

- */ public record AudienceAnyOfRule(@Unmodifiable List rules) implements AudienceRule { public AudienceAnyOfRule { @@ -28,9 +20,9 @@ public static AudienceAnyOfRule of(AudienceRule... rules) { } @Override - public boolean test(Player player) { + public boolean test(Viewer viewer, AudienceContext context) { for (int i = 0; i < rules.size(); i++) { - if (rules.get(i).test(player)) { + if (rules.get(i).test(viewer, context)) { return true; } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceContext.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceContext.java new file mode 100644 index 0000000..f76b889 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceContext.java @@ -0,0 +1,11 @@ +package com.github.imdmk.automessage.scheduled.audience.rule; + +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; + +public record AudienceContext(ViewerRegistry viewers, PlaytimeSource playtime) { + + public static AudienceContext of(ViewerRegistry viewers, PlaytimeSource playtime) { + return new AudienceContext(viewers, playtime); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceGroupRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceGroupRule.java index 9e9b42d..061ee3e 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceGroupRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceGroupRule.java @@ -1,15 +1,15 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; public record AudienceGroupRule(String group) implements AudienceRule { private static final String GROUP_PREFIX = "group."; @Override - public boolean test(Player player) { + public boolean test(Viewer viewer, AudienceContext context) { final String permission = GROUP_PREFIX + group; - return player.hasPermission(permission); + return viewer.hasPermission(permission); } @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNoneOfRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNoneOfRule.java index d09f731..1f1278f 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNoneOfRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNoneOfRule.java @@ -1,18 +1,10 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; import org.jetbrains.annotations.Unmodifiable; import java.util.List; -/** - * Passes only when none of the nested rules pass. - * - *

- * Typically used to exclude a group from an otherwise public announcement — hiding the "buy a - * rank" advert from the players who already bought one. - *

- */ public record AudienceNoneOfRule(@Unmodifiable List rules) implements AudienceRule { public AudienceNoneOfRule { @@ -28,9 +20,9 @@ public static AudienceNoneOfRule of(AudienceRule... rules) { } @Override - public boolean test(Player player) { + public boolean test(Viewer viewer, AudienceContext context) { for (int i = 0; i < rules.size(); i++) { - if (rules.get(i).test(player)) { + if (rules.get(i).test(viewer, context)) { return false; } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNotRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNotRule.java index 89c2972..2f2f66f 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNotRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceNotRule.java @@ -1,15 +1,12 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; -/** - * Inverts a single nested rule. - */ public record AudienceNotRule(AudienceRule rule) implements AudienceRule { @Override - public boolean test(Player player) { - return !rule.test(player); + public boolean test(Viewer viewer, AudienceContext context) { + return !rule.test(viewer, context); } @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePermissionRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePermissionRule.java index c1b561b..0d1a434 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePermissionRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePermissionRule.java @@ -1,12 +1,12 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; public record AudiencePermissionRule(String permission) implements AudienceRule { @Override - public boolean test(Player player) { - return player.hasPermission(permission); + public boolean test(Viewer viewer, AudienceContext context) { + return viewer.hasPermission(permission); } @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayTimeRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayTimeRule.java index 44e5119..cdb6a53 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayTimeRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayTimeRule.java @@ -1,29 +1,12 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.Statistic; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; import org.jetbrains.annotations.Nullable; import java.time.Duration; -/** - * Restricts a message to players whose time on the server falls in a range. - * - *

- * Newcomer tips stop reaching veterans, and "thanks for sticking around" stops reaching people who - * joined a minute ago. The figure comes from the server's own {@code PLAY_ONE_MINUTE} statistic — - * despite the name it counts ticks — so it needs no database of our own and matches whatever the - * server already reports. - *

- * - * @param minimum lowest playtime that still receives the message, inclusive - * @param maximum highest playtime that still receives it, inclusive; {@code null} leaves the range - * open at the top - */ public record AudiencePlayTimeRule(Duration minimum, @Nullable Duration maximum) implements AudienceRule { - private static final long MILLIS_PER_TICK = 50L; - public AudiencePlayTimeRule { if (minimum == null || minimum.isNegative()) { throw new IllegalArgumentException("minimum must be a non-negative duration"); @@ -34,20 +17,16 @@ public record AudiencePlayTimeRule(Duration minimum, @Nullable Duration maximum) } } - public static AudiencePlayTimeRule atLeast(Duration minimum) { - return new AudiencePlayTimeRule(minimum, null); - } - - public static AudiencePlayTimeRule below(Duration maximum) { - return new AudiencePlayTimeRule(Duration.ZERO, maximum); - } - @Override - public boolean test(Player player) { - final Duration played = Duration.ofMillis( - (long) player.getStatistic(Statistic.PLAY_ONE_MINUTE) * MILLIS_PER_TICK - ); + public boolean test(Viewer viewer, AudienceContext context) { + // A platform that keeps no statistics answers with nothing, and the rule does not match. + // Treating "unknown" as zero would make every veteran look like a newcomer. + return context.playtime().playtimeOf(viewer) + .map(this::isInRange) + .orElse(false); + } + private boolean isInRange(Duration played) { if (played.compareTo(minimum) < 0) { return false; } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayerCountRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayerCountRule.java index 3ee0dab..c2c891a 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayerCountRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudiencePlayerCountRule.java @@ -1,20 +1,7 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; -/** - * Restricts a message to a range of online player counts. - * - *

- * The point is announcements that only make sense at a given population: "quiet right now — invite - * a friend" below ten players, "the server is filling up" above eighty. Both read as noise at the - * wrong moment, which is why servers usually cannot use them at all. - *

- * - * @param minimum lowest online count that still receives the message, inclusive - * @param maximum highest online count that still receives it, inclusive; - * {@link Integer#MAX_VALUE} leaves the range open at the top - */ public record AudiencePlayerCountRule(int minimum, int maximum) implements AudienceRule { public static final int UNBOUNDED = Integer.MAX_VALUE; @@ -31,17 +18,9 @@ public record AudiencePlayerCountRule(int minimum, int maximum) implements Audie } } - public static AudiencePlayerCountRule atLeast(int minimum) { - return new AudiencePlayerCountRule(minimum, UNBOUNDED); - } - - public static AudiencePlayerCountRule atMost(int maximum) { - return new AudiencePlayerCountRule(0, maximum); - } - @Override - public boolean test(Player player) { - final int online = player.getServer().getOnlinePlayers().size(); + public boolean test(Viewer viewer, AudienceContext context) { + final int online = context.viewers().onlineCount(); return online >= minimum && online <= maximum; } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRule.java index 84a6ba8..00a5cdb 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRule.java @@ -1,21 +1,11 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; import java.time.Duration; import java.util.List; import java.util.Set; -/** - * One condition a player must satisfy to receive a message. - * - *

- * The rules listed on a message are combined with AND. {@link AudienceAnyOfRule}, - * {@link AudienceNoneOfRule} and {@link AudienceNotRule} nest rules inside one another, which - * turns that flat list into an expression tree and makes conditions like "VIP or moderator, but - * not in the arena" expressible without duplicating the message. - *

- */ public sealed interface AudienceRule permits AudiencePermissionRule, AudienceGroupRule, @@ -70,7 +60,7 @@ static AudienceNotRule not(AudienceRule rule) { return new AudienceNotRule(rule); } - boolean test(Player player); + boolean test(Viewer viewer, AudienceContext context); enum Type { PERMISSION, diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleSerializer.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleSerializer.java index f14dbe4..85243c6 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleSerializer.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleSerializer.java @@ -1,7 +1,7 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import com.github.imdmk.automessage.platform.time.DurationFormatter; -import com.github.imdmk.automessage.platform.time.DurationParser; +import com.github.imdmk.automessage.notice.time.DurationFormatter; +import com.github.imdmk.automessage.notice.time.DurationParser; import eu.okaeri.configs.schema.GenericsDeclaration; import eu.okaeri.configs.serdes.DeserializationData; import eu.okaeri.configs.serdes.ObjectSerializer; @@ -12,14 +12,6 @@ import java.util.List; import java.util.Set; -/** - * Reads and writes audience rules, including the ones that nest other rules. - * - *

- * The combinators recurse through this same serializer, so a rule tree of any depth round-trips - * without a second format to maintain. - *

- */ public final class AudienceRuleSerializer implements ObjectSerializer { @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceWorldRule.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceWorldRule.java index 5c78d51..525845b 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceWorldRule.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceWorldRule.java @@ -1,6 +1,6 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; import org.jetbrains.annotations.Unmodifiable; import java.util.LinkedHashSet; @@ -8,14 +8,6 @@ import java.util.Locale; import java.util.Set; -/** - * Restricts a message to players standing in one of the named worlds. - * - *

- * This is what keeps lobby advertising out of the event arena, and minigame instructions out of - * the survival world. - *

- */ public record AudienceWorldRule(@Unmodifiable Set worlds) implements AudienceRule { public AudienceWorldRule { @@ -35,8 +27,12 @@ public static AudienceWorldRule of(String... worlds) { } @Override - public boolean test(Player player) { - return worlds.contains(player.getWorld().getName().toLowerCase(Locale.ROOT)); + public boolean test(Viewer viewer, AudienceContext context) { + // A viewer with no world - the console, or anyone on a proxy - is in none of them, so + // the rule simply does not match rather than throwing. + return viewer.world() + .map(world -> worlds.contains(world.toLowerCase(Locale.ROOT))) + .orElse(false); } @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannel.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannel.java index ce73177..05dd292 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannel.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannel.java @@ -6,23 +6,6 @@ import java.util.Locale; import java.util.Objects; -/** - * An independent announcement stream with its own schedule and rotation. - * - *

- * A single global interval forces every announcement onto one rhythm: tips every five minutes and - * a shop advert every twenty cannot both be right. Channels give each group of messages its own - * timing, its own selector and its own on/off switch, and a message picks its channel by name. - *

- * - * @param name identifier used by messages to join this channel, matched ignoring case - * @param enabled whether this channel dispatches at all - * @param initialDelay how long after startup this channel sends its first message; may be null or - * invalid, and is normalised by {@code DispatchTiming} - * @param period how much time passes between two of this channel's messages; may be null or - * invalid, and is normalised by {@code DispatchTiming} - * @param selector rotation strategy used within this channel - */ public record AnnouncementChannel( String name, boolean enabled, @@ -31,7 +14,6 @@ public record AnnouncementChannel( MessageSelectorType selector ) { - /** Channel a message joins when it does not name one. */ public static final String DEFAULT_NAME = "default"; public AnnouncementChannel { @@ -47,10 +29,6 @@ public record AnnouncementChannel( // console. Rejecting them here would turn a fixable typo into a failed startup. } - /** - * Channel names are matched ignoring case, so a message written as {@code channel: Ads} still - * reaches a channel declared as {@code ads}. - */ public boolean matches(String channelName) { return normalize(name).equals(normalize(channelName)); } @@ -60,8 +38,4 @@ public static String normalize(String channelName) { ? DEFAULT_NAME : channelName.trim().toLowerCase(Locale.ROOT); } - - public boolean isDefault() { - return matches(DEFAULT_NAME); - } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannelSerializer.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannelSerializer.java index 22efdff..d32c8bc 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannelSerializer.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/channel/AnnouncementChannelSerializer.java @@ -1,7 +1,7 @@ package com.github.imdmk.automessage.scheduled.channel; -import com.github.imdmk.automessage.platform.time.DurationFormatter; -import com.github.imdmk.automessage.platform.time.DurationParser; +import com.github.imdmk.automessage.notice.time.DurationFormatter; +import com.github.imdmk.automessage.notice.time.DurationParser; import com.github.imdmk.automessage.scheduled.selector.MessageSelectorType; import eu.okaeri.configs.schema.GenericsDeclaration; import eu.okaeri.configs.serdes.DeserializationData; diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ChannelPreview.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ChannelPreview.java new file mode 100644 index 0000000..956e6fa --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ChannelPreview.java @@ -0,0 +1,41 @@ +package com.github.imdmk.automessage.scheduled.dispatcher; + +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; + +/** + * What a channel is about to announce and when, or why that cannot be answered. + */ +public record ChannelPreview( + String channel, + Kind kind, + @Nullable String message, + @Nullable Duration due +) { + + public enum Kind { + NEXT, + UNPREDICTABLE, + DISABLED, + EMPTY + } + + static ChannelPreview next(String channel, String message, @Nullable Duration due) { + return new ChannelPreview(channel, Kind.NEXT, message, due); + } + + // A RANDOM channel draws when it fires, so any message named now would be a different one + // from the one that arrives - but the time it arrives at is still known. + static ChannelPreview unpredictable(String channel, @Nullable Duration due) { + return new ChannelPreview(channel, Kind.UNPREDICTABLE, null, due); + } + + static ChannelPreview disabled(String channel) { + return new ChannelPreview(channel, Kind.DISABLED, null, null); + } + + static ChannelPreview empty(String channel) { + return new ChannelPreview(channel, Kind.EMPTY, null, null); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchObserver.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchObserver.java index 8646ac9..95b5934 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchObserver.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchObserver.java @@ -3,30 +3,37 @@ import com.github.imdmk.automessage.scheduled.ScheduledMessage; import com.github.imdmk.automessage.scheduled.placeholder.MessagePlaceholders; -/** - * Notified once per announcement, after it has gone out to the players. - * - *

- * Called once per message rather than once per recipient: an observer mirroring announcements - * elsewhere wants the announcement, not one copy of it per player online. - *

- */ -@FunctionalInterface +import java.util.List; + public interface DispatchObserver { void onDispatched(ScheduledMessage message, MessagePlaceholders placeholders); - /** - * Releases whatever the observer holds open. - * - *

- * An observer talking to the network owns threads, and those threads hold the plugin's class - * loader alive. Leaving them running is how a plugin survives its own disable. - *

- */ default void shutdown() { } + // Order matters: an observer that throws stops the ones behind it, so the cheap local one + // goes first and anything reaching the network goes last. + static DispatchObserver of(DispatchObserver... observers) { + final List all = List.of(observers); + + return new DispatchObserver() { + @Override + public void onDispatched(ScheduledMessage message, MessagePlaceholders placeholders) { + for (final DispatchObserver observer : all) { + observer.onDispatched(message, placeholders); + } + } + + @Override + public void shutdown() { + for (final DispatchObserver observer : all) { + observer.shutdown(); + } + } + }; + } + static DispatchObserver none() { return (message, placeholders) -> { }; diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchPlayersTarget.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchPlayersTarget.java deleted file mode 100644 index e9f5c81..0000000 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchPlayersTarget.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.github.imdmk.automessage.scheduled.dispatcher; - -import org.bukkit.entity.Player; - -import java.util.Collection; - -public record DispatchPlayersTarget( - Collection players -) implements DispatchTarget { - - @Override - public Collection recipients() { - return players; - } -} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchStatistics.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchStatistics.java new file mode 100644 index 0000000..bd78530 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchStatistics.java @@ -0,0 +1,100 @@ +package com.github.imdmk.automessage.scheduled.dispatcher; + +import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; +import com.github.imdmk.automessage.scheduled.placeholder.MessagePlaceholders; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import java.util.function.LongSupplier; + +/** + * Counts what has actually been announced, so an administrator can see the rotation working. + */ +public final class DispatchStatistics implements DispatchObserver { + + private final LongSupplier clock; + private final Map byMessage = new ConcurrentHashMap<>(); + private final Map byChannel = new ConcurrentHashMap<>(); + private final AtomicLong total = new AtomicLong(); + + public DispatchStatistics() { + this(System::nanoTime); + } + + // Nanoseconds rather than a wall clock: the only question asked of it is how long ago + // something happened, and that answer should survive the server's clock being corrected. + DispatchStatistics(LongSupplier clock) { + this.clock = clock; + } + + @Override + public void onDispatched(ScheduledMessage message, MessagePlaceholders placeholders) { + final long now = clock.getAsLong(); + + byMessage.computeIfAbsent(message.name(), name -> new Counter()).record(now, message.channel()); + byChannel.computeIfAbsent(AnnouncementChannel.normalize(message.channel()), name -> new Counter()) + .record(now, null); + total.incrementAndGet(); + } + + public long total() { + return total.get(); + } + + /** + * How much a single channel has carried, for a report that also knows when it fires next. + */ + public Optional channel(String channelName) { + final String key = AnnouncementChannel.normalize(channelName); + return Optional.ofNullable(byChannel.get(key)).map(counter -> entry(key, counter, clock.getAsLong())); + } + + public List snapshot() { + final long now = clock.getAsLong(); + final List entries = new ArrayList<>(); + + byMessage.forEach((name, counter) -> entries.add(entry(name, counter, now))); + + // Loudest first: the question behind this command is usually which message is dominating + // the rotation, not what the file happens to list first. + entries.sort(Comparator.comparingLong(Entry::count).reversed().thenComparing(Entry::name)); + + return List.copyOf(entries); + } + + private static Entry entry(String name, Counter counter, long now) { + return new Entry(name, counter.channel, counter.count.get(), Duration.ofNanos(now - counter.lastNanos)); + } + + /** + * A count of one thing that was announced - a message, or a whole channel. + */ + // A message names the channel it went out on, because a report that lists both without saying + // which is which leaves the reader guessing. A channel's own row has nothing to name. + public record Entry(String name, @Nullable String channel, long count, Duration since) { + } + + private static final class Counter { + + private final AtomicLong count = new AtomicLong(); + private volatile long lastNanos; + private volatile String channel; + + private void record(long now, @Nullable String channel) { + count.incrementAndGet(); + this.lastNanos = now; + + if (channel != null) { + this.channel = channel; + } + } + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTarget.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTarget.java index c5b6ee9..6e55700 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTarget.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTarget.java @@ -1,27 +1,19 @@ package com.github.imdmk.automessage.scheduled.dispatcher; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; import java.util.Collection; import java.util.List; -/** - * Who an announcement is being sent to. - * - *

- * Audience rules are applied by the dispatcher rather than by the target, so a target only has to - * answer who is in scope: one player for a triggered message, everyone online for a scheduled one. - *

- */ public interface DispatchTarget { - Collection recipients(); + Collection recipients(); - static DispatchTarget player(Player player) { - return new DispatchPlayersTarget(List.of(player)); + static DispatchTarget viewer(Viewer viewer) { + return new DispatchViewersTarget(List.of(viewer)); } - static DispatchTarget players(Collection players) { - return new DispatchPlayersTarget(players); + static DispatchTarget viewers(Collection viewers) { + return new DispatchViewersTarget(viewers); } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTiming.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTiming.java index bdef878..43e4f4e 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTiming.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchTiming.java @@ -1,37 +1,18 @@ package com.github.imdmk.automessage.scheduled.dispatcher; import com.github.imdmk.automessage.platform.logger.PluginLogger; -import com.github.imdmk.automessage.platform.time.DurationFormatter; +import com.github.imdmk.automessage.notice.time.DurationFormatter; import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; import java.time.Duration; -/** - * Validated timing of the automatic message dispatcher. - * - *

- * Values coming from {@code messagesDispatcher.yml} are user supplied and may be missing, - * negative or shorter than a single server tick. They are normalized once, when a channel's task - * is scheduled, so the scheduler never receives a value it cannot honour. - *

- * - *

- * A period shorter than one tick is silently rounded down to zero ticks by Bukkit, which makes - * the dispatcher fire on every tick instead of on the configured interval. - *

- * - * @param initialDelay delay before the first dispatch, never negative - * @param period delay between two dispatches, never shorter than {@link #MINIMUM_PERIOD} - */ public record DispatchTiming( Duration initialDelay, Duration period ) { - /** One server tick — the shortest interval Bukkit is able to schedule. */ public static final Duration MINIMUM_PERIOD = Duration.ofMillis(50L); - /** Period used when the configured one is missing or not a positive value. */ public static final Duration DEFAULT_PERIOD = Duration.ofSeconds(10L); public DispatchTiming { @@ -44,14 +25,6 @@ public record DispatchTiming( } } - /** - * Reads the timing from the configuration, replacing every value the scheduler could not - * honour with a safe one and reporting each correction to the console. - * - * @param channel channel whose timing should be normalized - * @param logger logger used to report corrected values - * @return normalized, always schedulable timing - */ public static DispatchTiming from(AnnouncementChannel channel, PluginLogger logger) { return new DispatchTiming( normalizeInitialDelay(channel.name(), channel.initialDelay(), logger), diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchViewersTarget.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchViewersTarget.java new file mode 100644 index 0000000..b40c81d --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchViewersTarget.java @@ -0,0 +1,15 @@ +package com.github.imdmk.automessage.scheduled.dispatcher; + +import com.github.imdmk.automessage.platform.viewer.Viewer; + +import java.util.Collection; + +public record DispatchViewersTarget( + Collection viewers +) implements DispatchTarget { + + @Override + public Collection recipients() { + return viewers; + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ForcedSend.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ForcedSend.java new file mode 100644 index 0000000..1aa56b7 --- /dev/null +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ForcedSend.java @@ -0,0 +1,35 @@ +package com.github.imdmk.automessage.scheduled.dispatcher; + +import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.Optional; + +/** + * What came of sending a channel's next announcement by hand. + */ +public record ForcedSend(Kind kind, Optional message, @Nullable Duration nextIn) { + + public enum Kind { + SENT, + NOBODY_ONLINE, + NO_MESSAGES, + DISABLED + } + + static ForcedSend of(MessageDispatcherTask.Outcome outcome, Duration nextIn) { + final Kind kind = switch (outcome.kind()) { + case SENT -> Kind.SENT; + case NOBODY_ONLINE -> Kind.NOBODY_ONLINE; + case NO_MESSAGES -> Kind.NO_MESSAGES; + }; + + return new ForcedSend(kind, outcome.message(), nextIn); + } + + // Named in the file but switched off, which is a different answer from a name nobody set up. + static ForcedSend disabled() { + return new ForcedSend(Kind.DISABLED, Optional.empty(), null); + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcher.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcher.java index 10f427b..cc7bc07 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcher.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcher.java @@ -5,8 +5,11 @@ import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter; import com.github.imdmk.automessage.scheduled.placeholder.MessagePlaceholders; import com.github.imdmk.automessage.scheduled.selector.MessageSelector; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext; +import java.util.ArrayList; +import java.util.Optional; import java.util.List; import java.util.function.Supplier; @@ -15,36 +18,43 @@ public final class MessageDispatcher { private final ScheduledMessageSender sender; private final Supplier selector; private final AudienceFilter filter; + private final AudienceContext audienceContext; private final DispatchObserver observer; public MessageDispatcher( ScheduledMessageSender sender, Supplier selector, AudienceFilter filter, + AudienceContext audienceContext, DispatchObserver observer ) { this.sender = sender; this.selector = selector; this.filter = filter; + this.audienceContext = audienceContext; this.observer = observer; } - public void dispatchNext(List messages, DispatchTarget target) { - dispatchNext(messages, target, true); + public Optional dispatchNext(List messages, DispatchTarget target) { + return dispatchNext(messages, target, true); } - public void dispatchNext( + // Returns what it chose, so a caller that was asked to send something can say what went out. + // The selector is the only thing that knows: a weighted or random channel draws afresh on + // every call, so looking first and sending second would name a different message. + public Optional dispatchNext( List messages, DispatchTarget target, boolean advanceSelectorIndex ) { if (messages.isEmpty()) { - return; + return Optional.empty(); } - selector.get() - .selectNext(messages, advanceSelectorIndex) - .ifPresent(message -> dispatch(message, target)); + final Optional chosen = selector.get().selectNext(messages, advanceSelectorIndex); + chosen.ifPresent(message -> dispatch(message, target)); + + return chosen; } public void dispatch( @@ -54,13 +64,18 @@ public void dispatch( // Which placeholders the message contains is a property of the message, not of who is // reading it, so the scan happens once here rather than once per recipient. final MessagePlaceholders placeholders = sender.placeholdersOf(message); + final List recipients = new ArrayList<>(); - for (final Player player : target.recipients()) { - if (filter.allows(player, message)) { - sender.sendAsync(player, message, placeholders); + for (final Viewer viewer : target.recipients()) { + if (filter.allows(viewer, message, audienceContext)) { + recipients.add(viewer); } } + // Handed over as one audience rather than one at a time, so a message that reads the + // same for everybody is built once instead of once per player. + sender.sendAll(recipients, message, placeholders); + // Once per announcement, not once per recipient - and after the players have been served, // so nothing an observer does can delay what happens on the server. observer.onDispatched(message, placeholders); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfig.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfig.java index 8b5384e..83078c8 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfig.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfig.java @@ -105,10 +105,6 @@ public boolean isEnabled() { return enabled; } - /** - * @return every configured channel; a file with none leaves the plugin silent, which is - * reported rather than guessed at - */ public List channels() { return List.copyOf(channels); } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherService.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherService.java index 451233d..0d89d73 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherService.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherService.java @@ -2,74 +2,77 @@ import com.github.imdmk.automessage.config.ConfigReloadListener; import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; -import com.github.imdmk.automessage.platform.time.DurationFormatter; +import com.github.imdmk.automessage.notice.time.DurationFormatter; import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; import com.github.imdmk.automessage.scheduled.selector.MessageSelectorProvider; -import org.bukkit.Server; -import org.bukkit.scheduler.BukkitTask; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; import java.util.ArrayList; import java.util.HashMap; +import java.util.LinkedHashMap; import java.util.List; +import java.util.function.LongSupplier; import java.util.Map; -/** - * Owns one repeating task per announcement channel. - * - *

- * Bukkit captures the delay and the period when a task is scheduled, so a changed interval cannot - * take effect while the task is running. Reloading the configuration therefore cancels every task - * and schedules a fresh set with the timing that was just read. - *

- * - *

- * Each channel keeps its own selector across reloads, so re-reading the configuration does not - * send every channel back to the top of its rotation. - *

- * - *

- * The ticks run on the main thread: they read the online player collection and evaluate the - * audience rules, and neither is safe to touch asynchronously. Only the delivery of each notice is - * handed off, so a tick costs one pass over the online players and nothing else. - *

- */ public final class MessageDispatcherService implements ConfigReloadListener { private final PluginLogger logger; - private final Server server; + private final ViewerRegistry viewers; private final TaskScheduler taskScheduler; private final MessageDispatcherConfig dispatcherConfig; private final ScheduledMessageRepository repository; private final ScheduledMessageDispatcherFactory dispatcherFactory; - private final List tasks = new ArrayList<>(); + // Keyed by channel, because two questions need the task itself and not just its handle: + // how long until it fires, and send it now. + private final Map running = new LinkedHashMap<>(); - /** - * Selectors are stateful — SEQUENTIAL and SHUFFLE remember where they stopped — so they are - * kept per channel across restarts of the tasks rather than rebuilt with them. - */ + // Selectors are stateful - SEQUENTIAL and SHUFFLE remember where they stopped - so they + // outlive the tasks and a reload does not send every channel back to the top of its rotation. private final Map selectorsByChannel = new HashMap<>(); + private final LongSupplier clock; + public MessageDispatcherService( PluginLogger logger, - Server server, + ViewerRegistry viewers, TaskScheduler taskScheduler, MessageDispatcherConfig dispatcherConfig, ScheduledMessageRepository repository, ScheduledMessageDispatcherFactory dispatcherFactory ) { + this(logger, viewers, taskScheduler, dispatcherConfig, repository, dispatcherFactory, System::nanoTime); + } + + // The clock is handed in so a test can ask how long is left without waiting for it. + public MessageDispatcherService( + PluginLogger logger, + ViewerRegistry viewers, + TaskScheduler taskScheduler, + MessageDispatcherConfig dispatcherConfig, + ScheduledMessageRepository repository, + ScheduledMessageDispatcherFactory dispatcherFactory, + LongSupplier clock + ) { + this.clock = clock; this.logger = logger; - this.server = server; + this.viewers = viewers; this.taskScheduler = taskScheduler; this.dispatcherConfig = dispatcherConfig; this.repository = repository; this.dispatcherFactory = dispatcherFactory; } + // Schedulers capture the delay and period when a task is created, so a changed interval + // cannot take effect while the task runs - a reload cancels every task and schedules afresh. public synchronized void start() { - if (!tasks.isEmpty()) { + if (!running.isEmpty()) { return; } @@ -88,27 +91,14 @@ public synchronized void start() { warnAboutOrphanedMessages(); } - /** - * Drops the rotation state of channels that no longer exist. - * - *

- * Selectors are deliberately kept across reloads so a channel does not restart its rotation - * every time the configuration is read. A channel that has been renamed or deleted, however, - * is never asked for again - and a SHUFFLE selector holds a deck of the messages it was - * dealing, so leaving the entry behind pins those objects for the life of the server. - *

- */ + // A SHUFFLE selector holds a deck of the messages it was dealing, so an entry for a channel + // that no longer exists pins those objects for the life of the server. private void forgetSelectorsOfRemovedChannels(List channels) { selectorsByChannel.keySet().removeIf( name -> channels.stream().noneMatch(channel -> channel.matches(name)) ); } - /** - * A message pointing at a channel nobody declared is never sent, and nothing about the running - * server says why. A typo in one line of YAML is otherwise invisible until somebody notices an - * announcement has been missing for a week. - */ private void warnAboutOrphanedMessages() { final List channels = dispatcherConfig.channels(); @@ -129,51 +119,137 @@ private void warnAboutOrphanedMessages() { private void schedule(AnnouncementChannel channel) { final DispatchTiming timing = DispatchTiming.from(channel, logger); - final MessageSelectorProvider selectors = selectorsByChannel.computeIfAbsent( + final MessageSelectorProvider selectors = selectorsFor(channel); + + launch(channel, timing, selectors); + + logger.info( + "Channel '%s': a message every %s, first one in %s, rotation %s.", + channel.name(), + DurationFormatter.formatReadable(timing.period()), + DurationFormatter.formatReadable(timing.initialDelay()), + channel.selector() + ); + } + + private com.github.imdmk.automessage.scheduled.selector.MessageSelectorType currentSelectorOf(String channelName) { + for (final AnnouncementChannel channel : dispatcherConfig.channels()) { + if (channel.matches(channelName)) { + return channel.selector(); + } + } + + return com.github.imdmk.automessage.scheduled.selector.MessageSelectorType.SEQUENTIAL; + } + + @Nullable + private Duration untilDue(AnnouncementChannel channel) { + final Running entry = running.get(AnnouncementChannel.normalize(channel.name())); + return entry == null ? null : entry.task().untilDue(); + } + + private record Running(MessageDispatcherTask task, TaskHandle handle, DispatchTiming timing) { + } + + private MessageSelectorProvider selectorsFor(AnnouncementChannel channel) { + return selectorsByChannel.computeIfAbsent( AnnouncementChannel.normalize(channel.name()), key -> new MessageSelectorProvider(() -> currentSelectorOf(key)) ); + } + + /** + * What each configured channel would announce next, asked without disturbing the rotation. + */ + public synchronized List upcoming() { + final List previews = new ArrayList<>(); + + for (final AnnouncementChannel channel : dispatcherConfig.channels()) { + previews.add(preview(channel)); + } + + return List.copyOf(previews); + } + + private ChannelPreview preview(AnnouncementChannel channel) { + if (!channel.enabled()) { + return ChannelPreview.disabled(channel.name()); + } + + final List messages = + repository.findByChannel(channel); + + if (messages.isEmpty()) { + return ChannelPreview.empty(channel.name()); + } + + if (channel.selector() == com.github.imdmk.automessage.scheduled.selector.MessageSelectorType.RANDOM) { + return ChannelPreview.unpredictable(channel.name(), untilDue(channel)); + } + + // Asked not to advance, so looking is not the same as sending - the deck a SHUFFLE + // channel is part way through stays exactly where it was. + return selectorsFor(channel).get() + .selectNext(messages, false) + .map(message -> ChannelPreview.next(channel.name(), message.name(), untilDue(channel))) + .orElseGet(() -> ChannelPreview.empty(channel.name())); + } + private void launch( + AnnouncementChannel channel, + DispatchTiming timing, + MessageSelectorProvider selectors + ) { final MessageDispatcherTask task = new MessageDispatcherTask( - server, + viewers, dispatcherConfig::isEnabled, channel, repository, dispatcherFactory.create(selectors), - timing + timing, + clock ); - tasks.add(taskScheduler.runTimerSync(task)); - - logger.info( - "Channel '%s': a message every %s, first one in %s, rotation %s.", - channel.name(), - DurationFormatter.format(timing.period()), - DurationFormatter.format(timing.initialDelay()), - channel.selector() + running.put( + AnnouncementChannel.normalize(channel.name()), + new Running(task, taskScheduler.runTimerSync(task), timing) ); } /** - * Read through the configuration rather than captured, so a reload that changes a channel's - * rotation is picked up without discarding the position that channel had reached. + * Sends a channel's next announcement now and starts its interval again from this moment. */ - private com.github.imdmk.automessage.scheduled.selector.MessageSelectorType currentSelectorOf(String channelName) { - for (final AnnouncementChannel channel : dispatcherConfig.channels()) { - if (channel.matches(channelName)) { - return channel.selector(); - } + public synchronized ForcedSend forceNext(AnnouncementChannel channel) { + final Running current = running.get(AnnouncementChannel.normalize(channel.name())); + + // A channel switched off in the file has no task to send through, which is a different + // answer from a name that was never configured - the argument rejects those. + if (current == null) { + return ForcedSend.disabled(); } - return com.github.imdmk.automessage.scheduled.selector.MessageSelectorType.SEQUENTIAL; + final MessageDispatcherTask.Outcome outcome = current.task().send(); + + if (outcome.kind() != MessageDispatcherTask.Outcome.Kind.SENT) { + return ForcedSend.of(outcome, current.task().untilDue()); + } + + // The interval restarts from this moment, so an announcement pushed out by hand is not + // followed seconds later by the one that was already due. Only when something did go + // out - a channel nobody heard has no reason to lose its place in the schedule. + current.handle().cancel(); + final DispatchTiming restarted = new DispatchTiming(current.timing().period(), current.timing().period()); + launch(current.task().channel(), restarted, selectorsFor(current.task().channel())); + + return ForcedSend.of(outcome, restarted.period()); } public synchronized void stop() { - for (final BukkitTask task : tasks) { - task.cancel(); + for (final Running entry : running.values()) { + entry.handle().cancel(); } - tasks.clear(); + running.clear(); } public synchronized void restart() { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherTask.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherTask.java index 7a759c1..e5dfa97 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherTask.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherTask.java @@ -1,64 +1,89 @@ package com.github.imdmk.automessage.scheduled.dispatcher; import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; import com.github.imdmk.automessage.scheduled.ScheduledMessage; import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; -import org.bukkit.Server; -import org.bukkit.entity.Player; import java.time.Duration; import java.util.Collection; import java.util.List; +import java.util.Optional; import java.util.function.BooleanSupplier; +import java.util.function.LongSupplier; -/** - * The repeating tick of one announcement channel. - */ public final class MessageDispatcherTask implements PluginTask { - private final Server server; + private final ViewerRegistry viewers; private final BooleanSupplier masterSwitch; private final AnnouncementChannel channel; private final ScheduledMessageRepository repository; private final MessageDispatcher messageDispatcher; private final DispatchTiming timing; + private final LongSupplier clock; + + // When this channel is next due. A scheduler will not say how much is left on a timer it + // owns, so the task that runs on it keeps count - which is what /automessage next reads. + private volatile long dueNanos; public MessageDispatcherTask( - Server server, + ViewerRegistry viewers, BooleanSupplier masterSwitch, AnnouncementChannel channel, ScheduledMessageRepository repository, MessageDispatcher messageDispatcher, - DispatchTiming timing + DispatchTiming timing, + LongSupplier clock ) { - this.server = server; + this.viewers = viewers; this.masterSwitch = masterSwitch; this.channel = channel; this.repository = repository; this.messageDispatcher = messageDispatcher; this.timing = timing; + this.clock = clock; + this.dueNanos = clock.getAsLong() + timing.initialDelay().toNanos(); } @Override public void run() { + // Counted from the moment the timer fired, whether or not anything is sent - a channel + // with nobody online is still due again a period from now. + this.dueNanos = clock.getAsLong() + timing.period().toNanos(); + // The master switch is read each tick rather than at schedule time, so /automessage // disable takes effect without tearing down and rebuilding every channel's task. if (!masterSwitch.getAsBoolean()) { return; } - final Collection onlinePlayers = server.getOnlinePlayers(); - if (onlinePlayers.isEmpty()) { - return; + send(); + } + + /** + * Sends this channel's next announcement, whatever the master switch says. + */ + public Outcome send() { + final Collection online = viewers.online(); + if (online.isEmpty()) { + return Outcome.nobodyOnline(); } final List messages = repository.findByChannel(channel); if (messages.isEmpty()) { - return; + return Outcome.noMessages(); } - messageDispatcher.dispatchNext(messages, DispatchTarget.players(onlinePlayers)); + return messageDispatcher.dispatchNext(messages, DispatchTarget.viewers(online)) + .map(Outcome::sent) + .orElseGet(Outcome::noMessages); + } + + public Duration untilDue() { + final long remaining = dueNanos - clock.getAsLong(); + return remaining <= 0L ? Duration.ZERO : Duration.ofNanos(remaining); } public AnnouncementChannel channel() { @@ -74,4 +99,28 @@ public Duration delay() { public Duration period() { return timing.period(); } + + /** + * What came of an attempt to send, so a command can say why nothing arrived. + */ + public record Outcome(Kind kind, Optional message) { + + public enum Kind { + SENT, + NOBODY_ONLINE, + NO_MESSAGES + } + + static Outcome sent(ScheduledMessage message) { + return new Outcome(Kind.SENT, Optional.of(message)); + } + + static Outcome nobodyOnline() { + return new Outcome(Kind.NOBODY_ONLINE, Optional.empty()); + } + + static Outcome noMessages() { + return new Outcome(Kind.NO_MESSAGES, Optional.empty()); + } + } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ScheduledMessageDispatcherFactory.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ScheduledMessageDispatcherFactory.java index 4d037ac..c741987 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ScheduledMessageDispatcherFactory.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/dispatcher/ScheduledMessageDispatcherFactory.java @@ -4,14 +4,6 @@ import java.util.function.Supplier; -/** - * Builds the dispatcher a single channel uses. - * - *

- * Every channel shares the sender and the audience filter but rotates independently, so the - * selector is the one piece supplied per channel. - *

- */ @FunctionalInterface public interface ScheduledMessageDispatcherFactory { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/BuiltinPlaceholder.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/BuiltinPlaceholder.java index 119f92f..2365d68 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/BuiltinPlaceholder.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/BuiltinPlaceholder.java @@ -1,58 +1,37 @@ package com.github.imdmk.automessage.scheduled.placeholder; -import org.bukkit.Server; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.Locale; import java.util.function.BiFunction; -/** - * Values AutoMessage can substitute into a message without any other plugin installed. - * - *

- * Each constant carries the token exactly as it is written in the configuration, braces included, - * because that is the form Multification matches on. - *

- * - *

- * Only values the Spigot API can answer for belong here. TPS, for one, does not: it is a Paper - * extension with no equivalent in the API this plugin compiles against, and reaching it would mean - * runtime reflection that quietly fails on the servers the plugin claims to support. Servers that - * want it can write {@code %server_tps%} and let PlaceholderAPI answer. - *

- * - *

- * Each constant also declares whether it needs a viewer. That distinction is what lets a - * destination with no single reader - a Discord channel, say - still resolve the half of them that - * describe the server rather than the person reading. - *

- */ public enum BuiltinPlaceholder { - PLAYER("{PLAYER}", Scope.VIEWER, (server, player) -> player.getName()), - DISPLAY_NAME("{DISPLAY_NAME}", Scope.VIEWER, (server, player) -> player.getDisplayName()), - UUID("{UUID}", Scope.VIEWER, (server, player) -> player.getUniqueId().toString()), - WORLD("{WORLD}", Scope.VIEWER, (server, player) -> player.getWorld().getName()), + PLAYER("{PLAYER}", Scope.VIEWER, (viewers, viewer) -> viewer.name()), + DISPLAY_NAME("{DISPLAY_NAME}", Scope.VIEWER, (viewers, viewer) -> viewer.displayName()), + UUID("{UUID}", Scope.VIEWER, (viewers, viewer) -> viewer.uniqueId().toString()), + WORLD("{WORLD}", Scope.VIEWER, (viewers, viewer) -> viewer.world().orElse("")), - ONLINE("{ONLINE}", Scope.SERVER, (server, player) -> Integer.toString(server.getOnlinePlayers().size())), - MAX_PLAYERS("{MAX_PLAYERS}", Scope.SERVER, (server, player) -> Integer.toString(server.getMaxPlayers())), + ONLINE("{ONLINE}", Scope.SERVER, (viewers, viewer) -> Integer.toString(viewers.onlineCount())), + MAX_PLAYERS("{MAX_PLAYERS}", Scope.SERVER, (viewers, viewer) -> Integer.toString(viewers.maxPlayers())), - DATE("{DATE}", Scope.SERVER, (server, player) -> Formats.now(Formats.DATE)), - TIME("{TIME}", Scope.SERVER, (server, player) -> Formats.now(Formats.TIME)); + DATE("{DATE}", Scope.SERVER, (viewers, viewer) -> Formats.now(Formats.DATE)), + TIME("{TIME}", Scope.SERVER, (viewers, viewer) -> Formats.now(Formats.TIME)); - /** Whether a value describes the server or the person reading it. */ - public enum Scope { + private enum Scope { SERVER, VIEWER } + // The token carries its braces because that is exactly what is matched in the text. private final String token; private final Scope scope; - private final BiFunction resolver; + private final BiFunction resolver; - BuiltinPlaceholder(String token, Scope scope, BiFunction resolver) { + BuiltinPlaceholder(String token, Scope scope, BiFunction resolver) { this.token = token; this.scope = scope; this.resolver = resolver; @@ -62,35 +41,22 @@ public String token() { return token; } - public Scope scope() { - return scope; - } - public boolean requiresViewer() { return scope == Scope.VIEWER; } - public String resolve(Server server, Player player) { - return resolver.apply(server, player); + public String resolve(ViewerRegistry viewers, Viewer viewer) { + return resolver.apply(viewers, viewer); } - /** - * Resolves a value that does not depend on who is reading. - * - * @throws IllegalStateException when called for a placeholder that needs a viewer - */ - public String resolveForServer(Server server) { + public String resolveForServer(ViewerRegistry viewers) { if (requiresViewer()) { throw new IllegalStateException(token + " cannot be resolved without a viewer"); } - return resolver.apply(server, null); + return resolver.apply(viewers, null); } - /** - * Enum constants are initialised before the enum's own static fields, so the formatters live - * here rather than beside the constants that use them. - */ private static final class Formats { private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("dd.MM.yyyy", Locale.ROOT); diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/MessagePlaceholders.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/MessagePlaceholders.java index 717af22..9551f5e 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/MessagePlaceholders.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/MessagePlaceholders.java @@ -1,9 +1,9 @@ package com.github.imdmk.automessage.scheduled.placeholder; import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; -import com.eternalcode.multification.notice.Notice; -import org.bukkit.Server; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; import org.jetbrains.annotations.Unmodifiable; import java.util.LinkedHashMap; @@ -11,22 +11,6 @@ import java.util.Map; import java.util.Set; -/** - * The placeholder tokens one message needs, resolved per viewer. - * - *

- * A message is scanned once per broadcast rather than once per player — the tokens it contains do - * not change between viewers, only their values do. On a message that uses no placeholders at all, - * which is most of them, {@link #resolveFor} does no work and allocates nothing. - *

- * - *

- * Values are resolved on the thread that dispatches, which is the main thread. That matters for - * PlaceholderAPI: expansions routinely read world and entity state, and resolving them on the - * async delivery thread would be exactly the kind of off-thread API access this plugin avoids - * elsewhere. - *

- */ public final class MessagePlaceholders { private static final MessagePlaceholders NONE = @@ -62,22 +46,27 @@ public static MessagePlaceholders scan( return new MessagePlaceholders(builtins, externalTokens, externalResolver); } + // Whether the rendered text can differ between two players. A PlaceholderAPI token is + // assumed to, since only the expansion behind it knows, and guessing wrong would send one + // player another player's text. + public boolean viewerScoped() { + if (!externalTokens.isEmpty()) { + return true; + } + for (final BuiltinPlaceholder builtin : builtins) { + if (builtin.requiresViewer()) { + return true; + } + } + return false; + } + public boolean isEmpty() { return builtins.isEmpty() && externalTokens.isEmpty(); } - /** - * Resolves what can be resolved for a destination with no single reader. - * - *

- * A Discord channel has no client language and no player behind it, so the placeholders that - * describe the server answer normally while the ones describing a viewer resolve to nothing. - * Dropping them beats leaving the raw token: a reader seeing "Welcome {PLAYER}" learns only - * that something is broken. - *

- */ @Unmodifiable - public Map resolveWithoutViewer(Server server) { + public Map resolveWithoutViewer(ViewerRegistry viewers) { if (isEmpty()) { return Map.of(); } @@ -87,7 +76,7 @@ public Map resolveWithoutViewer(Server server) { for (final BuiltinPlaceholder builtin : builtins) { resolved.put( builtin.token(), - builtin.requiresViewer() ? "" : builtin.resolveForServer(server) + builtin.requiresViewer() ? "" : builtin.resolveForServer(viewers) ); } @@ -103,7 +92,7 @@ public Map resolveWithoutViewer(Server server) { } @Unmodifiable - public Map resolveFor(Server server, Player viewer) { + public Map resolveFor(ViewerRegistry viewers, Viewer viewer) { if (isEmpty()) { return Map.of(); } @@ -111,7 +100,7 @@ public Map resolveFor(Server server, Player viewer) { final Map resolved = new LinkedHashMap<>(); for (final BuiltinPlaceholder builtin : builtins) { - resolved.put(builtin.token(), builtin.resolve(server, viewer)); + resolved.put(builtin.token(), builtin.resolve(viewers, viewer)); } for (final String token : externalTokens) { diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScanner.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScanner.java index 7341fa7..c93f6a2 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScanner.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScanner.java @@ -1,8 +1,6 @@ package com.github.imdmk.automessage.scheduled.placeholder; -import com.eternalcode.multification.notice.Notice; -import com.eternalcode.multification.notice.NoticePart; -import com.eternalcode.multification.notice.resolver.text.TextContent; +import com.github.imdmk.automessage.notice.Notice; import java.util.LinkedHashSet; import java.util.List; @@ -10,33 +8,14 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * Finds the placeholder tokens a message actually contains. - * - *

- * Resolving every known placeholder for every player would mean asking the server for its player - * count, its TPS and the current time on messages that mention none of them. Scanning the message - * once tells the dispatcher the short list it really has to resolve, which for a typical - * announcement is empty. - *

- * - *

- * Every notice type that carries text — chat, actionbar, title, bossbar — implements - * {@link TextContent}, so one check covers them all; a sound notice simply contributes nothing. - *

- */ public final class PlaceholderScanner { - /** PlaceholderAPI's own syntax, e.g. {@code %vault_eco_balance%}. */ private static final Pattern EXTERNAL_TOKEN = Pattern.compile("%[a-zA-Z0-9_]+%"); private PlaceholderScanner() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * @return the built-in placeholders written somewhere in the message - */ public static Set builtinsIn(List> translations) { final Set found = new LinkedHashSet<>(); final List texts = textsOf(translations); @@ -53,9 +32,6 @@ public static Set builtinsIn(List> translations return found; } - /** - * @return every {@code %...%} token in the message, left for PlaceholderAPI to interpret - */ public static Set externalTokensIn(List> translations) { final Set found = new LinkedHashSet<>(); @@ -85,11 +61,7 @@ private static List textsOf(List> translations) { private static void collect(List notices, List texts) { for (final Notice notice : notices) { - for (final NoticePart part : notice.parts()) { - if (part.content() instanceof TextContent textContent) { - texts.addAll(textContent.contents()); - } - } + texts.addAll(notice.texts()); } } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorProvider.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorProvider.java index cc06078..047550a 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorProvider.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorProvider.java @@ -3,16 +3,6 @@ import java.util.Objects; import java.util.function.Supplier; -/** - * Supplies the {@link MessageSelector} matching the currently configured - * {@link MessageSelectorType}. - * - *

- * Selectors are stateful — {@link SequentialMessageSelector} remembers the position it stopped at. - * The instance is therefore cached and only rebuilt when the configured strategy actually changes, - * so reloading the configuration does not reset the rotation. - *

- */ public final class MessageSelectorProvider implements Supplier { private final Supplier typeSupplier; diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorType.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorType.java index 9cb9235..4cb6051 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorType.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorType.java @@ -2,15 +2,11 @@ public enum MessageSelectorType { - /** Independent draw each time; the same message can come up twice in a row. */ RANDOM, - /** Random order, but every message is shown once before any repeats. */ SHUFFLE, - /** Fixed order, cycling from the top. */ SEQUENTIAL, - /** Random draw biased by each message's {@code weight}. */ WEIGHTED, } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/ShuffleMessageSelector.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/ShuffleMessageSelector.java index 8dbf942..0743907 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/ShuffleMessageSelector.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/ShuffleMessageSelector.java @@ -8,30 +8,12 @@ import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; -/** - * Random order, but every message is shown once before any is repeated. - * - *

- * {@code RANDOM} draws independently each time, so it can show the same announcement three times - * running while another waits ten rounds for its turn. Players read that as a broken plugin. This - * selector deals the messages out like a shuffled deck instead: unpredictable order, no repeats - * until the deck is exhausted, then a fresh shuffle. - *

- */ final class ShuffleMessageSelector implements MessageSelector { - /** Remaining messages of the current deck, consumed from the end. */ private final List deck = new ArrayList<>(); - /** - * The message list the current deck was dealt from. - * - *

- * A reload can add, remove or reorder messages. Carrying on with a deck built from the old - * list would keep announcing entries the administrator has just deleted, so the deck is - * re-dealt whenever the source no longer matches. - *

- */ + // A reload can add, remove or reorder messages; carrying on with a deck dealt from the old + // list would keep announcing entries the administrator has just deleted. private List dealtFrom = List.of(); @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/WeightedMessageSelector.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/WeightedMessageSelector.java index 9059a0e..af26a68 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/WeightedMessageSelector.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/selector/WeightedMessageSelector.java @@ -6,16 +6,6 @@ import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; -/** - * Random selection biased by each message's {@code weight}. - * - *

- * A message of weight 5 comes up five times as often as one of weight 1, which is how a server - * promotes the vote reminder over the tips without letting it crowd them out entirely. Weight 0 - * excludes a message from the rotation without deleting it — useful for parking a seasonal - * announcement until it is wanted again. - *

- */ final class WeightedMessageSelector implements MessageSelector { @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/JoinTrigger.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/JoinTrigger.java index 920218a..1dcf276 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/JoinTrigger.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/JoinTrigger.java @@ -1,17 +1,9 @@ package com.github.imdmk.automessage.scheduled.trigger; -import org.bukkit.entity.Player; - import java.time.Duration; -/** - * Fires for the player who just joined. - * - * @param delay how long to wait after the join before sending; the join itself floods - * chat with the server's own messages, and an announcement landing in the - * middle of that is simply not read - * @param firstJoinOnly restricts the message to players the server has never seen before - */ +// firstJoin is passed in rather than asked of the viewer: "has played here before" is spelled +// differently on every platform, and some cannot answer it at all. public record JoinTrigger(Duration delay, boolean firstJoinOnly) implements MessageTrigger { public JoinTrigger { @@ -20,8 +12,8 @@ public record JoinTrigger(Duration delay, boolean firstJoinOnly) implements Mess } } - public boolean appliesTo(Player player) { - return !firstJoinOnly || !player.hasPlayedBefore(); + public boolean appliesTo(boolean firstJoin) { + return !firstJoinOnly || firstJoin; } @Override diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTrigger.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTrigger.java index 24c517a..024938b 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTrigger.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTrigger.java @@ -2,16 +2,6 @@ import java.time.Duration; -/** - * An event that sends a message, instead of the clock doing it. - * - *

- * A message carrying a trigger is taken out of the timed rotation entirely: it fires when its - * event happens and at no other time. That is what turns AutoMessage from a rotator of adverts - * into something that can greet a first-time player or mark the moment the hundredth player logs - * in. - *

- */ public sealed interface MessageTrigger permits JoinTrigger, PlayerCountTrigger { static JoinTrigger join(Duration delay) { @@ -30,13 +20,10 @@ static PlayerCountTrigger playerCount(int threshold) { enum Type { - /** Sent to a player as they join. */ JOIN, - /** Sent to a player the first time they ever join. */ FIRST_JOIN, - /** Broadcast to everyone the moment the online count reaches a threshold. */ PLAYER_COUNT } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializer.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializer.java index 9a013c6..b631be5 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializer.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializer.java @@ -1,7 +1,7 @@ package com.github.imdmk.automessage.scheduled.trigger; -import com.github.imdmk.automessage.platform.time.DurationFormatter; -import com.github.imdmk.automessage.platform.time.DurationParser; +import com.github.imdmk.automessage.notice.time.DurationFormatter; +import com.github.imdmk.automessage.notice.time.DurationParser; import eu.okaeri.configs.schema.GenericsDeclaration; import eu.okaeri.configs.serdes.DeserializationData; import eu.okaeri.configs.serdes.ObjectSerializer; @@ -51,7 +51,6 @@ public MessageTrigger deserialize(DeserializationData data, @NotNull GenericsDec }; } - /** Only the join triggers carry a delay; on any other type the key is not read. */ private static Duration delayOf(DeserializationData data) { return data.containsKey("delay") ? DurationParser.parse(data.get("delay", String.class)) diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerService.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerService.java index 7a1e017..e926996 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerService.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerService.java @@ -6,96 +6,84 @@ import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter; import com.github.imdmk.automessage.scheduled.dispatcher.DispatchTarget; import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcher; -import org.bukkit.Server; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext; import java.time.Duration; import java.util.Collection; import java.util.List; -/** - * Decides what a triggering event should send, and to whom. - * - *

- * Kept separate from the Bukkit listener so the rules about who receives what can be exercised - * without a running server. - *

- */ public final class MessageTriggerService { - private final Server server; + private final ViewerRegistry viewers; private final TaskScheduler taskScheduler; private final ScheduledMessageRepository repository; private final MessageDispatcher dispatcher; private final AudienceFilter filter; + private final AudienceContext audienceContext; private final PlayerCountMilestones milestones; public MessageTriggerService( - Server server, + ViewerRegistry viewers, TaskScheduler taskScheduler, ScheduledMessageRepository repository, MessageDispatcher dispatcher, AudienceFilter filter, + AudienceContext audienceContext, PlayerCountMilestones milestones ) { - this.server = server; + this.viewers = viewers; this.taskScheduler = taskScheduler; this.repository = repository; this.dispatcher = dispatcher; this.filter = filter; + this.audienceContext = audienceContext; this.milestones = milestones; } - /** - * Sends the join and first-join messages this player qualifies for. - */ - public void onJoin(Player player) { + public void onJoin(Viewer viewer, boolean firstJoin) { for (final MessageTrigger.Type type : List.of(MessageTrigger.Type.JOIN, MessageTrigger.Type.FIRST_JOIN)) { for (final ScheduledMessage message : repository.findByTrigger(type)) { - if (message.trigger() instanceof JoinTrigger join && join.appliesTo(player)) { - sendToJoiner(player, message, join.delay()); + if (message.trigger() instanceof JoinTrigger join && join.appliesTo(firstJoin)) { + sendToJoiner(viewer, message, join.delay()); } } } } - private void sendToJoiner(Player player, ScheduledMessage message, Duration delay) { + private void sendToJoiner(Viewer viewer, ScheduledMessage message, Duration delay) { if (delay.isZero()) { - dispatchTo(player, message); + dispatchTo(viewer, message); return; } taskScheduler.runLaterSync(() -> { // The player can be gone by the time the delay elapses; sending to a disconnected // player is at best wasted work. - if (player.isOnline()) { - dispatchTo(player, message); + if (viewer.isOnline()) { + dispatchTo(viewer, message); } }, delay); } - private void dispatchTo(Player player, ScheduledMessage message) { - // Audience rules still apply: a join message restricted to a permission should not reach - // a player who lacks it just because the trigger fired for them. - if (filter.allows(player, message)) { - dispatcher.dispatch(message, DispatchTarget.player(player)); + private void dispatchTo(Viewer viewer, ScheduledMessage message) { + // Not the rules themselves - the dispatcher applies those to whoever it is handed. This + // asks first so a trigger that reaches nobody is not dispatched at all: the dispatcher + // tells its observers about every announcement it is given, recipients or none, and a + // greeting for a player who fails the rules would otherwise show up in the statistics. + if (filter.allows(viewer, message, audienceContext)) { + dispatcher.dispatch(message, DispatchTarget.viewer(viewer)); } } - /** - * Announces any milestone the current online count has just reached. - * - * @param onlineCount the count to test against, passed in because during a join event the - * server's own collection has already been updated and during a quit event - * it has not - */ public void onPlayerCountChanged(int onlineCount) { final List messages = repository.findByTrigger(MessageTrigger.Type.PLAYER_COUNT); if (messages.isEmpty()) { return; } - final Collection online = server.getOnlinePlayers(); + final Collection online = viewers.online(); if (online.isEmpty()) { return; } @@ -103,12 +91,8 @@ public void onPlayerCountChanged(int onlineCount) { for (final ScheduledMessage message : messages) { if (message.trigger() instanceof PlayerCountTrigger trigger && milestones.reach(trigger.threshold(), onlineCount)) { - dispatcher.dispatch(message, DispatchTarget.players(online)); + dispatcher.dispatch(message, DispatchTarget.viewers(online)); } } } - - public void reset() { - milestones.reset(); - } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountMilestones.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountMilestones.java index 7ea80cf..30abb34 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountMilestones.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountMilestones.java @@ -3,22 +3,12 @@ import java.util.Set; import java.util.concurrent.ConcurrentHashMap; -/** - * Remembers which player-count thresholds have already been announced. - * - *

- * A threshold fires when the count crosses it going up, and rearms only once the count has fallen - * back below. Without that, a server hovering on the boundary would announce the same milestone - * every time a single player logged in or out. - *

- */ public final class PlayerCountMilestones { private final Set announced = ConcurrentHashMap.newKeySet(); - /** - * @return true when this threshold has just been reached and should be announced - */ + // A threshold rearms only once the count has fallen back below it. Without that, a server + // hovering on the boundary announces the same milestone every time one player logs in or out. public boolean reach(int threshold, int onlineCount) { if (onlineCount < threshold) { announced.remove(threshold); @@ -27,8 +17,4 @@ public boolean reach(int threshold, int onlineCount) { return announced.add(threshold); } - - public void reset() { - announced.clear(); - } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountTrigger.java b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountTrigger.java index 632f2bf..6441625 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountTrigger.java +++ b/automessage-core/src/main/java/com/github/imdmk/automessage/scheduled/trigger/PlayerCountTrigger.java @@ -1,15 +1,5 @@ package com.github.imdmk.automessage.scheduled.trigger; -/** - * Fires once when the online player count reaches a threshold. - * - *

- * Milestones like "100 players online for the first time today" only land if they are announced at - * the moment they happen. - *

- * - * @param threshold online count that triggers the message, inclusive - */ public record PlayerCountTrigger(int threshold) implements MessageTrigger { public PlayerCountTrigger { diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/AutoMessageEndToEndTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/AutoMessageEndToEndTest.java new file mode 100644 index 0000000..961732e --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/AutoMessageEndToEndTest.java @@ -0,0 +1,317 @@ +package com.github.imdmk.automessage; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.github.imdmk.automessage.support.RecordingViewer; +import com.github.imdmk.automessage.support.TestPlatform; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +// The plugin as a whole, on a server that only exists in this test. +// +// Every other test here checks one piece. This one builds the real AutoMessage from a real +// configuration and drives a real broadcast all the way to what a player would see - which is the +// only place the wiring between those pieces is exercised at all. +class AutoMessageEndToEndTest { + + @TempDir + Path dataFolder; + + private TestPlatform platform; + private AutoMessage automessage; + + private AutoMessage start(TestPlatform platform) { + this.platform = platform; + this.automessage = new AutoMessage( + platform, + mock(PluginLogger.class), + dataFolder.toFile(), + ExternalPlaceholderResolver.disabled() + ); + + return automessage; + } + + @AfterEach + void tearDown() { + if (automessage != null) { + automessage.shutdown(); + } + } + + @Test + @DisplayName("should write a usable configuration and schedule the channel it declares") + void shouldStartUp() { + start(TestPlatform.fullServer()); + + assertThat(new File(dataFolder.toFile(), "config.yml")).exists(); + assertThat(new File(dataFolder.toFile(), "scheduledMessages.yml")).exists(); + assertThat(new File(dataFolder.toFile(), "lang/en.yml")).exists(); + + // One channel ships by default, so exactly one repeating task should be running. + assertThat(platform.scheduler().runningTimers()).isEqualTo(1); + } + + @Test + @DisplayName("should deal every shipped announcement once before repeating any") + void shouldBroadcastTheWholeRotation() { + final RecordingViewer steve = RecordingViewer.english("Steve", "rank.vip"); + start(TestPlatform.fullServer().join(steve)); + + // Eight of the nine shipped messages are on the rotation; the ninth waits for a first + // join. SHUFFLE deals a whole deck, so eight ticks is exactly one of each. + for (int tick = 0; tick < 8; tick++) { + platform.scheduler().tick(); + } + + assertThat(steve.everythingSeen()) + .hasSize(9) + .doesNotHaveDuplicates(); + + // A sound rides along with the vote reminder, which is how we know a multi-part notice + // survived the whole chain. + assertThat(steve.sounds).hasSize(1); + } + + @Test + @DisplayName("should count what it announced, so /automessage stats has something to report") + void shouldCountWhatItAnnounced() { + start(TestPlatform.fullServer().join(RecordingViewer.english("Steve"))); + + platform.scheduler().tick(); + platform.scheduler().tick(); + + // The counter rides along with the Discord mirror on one observer; wiring it in behind + // something that throws, or not at all, leaves the command permanently empty. + assertThat(automessage.statistics().total()).isEqualTo(2); + assertThat(automessage.statistics().snapshot()).hasSize(2); + } + + @Test + @DisplayName("should substitute placeholders with values from this server") + void shouldSubstitutePlaceholders() { + final RecordingViewer steve = RecordingViewer.english("Steve"); + start(TestPlatform.fullServer().join(steve)); + + for (int tick = 0; tick < 8; tick++) { + platform.scheduler().tick(); + } + + assertThat(steve.chat) + .anySatisfy(line -> assertThat(line).contains("There are 1/" + TestPlatform.MAX_PLAYERS)); + + assertThat(steve.everythingSeen()).noneMatch(line -> line.contains("{")); + } + + @Test + @DisplayName("should serve each player the language their client reports") + void shouldServeEachPlayerTheirLanguage() { + final RecordingViewer english = RecordingViewer.english("Steve"); + final RecordingViewer polish = new RecordingViewer("Kuba", "pl_pl", "world"); + + start(TestPlatform.fullServer().join(english).join(polish)); + + for (int tick = 0; tick < 8; tick++) { + platform.scheduler().tick(); + } + + assertThat(english.chat).anySatisfy(line -> assertThat(line).contains("Enjoying the server")); + assertThat(polish.chat).anySatisfy(line -> assertThat(line).contains("Podoba Ci")); + + // The same broadcast, two languages: nobody sees the other's text. + assertThat(polish.chat).noneMatch(line -> line.contains("Enjoying the server")); + } + + @Test + @DisplayName("should keep a permission-gated announcement from whoever lacks the permission") + void shouldHonourAudienceRules() { + final RecordingViewer vip = RecordingViewer.english("Vip", "rank.vip"); + final RecordingViewer plain = RecordingViewer.english("Plain"); + + start(TestPlatform.fullServer().join(vip).join(plain)); + + for (int tick = 0; tick < 8; tick++) { + platform.scheduler().tick(); + } + + assertThat(vip.chat).anySatisfy(line -> assertThat(line).contains("VIP tip")); + assertThat(plain.chat).noneMatch(line -> line.contains("VIP tip")); + } + + @Test + @DisplayName("should send nothing to a player who turned announcements off") + void shouldSkipAPlayerWhoTurnedAnnouncementsOff() { + final RecordingViewer quiet = RecordingViewer.english("Quiet"); + final RecordingViewer listening = RecordingViewer.english("Listening"); + + start(TestPlatform.fullServer().join(quiet).join(listening)); + automessage.optOut().toggle(quiet.uniqueId()); + + platform.scheduler().tick(); + + assertThat(listening.everythingSeen()).isNotEmpty(); + assertThat(quiet.everythingSeen()).isEmpty(); + assertThat(quiet.sounds).isEmpty(); + } + + @Test + @DisplayName("should not greet a player who turned announcements off either") + void shouldNotGreetAPlayerWhoTurnedAnnouncementsOff() { + final RecordingViewer quiet = RecordingViewer.english("Quiet"); + + start(TestPlatform.fullServer().join(quiet)); + automessage.optOut().toggle(quiet.uniqueId()); + + // A greeting is dispatched by an event rather than by the rotation, and asking for quiet + // has to cover both - otherwise turning announcements off still greets you on the way in. + automessage.triggerService().onJoin(quiet, true); + platform.scheduler().runDelayed(); + + assertThat(quiet.everythingSeen()).isEmpty(); + } + + @Test + @DisplayName("should greet a first-time player and nobody else") + void shouldGreetAFirstJoin() { + final RecordingViewer newcomer = RecordingViewer.english("Newcomer"); + final RecordingViewer regular = RecordingViewer.english("Regular"); + + start(TestPlatform.fullServer().join(newcomer).join(regular)); + + automessage.triggerService().onJoin(newcomer, true); + automessage.triggerService().onJoin(regular, false); + + // The welcome waits three seconds so the join spam settles first. + platform.scheduler().runDelayed(); + + assertThat(newcomer.chat).anySatisfy(line -> assertThat(line).contains("Welcome to the server, Newcomer")); + assertThat(regular.chat).isEmpty(); + } + + @Test + @DisplayName("should not greet somebody who left while the greeting was waiting") + void shouldNotGreetAPlayerWhoLeft() { + final RecordingViewer newcomer = RecordingViewer.english("Newcomer"); + start(TestPlatform.fullServer().join(newcomer)); + + automessage.triggerService().onJoin(newcomer, true); + newcomer.disconnect(); + + platform.scheduler().runDelayed(); + + assertThat(newcomer.chat).isEmpty(); + } + + @Test + @DisplayName("should apply an edited configuration on reload, without restarting") + void shouldApplyAnEditedConfigurationOnReload() throws IOException { + final RecordingViewer steve = RecordingViewer.english("Steve"); + start(TestPlatform.fullServer().join(steve)); + + final Path messages = dataFolder.resolve("scheduledMessages.yml"); + Files.writeString(messages, """ + messages: + - name: only-one + """); + + Files.writeString(dataFolder.resolve("lang/en.yml"), """ + announcements: + only-one: + - "The only message left" + """); + + automessage.configReloadService().reload(); + platform.scheduler().tick(); + + assertThat(steve.chat).containsExactly("The only message left"); + } + + @Test + @DisplayName("should stop everything it started when the server shuts down") + void shouldStopEverythingOnShutdown() { + final RecordingViewer steve = RecordingViewer.english("Steve"); + start(TestPlatform.fullServer().join(steve)); + + automessage.shutdown(); + this.automessage = null; + + assertThat(platform.scheduler().isShutdown()).isTrue(); + assertThat(platform.scheduler().runningTimers()).isZero(); + + steve.forget(); + platform.scheduler().tick(); + + assertThat(steve.everythingSeen()).isEmpty(); + } + + @Test + @DisplayName("should shut down cleanly even when the configuration cannot be written") + void shouldShutDownWithAnUnwritableConfiguration() { + start(TestPlatform.fullServer()); + + // A read-only file, not a read-only folder: the folder permission does not stop a write + // to a file that already exists, so a test built on it passes without ever failing. + final File config = new File(dataFolder.toFile(), "config.yml"); + assertThat(config.setReadOnly()).isTrue(); + + try { + assertThatCode(() -> automessage.shutdown()).doesNotThrowAnyException(); + + // The point of the fix: the steps after the failing save still ran, so nothing is + // left holding threads under a class loader nobody can collect. + assertThat(platform.scheduler().isShutdown()).isTrue(); + } finally { + assertThat(config.setWritable(true)).isTrue(); + this.automessage = null; + } + } + + @Test + @DisplayName("should send nothing at all while nobody is online") + void shouldSendNothingToAnEmptyServer() { + start(TestPlatform.fullServer()); + + assertThatCode(() -> { + for (int tick = 0; tick < 8; tick++) { + platform.scheduler().tick(); + } + }).doesNotThrowAnyException(); + } + + @Test + @DisplayName("should leave out what the platform cannot do, all the way through") + void shouldRespectPlatformCapabilities() { + final RecordingViewer steve = new RecordingViewer("Steve", "en_us", null); + + // A proxy: no worlds, no playtime, no first join, and no permissions of its own. + start(new TestPlatform(com.github.imdmk.automessage.platform.capability.Capabilities.of( + com.github.imdmk.automessage.platform.capability.Capability.SOUND_NOTICE, + com.github.imdmk.automessage.platform.capability.Capability.BOSSBAR_NOTICE, + com.github.imdmk.automessage.platform.capability.Capability.TITLE_NOTICE + )).join(steve)); + + for (int tick = 0; tick < 8; tick++) { + platform.scheduler().tick(); + } + + final List seen = steve.everythingSeen(); + + // The examples that need a capability this platform lacks were never written to the file, + // so they cannot reach anybody. + assertThat(seen).isNotEmpty(); + assertThat(seen).noneMatch(line -> line.contains("VIP tip")); + assertThat(seen).noneMatch(line -> line.contains("New here?")); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/GroupedBroadcastTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/GroupedBroadcastTest.java new file mode 100644 index 0000000..09e2216 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/GroupedBroadcastTest.java @@ -0,0 +1,126 @@ +package com.github.imdmk.automessage; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.github.imdmk.automessage.support.RecordingViewer; +import com.github.imdmk.automessage.support.TestPlatform; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +// One announcement now reaches everybody who can read the same text from a single render, so +// these check that what each player ends up seeing is still exactly what they saw before. +class GroupedBroadcastTest { + + private static final String MESSAGE = "group-test"; + + @TempDir + Path dataFolder; + + private AutoMessage automessage; + private TestPlatform platform; + + private void announcement(String code, String body) throws IOException { + Files.createDirectories(dataFolder.resolve("lang")); + Files.writeString(dataFolder.resolve("lang/" + code + ".yml"), + "announcements:\n " + MESSAGE + ":\n" + body); + } + + private void onlyMessage() throws IOException { + Files.writeString(dataFolder.resolve("scheduledMessages.yml"), "messages:\n- name: " + MESSAGE + "\n"); + } + + private void start(RecordingViewer... viewers) { + this.platform = TestPlatform.fullServer(); + for (final RecordingViewer viewer : viewers) { + platform.join(viewer); + } + this.automessage = new AutoMessage( + platform, mock(PluginLogger.class), dataFolder.toFile(), ExternalPlaceholderResolver.disabled() + ); + } + + @AfterEach + void tearDown() { + if (automessage != null) { + automessage.shutdown(); + } + } + + @Test + @DisplayName("a title reaches every player in the group, not just the first") + void everyoneInTheGroupSeesTheTitle() throws IOException { + onlyMessage(); + announcement("en", " - title: \"EVENT\"\n subtitle: \"soon\"\n"); + final RecordingViewer steve = RecordingViewer.english("Steve"); + final RecordingViewer alex = RecordingViewer.english("Alex"); + start(steve, alex); + + platform.scheduler().tick(); + + // Adventure's own group audience takes a title apart into parts a platform may not + // implement; getting this wrong drops titles for everybody once two players are online. + assertThat(steve.titles).hasSize(1); + assertThat(alex.titles).hasSize(1); + } + + @Test + @DisplayName("grouping never hands a player another language's text") + void eachLanguageKeepsItsOwnText() throws IOException { + onlyMessage(); + announcement("en", " - \"Hello\"\n"); + announcement("pl", " - \"Czesc\"\n"); + final RecordingViewer steve = RecordingViewer.english("Steve"); + final RecordingViewer kuba = new RecordingViewer("Kuba", "pl_pl", "world"); + start(steve, kuba); + + platform.scheduler().tick(); + + assertThat(steve.chat).containsExactly("Hello"); + assertThat(kuba.chat).containsExactly("Czesc"); + } + + @Test + @DisplayName("a message that names the player is still built for each of them") + void viewerPlaceholdersStayPerPlayer() throws IOException { + onlyMessage(); + announcement("en", " - \"Hi {PLAYER}\"\n"); + final RecordingViewer steve = RecordingViewer.english("Steve"); + final RecordingViewer alex = RecordingViewer.english("Alex"); + start(steve, alex); + + platform.scheduler().tick(); + + assertThat(steve.chat).containsExactly("Hi Steve"); + assertThat(alex.chat).containsExactly("Hi Alex"); + } + + @Test + @DisplayName("a placeholder added by a reload is resolved rather than printed raw") + void reloadIsSeenByThePlaceholderScan() throws IOException { + onlyMessage(); + announcement("en", " - \"Players online\"\n"); + final RecordingViewer steve = RecordingViewer.english("Steve"); + start(steve); + + platform.scheduler().tick(); + assertThat(steve.chat).containsExactly("Players online"); + + // Which placeholders a message contains is remembered between announcements. A reload + // that adds one has to be noticed, or the token reaches the player as literal text. + announcement("en", " - \"Players online: {ONLINE}\"\n"); + automessage.configReloadService().reload(); + steve.forget(); + + platform.scheduler().tick(); + assertThat(steve.chat).containsExactly("Players online: 1"); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/UpcomingPreviewTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/UpcomingPreviewTest.java new file mode 100644 index 0000000..33ae20c --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/UpcomingPreviewTest.java @@ -0,0 +1,132 @@ +package com.github.imdmk.automessage; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.github.imdmk.automessage.scheduled.dispatcher.ChannelPreview; +import com.github.imdmk.automessage.support.RecordingViewer; +import com.github.imdmk.automessage.support.TestPlatform; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +// /automessage next answers what is coming, so what it says has to be what arrives - and asking +// must not itself move the rotation on. +class UpcomingPreviewTest { + + @TempDir + Path dataFolder; + + private TestPlatform platform; + private AutoMessage automessage; + private final RecordingViewer steve = RecordingViewer.english("Steve"); + + private void channels(String yaml) throws IOException { + Files.writeString(dataFolder.resolve("config.yml"), + "enabled: true\nfallbackLanguage: en\nchannels:\n" + yaml); + } + + private static String channel(String name, boolean enabled, String selector) { + return " - name: " + name + "\n enabled: " + enabled + + "\n initialDelay: 0s\n period: 1m\n selector: " + selector + "\n"; + } + + // Each message says its own name, so what a player received names what was chosen. + private void messages(String... names) throws IOException { + final StringBuilder scheduled = new StringBuilder("messages:\n"); + final StringBuilder english = new StringBuilder("announcements:\n"); + + for (final String name : names) { + scheduled.append("- name: ").append(name).append('\n'); + english.append(" ").append(name).append(":\n - \"").append(name).append("\"\n"); + } + + Files.writeString(dataFolder.resolve("scheduledMessages.yml"), scheduled.toString()); + Files.createDirectories(dataFolder.resolve("lang")); + Files.writeString(dataFolder.resolve("lang/en.yml"), english.toString()); + } + + private void start() { + this.platform = TestPlatform.fullServer().join(steve); + this.automessage = new AutoMessage( + platform, mock(PluginLogger.class), dataFolder.toFile(), ExternalPlaceholderResolver.disabled() + ); + } + + private List upcoming() { + return automessage.dispatcherService().upcoming(); + } + + @AfterEach + void tearDown() { + if (automessage != null) { + automessage.shutdown(); + } + } + + @Test + @DisplayName("names the message that actually arrives next") + void predictsWhatArrives() throws IOException { + messages("alpha", "beta", "gamma"); + channels(channel("default", true, "SEQUENTIAL")); + start(); + + final ChannelPreview preview = upcoming().getFirst(); + assertThat(preview.kind()).isEqualTo(ChannelPreview.Kind.NEXT); + + platform.scheduler().tick(); + + assertThat(steve.chat).containsExactly(preview.message()); + } + + @Test + @DisplayName("asking does not use up the message it names") + void askingDoesNotAdvanceTheRotation() throws IOException { + messages("alpha", "beta", "gamma"); + channels(channel("default", true, "SEQUENTIAL")); + start(); + + upcoming(); + upcoming(); + platform.scheduler().tick(); + + // Two questions must not skip two messages. Deliberately not three, which with three + // messages would wrap right back to the front and pass either way. + assertThat(steve.chat).containsExactly("alpha"); + } + + @Test + @DisplayName("admits it cannot know on a channel that draws at random") + void randomIsAdmittedRatherThanGuessed() throws IOException { + messages("alpha", "beta"); + channels(channel("default", true, "RANDOM")); + start(); + + assertThat(upcoming()).singleElement() + .extracting(ChannelPreview::kind) + .isEqualTo(ChannelPreview.Kind.UNPREDICTABLE); + } + + @Test + @DisplayName("says which channels are switched off and which have nothing to say") + void reportsDisabledAndEmptyChannels() throws IOException { + messages("alpha"); + channels(channel("default", false, "SEQUENTIAL") + channel("ads", true, "SEQUENTIAL")); + start(); + + assertThat(upcoming()) + .extracting(ChannelPreview::channel, ChannelPreview::kind) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("default", ChannelPreview.Kind.DISABLED), + org.assertj.core.groups.Tuple.tuple("ads", ChannelPreview.Kind.EMPTY) + ); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgumentTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgumentTest.java similarity index 92% rename from automessage-core/src/test/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgumentTest.java rename to automessage-core/src/test/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgumentTest.java index 9d5bf7c..57e3fcb 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/litecommands/argument/ScheduledMessageArgumentTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/command/argument/ScheduledMessageArgumentTest.java @@ -1,4 +1,4 @@ -package com.github.imdmk.automessage.platform.litecommands.argument; +package com.github.imdmk.automessage.command.argument; import com.github.imdmk.automessage.scheduled.ScheduledMessage; import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; @@ -7,7 +7,6 @@ import dev.rollczi.litecommands.argument.Argument; import dev.rollczi.litecommands.invocation.Invocation; import dev.rollczi.litecommands.suggestion.SuggestionContext; -import org.bukkit.command.CommandSender; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -26,12 +25,13 @@ class ScheduledMessageArgumentTest { private static final ScheduledMessage SECOND = message("second-message"); @SuppressWarnings("unchecked") - private final Invocation invocation = mock(Invocation.class); + private final Invocation invocation = mock(Invocation.class); @SuppressWarnings("unchecked") private final Argument argument = mock(Argument.class); - private final ScheduledMessageArgument resolver = new ScheduledMessageArgument(new FakeRepository()); + private final ScheduledMessageArgument resolver = + new ScheduledMessageArgument<>(new FakeRepository()); private static ScheduledMessage message(String name) { return new ScheduledMessage(name, List.of()); diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/config/CapabilityFilterTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/config/CapabilityFilterTest.java new file mode 100644 index 0000000..9f9c713 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/config/CapabilityFilterTest.java @@ -0,0 +1,86 @@ +package com.github.imdmk.automessage.config; + +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import eu.okaeri.configs.serdes.OkaeriSerdesPack; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class CapabilityFilterTest { + + @TempDir + Path dataFolder; + + public static final class PlatformDependentConfig extends ConfigSection { + + public boolean enabled = true; + + @RequiresCapability(Capability.WORLD_RULE) + public String defaultWorld = "world"; + + @Override + public OkaeriSerdesPack getSerdesPack() { + return registry -> { + }; + } + + @Override + public String getFileName() { + return "platform-dependent.yml"; + } + } + + private String write(Capabilities capabilities) throws IOException { + final ConfigManager manager = + new ConfigManager(mock(PluginLogger.class), dataFolder.toFile(), capabilities); + + manager.create(PlatformDependentConfig.class); + manager.saveAll(); + + return Files.readString(new File(dataFolder.toFile(), "platform-dependent.yml").toPath()); + } + + @Test + @DisplayName("should leave out an option the platform cannot honour") + void shouldOmitUnsupportedOption() throws IOException { + final String yaml = write(Capabilities.of(Capability.TITLE_NOTICE)); + + assertThat(yaml).contains("enabled"); + assertThat(yaml).doesNotContain("defaultWorld"); + } + + @Test + @DisplayName("should write the option where the platform supports it") + void shouldKeepSupportedOption() throws IOException { + final String yaml = write(Capabilities.all()); + + assertThat(yaml).contains("enabled"); + assertThat(yaml).contains("defaultWorld"); + } + + @Test + @DisplayName("should reload a filtered config without resurrecting the removed option") + void shouldSurviveReload() throws IOException { + final ConfigManager manager = new ConfigManager( + mock(PluginLogger.class), dataFolder.toFile(), Capabilities.of(Capability.TITLE_NOTICE) + ); + + manager.create(PlatformDependentConfig.class); + manager.loadAll(); + manager.saveAll(); + + final String yaml = Files.readString(new File(dataFolder.toFile(), "platform-dependent.yml").toPath()); + + assertThat(yaml).doesNotContain("defaultWorld"); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/config/CommentDirectivesTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/config/CommentDirectivesTest.java new file mode 100644 index 0000000..72afb23 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/config/CommentDirectivesTest.java @@ -0,0 +1,84 @@ +package com.github.imdmk.automessage.config; + +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class CommentDirectivesTest { + + private static final Capabilities WITH_WORLDS = Capabilities.of(Capability.WORLD_RULE); + private static final Capabilities WITHOUT_WORLDS = Capabilities.of(Capability.PLAYTIME_RULE); + + private static final String[] BLOCK = { + "# who receives it", + "@requires WORLD_RULE", + "# - type: WORLD", + "# worlds: [world]", + "@end", + "# - type: PLAYER_COUNT" + }; + + @Test + @DisplayName("should keep a documented block the platform can honour") + void shouldKeepASupportedBlock() { + assertThat(CommentDirectives.apply(BLOCK, WITH_WORLDS)).containsExactly( + "# who receives it", + "# - type: WORLD", + "# worlds: [world]", + "# - type: PLAYER_COUNT" + ); + } + + @Test + @DisplayName("should drop a documented block the platform cannot honour") + void shouldDropAnUnsupportedBlock() { + assertThat(CommentDirectives.apply(BLOCK, WITHOUT_WORLDS)).containsExactly( + "# who receives it", + "# - type: PLAYER_COUNT" + ); + } + + @Test + @DisplayName("should leave a comment with no directives exactly as it was") + void shouldLeaveAPlainCommentAlone() { + final String[] plain = {"# nothing platform-specific here"}; + + assertThat(CommentDirectives.apply(plain, WITHOUT_WORLDS)).isSameAs(plain); + } + + @Test + @DisplayName("should refuse a capability name that does not exist") + void shouldRefuseAnUnknownCapability() { + // A typo would quietly keep documentation nobody can act on - the exact failure this + // whole mechanism exists to prevent. + final String[] lines = {"@requires WROLD_RULE", "# text", "@end"}; + + assertThatThrownBy(() -> CommentDirectives.apply(lines, WITH_WORLDS)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("WROLD_RULE"); + } + + @Test + @DisplayName("should refuse a block that was never closed") + void shouldRefuseAnUnclosedBlock() { + final String[] lines = {"@requires WORLD_RULE", "# text"}; + + assertThatThrownBy(() -> CommentDirectives.apply(lines, WITH_WORLDS)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("@end"); + } + + @Test + @DisplayName("should refuse an end with nothing open") + void shouldRefuseAStrayEnd() { + final String[] lines = {"# text", "@end"}; + + assertThatThrownBy(() -> CommentDirectives.apply(lines, WITH_WORLDS)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("@requires"); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/config/ConfigManagerTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/config/ConfigManagerTest.java deleted file mode 100644 index 054cc88..0000000 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/config/ConfigManagerTest.java +++ /dev/null @@ -1,128 +0,0 @@ -package com.github.imdmk.automessage.config; - -import com.github.imdmk.automessage.platform.logger.PluginLogger; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertSame; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.any; -import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -class ConfigManagerTest { - - private ConfigManager manager; - - private ConfigFactory factory; - private ConfigBinder binder; - private ConfigLifecycle lifecycle; - - private final PluginLogger logger = mock(PluginLogger.class); - private final File dataFolder = new File("build/test-configs"); - - @BeforeEach - @DisplayName("Setup test environment and inject mock dependencies") - void setUp() { - manager = new ConfigManager(logger, dataFolder); - - factory = mock(ConfigFactory.class); - binder = mock(ConfigBinder.class); - lifecycle = mock(ConfigLifecycle.class); - - inject("factory", factory); - inject("binder", binder); - inject("lifecycle", lifecycle); - } - - @Test - @DisplayName("create(): should instantiate, bind, initialize and register config") - void create_shouldInstantiateBindInitializeAndRegister() { - // given - SampleConfig config = new SampleConfig(); - when(factory.create(SampleConfig.class)).thenReturn(config); - - // when - SampleConfig result = manager.create(SampleConfig.class); - - // then - assertSame(config, result); - - verify(factory).create(SampleConfig.class); - verify(binder).bind(eq(config), any(File.class)); - verify(lifecycle).initialize(config); - - assertTrue(manager.getConfigs().contains(config)); - assertSame(config, manager.get(SampleConfig.class)); - } - - @Test - @DisplayName("createAll(): should call create() for each provided config class") - void createAll_shouldCreateEachConfig() { - when(factory.create(SampleConfig.class)).thenReturn(new SampleConfig()); - - manager.createAll(List.of(SampleConfig.class)); - - verify(factory).create(SampleConfig.class); - } - - @Test - @DisplayName("require(): should throw when config has not been created earlier") - void require_shouldThrowWhenNotCreated() { - assertThrows(IllegalStateException.class, () -> manager.require(SampleConfig.class)); - } - - @Test - @DisplayName("loadAll(): should delegate loading to lifecycle") - void loadAll_shouldDelegateToLifecycle() { - SampleConfig config = new SampleConfig(); - when(factory.create(SampleConfig.class)).thenReturn(config); - - manager.create(SampleConfig.class); - manager.loadAll(); - - verify(lifecycle).load(config); - } - - @Test - @DisplayName("saveAll(): should delegate saving to lifecycle") - void saveAll_shouldDelegateToLifecycle() { - SampleConfig config = new SampleConfig(); - when(factory.create(SampleConfig.class)).thenReturn(config); - - manager.create(SampleConfig.class); - manager.saveAll(); - - verify(lifecycle).save(config); - } - - @Test - @DisplayName("clearAll(): should remove all configs from registry") - void clearAll_shouldRemoveAllConfigs() { - when(factory.create(SampleConfig.class)).thenReturn(new SampleConfig()); - - manager.create(SampleConfig.class); - manager.clearAll(); - - assertTrue(manager.getConfigs().isEmpty()); - assertNull(manager.get(SampleConfig.class)); - } - - private void inject(String field, Object value) { - try { - var f = ConfigManager.class.getDeclaredField(field); - f.setAccessible(true); - f.set(manager, value); - } catch (Exception e) { - throw new RuntimeException(e); - } - } -} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/config/PlatformConfigTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/config/PlatformConfigTest.java new file mode 100644 index 0000000..657baf3 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/config/PlatformConfigTest.java @@ -0,0 +1,134 @@ +package com.github.imdmk.automessage.config; + +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class PlatformConfigTest { + + private static final Capabilities PROXY = Capabilities.of( + Capability.PERMISSION_RULE, + Capability.GROUP_RULE, + Capability.SOUND_NOTICE, + Capability.BOSSBAR_NOTICE, + Capability.TITLE_NOTICE, + Capability.METRICS + ); + + private static final Capabilities EMBEDDED = Capabilities.of( + Capability.SOUND_NOTICE, + Capability.BOSSBAR_NOTICE, + Capability.TITLE_NOTICE + ); + + @TempDir + Path bukkitFolder; + + @TempDir + Path proxyFolder; + + @TempDir + Path embeddedFolder; + + private String scheduledMessages(Path folder, Capabilities capabilities) throws IOException { + final ConfigManager manager = + new ConfigManager(mock(PluginLogger.class), folder.toFile(), capabilities); + + manager.create(ScheduledMessagesConfig.class); + manager.saveAll(); + + return Files.readString(new File(folder.toFile(), "scheduledMessages.yml").toPath()); + } + + @Test + @DisplayName("should ship the full example set on a server that can honour it") + void shouldShipEveryExampleOnAFullServer() throws IOException { + final String yaml = scheduledMessages(bukkitFolder, Capabilities.all()); + + assertThat(yaml) + .contains("vote-reminder") + .contains("newcomer-tip") + .contains("first-join-welcome"); + } + + @Test + @DisplayName("should leave out the examples a proxy could never deliver") + void shouldOmitUndeliverableExamplesOnAProxy() throws IOException { + final String yaml = scheduledMessages(proxyFolder, PROXY); + + // Nothing about these depends on the platform. + assertThat(yaml) + .contains("vote-reminder") + .contains("vip-perk-reminder"); + + // Playtime is a statistic the proxy does not keep; a first join is one it cannot recognise. + assertThat(yaml) + .doesNotContain("newcomer-tip") + .doesNotContain("first-join-welcome"); + } + + @Test + @DisplayName("should document only the rules and triggers the platform can honour") + void shouldDocumentOnlyWhatThePlatformCanHonour() throws IOException { + final String bukkit = scheduledMessages(bukkitFolder, Capabilities.all()); + final String proxy = scheduledMessages(proxyFolder, PROXY); + + // The comment block is what an administrator actually reads; on a full server it + // documents everything. + assertThat(bukkit) + .contains("type: WORLD") + .contains("type: PLAYTIME") + .contains("type: FIRST_JOIN") + .contains("PlaceholderAPI") + .contains("{WORLD}"); + + // On a proxy every one of those is an instruction that cannot be followed. + assertThat(proxy) + .doesNotContain("type: WORLD") + .doesNotContain("type: PLAYTIME") + .doesNotContain("type: FIRST_JOIN") + .doesNotContain("PlaceholderAPI") + .doesNotContain("{WORLD}"); + + // What neither depends on stays in both. + assertThat(proxy).contains("type: PLAYER_COUNT"); + } + + @Test + @DisplayName("should never leak a directive into a written file") + void shouldNeverLeakADirective() throws IOException { + // The markers are how the block is described, not something a reader should ever see. + assertThat(scheduledMessages(bukkitFolder, Capabilities.all())) + .doesNotContain("@requires") + .doesNotContain("@end"); + } + + @Test + @DisplayName("should leave out permission-gated examples where there are no permissions") + void shouldOmitPermissionExamplesOnAnEmbeddedServer() throws IOException { + final String yaml = scheduledMessages(embeddedFolder, EMBEDDED); + + // A rule nobody can satisfy is worse than a missing example: it looks configured. + assertThat(yaml).doesNotContain("vip-perk-reminder"); + + // The plain broadcast needs nothing of the platform, so it still ships. + assertThat(yaml).contains("vote-reminder"); + + // And the documentation stops offering a rule nobody here could satisfy. + assertThat(yaml) + .doesNotContain("type: PERMISSION") + .doesNotContain("type: GROUP"); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/config/RenderConfigsTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/config/RenderConfigsTest.java deleted file mode 100644 index fbbb5c1..0000000 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/config/RenderConfigsTest.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.github.imdmk.automessage.config; - -import com.github.imdmk.automessage.language.LanguageRegistry; -import com.github.imdmk.automessage.platform.discord.DiscordWebhookConfig; -import com.github.imdmk.automessage.platform.logger.PluginLogger; -import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig; -import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig; -import org.junit.jupiter.api.Test; - -import java.io.File; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.List; - -import static org.mockito.Mockito.mock; - -/** Throwaway harness: renders the configuration files a fresh install would write. */ -class RenderConfigsTest { - - @Test - void render() throws Exception { - Path dir = Path.of("build/rendered-configs"); - Files.createDirectories(dir); - - ConfigManager manager = new ConfigManager(mock(PluginLogger.class), new File(dir.toString())); - - MessageDispatcherConfig dispatcher = manager.create(MessageDispatcherConfig.class); - manager.create(ScheduledMessagesConfig.class); - manager.create(DiscordWebhookConfig.class); - - LanguageRegistry.load(manager, mock(PluginLogger.class), dispatcher.fallbackLanguage); - - manager.saveAll(); - } -} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/config/SampleConfig.java b/automessage-core/src/test/java/com/github/imdmk/automessage/config/SampleConfig.java deleted file mode 100644 index c826ec3..0000000 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/config/SampleConfig.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.github.imdmk.automessage.config; - -import eu.okaeri.configs.serdes.OkaeriSerdesPack; - -public class SampleConfig extends ConfigSection { - - public int value = 5; - - @Override - public OkaeriSerdesPack getSerdesPack() { - return registry -> {}; - } - - @Override - public String getFileName() { - return "sample.yml"; - } -} - diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/language/LanguageRegistryTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/language/LanguageRegistryTest.java index 95c971d..aabd35e 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/language/LanguageRegistryTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/language/LanguageRegistryTest.java @@ -28,7 +28,19 @@ void setUp() { } private LanguageRegistry load() { - return LanguageRegistry.load(configManager, mock(PluginLogger.class), "en"); + return load(() -> "en"); + } + + private LanguageRegistry load(java.util.function.Supplier fallback) { + return LanguageRegistry.load(configManager, mock(PluginLogger.class), fallback); + } + + private void writeLanguage(String code, String announcement) throws java.io.IOException { + Files.writeString(dataFolder.resolve("lang/" + code + ".yml"), """ + announcements: + greeting: + - "%s" + """.formatted(announcement)); } @Test @@ -113,24 +125,7 @@ void announcementFallsBack() throws Exception { .isEqualTo(registry.announcement("vote-reminder", "en")); } - @Test - @DisplayName("every shipped announcement has text in every shipped language") - void shippedLanguagesAreComplete() { - LanguageRegistry registry = load(); - - List names = List.copyOf(registry.fallback().announcements.keySet()); - assertThat(names).isNotEmpty(); - - for (LanguageConfig language : registry.all()) { - for (String name : names) { - assertThat(language.announcement(name)) - .withFailMessage("'%s' has no text in lang/%s.yml", name, language.code()) - .isNotNull(); - } - } - } - - @Test +@Test @DisplayName("writes out the languages the build ships, without being told to") void shipsItsOwnLanguages() { LanguageRegistry registry = load(); @@ -143,4 +138,50 @@ void shipsItsOwnLanguages() { assertThat(dataFolder.resolve("lang/pl.yml")).exists(); assertThat(dataFolder.resolve("lang/de.yml")).exists(); } + + @Test + @DisplayName("should pick up a language added since startup, on reload") + void shouldPickUpALanguageAddedSinceStartup() throws java.io.IOException { + LanguageRegistry registry = load(); + + // The language file tells an administrator to copy it and reload. Before this, reload + // only re-read the files already open, so the instruction printed in the file was wrong + // and the new language stayed invisible until the server restarted. + assertThat(registry.provide("fr").code()).isEqualTo("en"); + + writeLanguage("fr", "Bonjour"); + registry.onConfigReload(); + + assertThat(registry.provide("fr").code()).isEqualTo("fr"); + assertThat(registry.announcement("greeting", "fr")).isNotNull(); + } + + @Test + @DisplayName("should apply a changed fallback language on reload") + void shouldApplyAChangedFallbackOnReload() { + String[] configured = {"en"}; + LanguageRegistry registry = load(() -> configured[0]); + + assertThat(registry.fallback().code()).isEqualTo("en"); + + // Read through the configuration rather than captured at startup, so this setting behaves + // like every other one in the file. + configured[0] = "pl"; + registry.onConfigReload(); + + assertThat(registry.fallback().code()).isEqualTo("pl"); + } + + @Test + @DisplayName("should not reopen a language it already had") + void shouldNotReopenAnExistingLanguage() { + LanguageRegistry registry = load(); + LanguageConfig before = registry.provide("en"); + + registry.onConfigReload(); + + // Reopening would register a second config bound to the same file, and okaeri has just + // reloaded this one from disk anyway. + assertThat(registry.provide("en")).isSameAs(before); + } } diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/language/LegacyLanguageFileTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/language/LegacyLanguageFileTest.java new file mode 100644 index 0000000..af5fa58 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/language/LegacyLanguageFileTest.java @@ -0,0 +1,137 @@ +package com.github.imdmk.automessage.language; + +import com.github.imdmk.automessage.config.ConfigManager; +import com.github.imdmk.automessage.notice.ActionBarPart; +import com.github.imdmk.automessage.notice.BossBarPart; +import com.github.imdmk.automessage.notice.ChatPart; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.notice.SoundPart; +import com.github.imdmk.automessage.notice.SubtitlePart; +import com.github.imdmk.automessage.notice.TitlePart; +import com.github.imdmk.automessage.notice.TitleTimesPart; +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.key.Key; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class LegacyLanguageFileTest { + + @TempDir + Path dataFolder; + + private LanguageConfig loadLegacy(String yaml) throws Exception { + Files.createDirectories(dataFolder.resolve("lang")); + Files.writeString(dataFolder.resolve("lang/en.yml"), yaml); + + return LanguageRegistry + .load(new ConfigManager(mock(PluginLogger.class), dataFolder.toFile()), + mock(PluginLogger.class), () -> "en") + .provide("en"); + } + + @Test + @DisplayName("reads every notice shape the old format could write") + void readsEveryLegacyShape() throws Exception { + LanguageConfig language = loadLegacy(""" + commands: + viewPlayerOnly: "players only" + announcements: + chat-single: + - "one line" + chat-multi: + - - first + - second + actionbar: + - actionbar: "above the hotbar" + title-pair: + - title: Title + subtitle: Sub + title-timed: + - title: Title + subtitle: Sub + times: "500ms 3s 500ms" + bossbar: + - bossbar: + message: bar + duration: 5s + color: RED + overlay: PROGRESS + progress: '0.5' + sound-bare: + - sound: entity.player.levelup + sound-full: + - sound: "block.note_block.pling MASTER 2.0 7.0" + mixed: + - chat + - actionbar: bar + - sound: "entity.experience_orb.pickup MASTER 1.0 1.0" + """); + + assertThat(language.announcement("chat-single")) + .containsExactly(Notice.of(ChatPart.of("one line"))); + + assertThat(language.announcement("chat-multi")) + .containsExactly(Notice.of(ChatPart.of("first", "second"))); + + assertThat(language.announcement("actionbar")) + .containsExactly(Notice.of(new ActionBarPart("above the hotbar"))); + + assertThat(language.announcement("title-pair")) + .containsExactly(Notice.of(new TitlePart("Title"), new SubtitlePart("Sub"))); + + assertThat(language.announcement("title-timed").getFirst().parts()) + .contains(new TitleTimesPart(Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500))); + + assertThat(language.announcement("bossbar")) + .containsExactly(Notice.of(new BossBarPart( + "bar", Duration.ofSeconds(5), BossBar.Color.RED, BossBar.Overlay.PROGRESS, 0.5D))); + + assertThat(language.announcement("sound-bare")) + .containsExactly(Notice.of(SoundPart.of(Key.key("entity.player.levelup")))); + + // Volume then pitch, as the file spells it - getting this backwards would swap every + // configured sound on every server. + SoundPart sound = (SoundPart) language.announcement("sound-full").getFirst().parts().getFirst(); + assertThat(sound.volumeOrDefault()).isEqualTo(2.0F); + assertThat(sound.pitchOrDefault()).isEqualTo(7.0F); + + assertThat(language.announcement("mixed")).hasSize(3); + } + + @Test + @DisplayName("reads the command messages a server has already customised") + void readsCustomisedCommandMessages() throws Exception { + LanguageConfig language = loadLegacy(""" + commands: + viewPlayerOnly: "my own wording" + dispatcherEnabled: "turned on" + announcements: {} + """); + + assertThat(language.commands.viewPlayerOnly.texts()).containsExactly("my own wording"); + assertThat(language.commands.dispatcherEnabled.texts()).containsExactly("turned on"); + } + + @Test + @DisplayName("a file the server never touched still gets the shipped defaults") + void keepsShippedDefaultsForUntouchedKeys() throws Exception { + LanguageConfig language = loadLegacy(""" + commands: + viewPlayerOnly: "mine" + announcements: {} + """); + + // Only one key was overridden; the rest must still say something. + assertThat(language.commands.configReloadedSuccess).isNotNull(); + assertThat(language.commands.configReloadedSuccess.texts()).isNotEmpty(); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/language/ShippedDefaultsConsistencyTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/language/ShippedDefaultsConsistencyTest.java index 1e5432f..58d75f3 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/language/ShippedDefaultsConsistencyTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/language/ShippedDefaultsConsistencyTest.java @@ -14,10 +14,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -/** - * The schedule and the text now live in different files, so nothing but a test stops them from - * disagreeing. A shipped message with no text would reach a player as silence. - */ class ShippedDefaultsConsistencyTest { @TempDir @@ -29,7 +25,9 @@ void everyMessageHasTextEverywhere() { ConfigManager configManager = new ConfigManager(mock(PluginLogger.class), dataFolder.toFile()); ScheduledMessagesConfig messages = configManager.create(ScheduledMessagesConfig.class); - LanguageRegistry languages = LanguageRegistry.load(configManager, mock(PluginLogger.class), "en"); + LanguageRegistry languages = LanguageRegistry.load(configManager, mock(PluginLogger.class), () -> "en"); + + assertThat(messages.messages).isNotEmpty(); for (ScheduledMessage message : messages.messages) { for (LanguageConfig language : languages.all()) { @@ -49,7 +47,7 @@ void noOrphanedText() { ConfigManager configManager = new ConfigManager(mock(PluginLogger.class), dataFolder.toFile()); ScheduledMessagesConfig messages = configManager.create(ScheduledMessagesConfig.class); - LanguageRegistry languages = LanguageRegistry.load(configManager, mock(PluginLogger.class), "en"); + LanguageRegistry languages = LanguageRegistry.load(configManager, mock(PluginLogger.class), () -> "en"); List known = messages.messages.stream().map(ScheduledMessage::name).toList(); diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/language/ShippedLanguagesTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/language/ShippedLanguagesTest.java new file mode 100644 index 0000000..69082f6 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/language/ShippedLanguagesTest.java @@ -0,0 +1,89 @@ +package com.github.imdmk.automessage.language; + +import com.github.imdmk.automessage.config.ConfigManager; +import com.github.imdmk.automessage.notice.ChatPart; +import com.github.imdmk.automessage.notice.Notice; +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Path; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +// The shipped translations are handed to a record constructor by position, thirty-one strings +// long. Inserting a field and forgetting one list shifts every translation after it by one, and +// nothing else in the build would notice: it compiles, and every message still says something. +// These anchors are spread across the record so a shift anywhere lands on at least one. +class ShippedLanguagesTest { + + @TempDir + Path dataFolder; + + private LanguageRegistry languages; + + @BeforeEach + void setUp() { + this.languages = LanguageRegistry.load( + new ConfigManager(mock(PluginLogger.class), dataFolder.toFile()), + mock(PluginLogger.class), + () -> "en" + ); + } + + private String text(String code, Function field) { + final Notice notice = field.apply(languages.provide(code).commands); + return ((ChatPart) notice.parts().getFirst()).lines().getFirst(); + } + + @Test + @DisplayName("Polish messages land on the fields they were written for") + void polishIsNotShifted() { + assertThat(text("pl", commands -> commands.viewPlayerOnly)).contains("Tylko gracze"); + assertThat(text("pl", commands -> commands.nextDisabled)).contains("wyłączony"); + assertThat(text("pl", commands -> commands.statsChannelsHeader)).contains("Kanały"); + assertThat(text("pl", commands -> commands.statsMessagesHeader)).contains("Wiadomości"); + assertThat(text("pl", commands -> commands.statsChannelDisabled)).contains("wyłączony w config.yml"); + assertThat(text("pl", commands -> commands.statsChannelEmpty)).contains("brak przypisanych"); + assertThat(text("pl", commands -> commands.channelNotFound)).contains("nie istnieje"); + assertThat(text("pl", commands -> commands.toggleOff)).contains("wyłączone"); + assertThat(text("pl", commands -> commands.togglePlayerOnly)).contains("Tylko gracze"); + } + + @Test + @DisplayName("German messages land on the fields they were written for") + void germanIsNotShifted() { + assertThat(text("de", commands -> commands.viewPlayerOnly)).contains("Nur Spieler"); + assertThat(text("de", commands -> commands.nextDisabled)).contains("deaktiviert"); + assertThat(text("de", commands -> commands.statsChannelsHeader)).contains("Kanäle"); + assertThat(text("de", commands -> commands.statsMessagesHeader)).contains("Nachrichten"); + assertThat(text("de", commands -> commands.statsChannelDisabled)).contains("deaktiviert"); + assertThat(text("de", commands -> commands.statsChannelEmpty)).contains("keine Nachrichten"); + assertThat(text("de", commands -> commands.channelNotFound)).contains("keinen Kanal"); + assertThat(text("de", commands -> commands.toggleOff)).contains("aus"); + assertThat(text("de", commands -> commands.togglePlayerOnly)).contains("Nur Spieler"); + } + + @Test + @DisplayName("a placeholder a message needs is present in every language that ships it") + void placeholdersSurviveTranslation() { + // A translation that drops {DELAY} silently removes the answer the command exists to give. + for (final String code : new String[] {"en", "pl", "de"}) { + assertThat(text(code, commands -> commands.nextEntry)) + .withFailMessage("nextEntry in %s lost a placeholder", code) + .contains("{CHANNEL}", "{MESSAGE}", "{DELAY}"); + // No {AGO}: when a channel last fired is dropped from its row on purpose, because + // the row has to fit a chat line. The message rows underneath still carry it. + assertThat(text(code, commands -> commands.statsChannel)) + .withFailMessage("statsChannel in %s lost a placeholder", code) + .contains("{CHANNEL}", "{COUNT}", "{DELAY}"); + assertThat(text(code, commands -> commands.statsEntry)) + .withFailMessage("statsEntry in %s lost a placeholder", code) + .contains("{MESSAGE}", "{CHANNEL}", "{COUNT}", "{AGO}"); + } + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookTest.java index 21f2fe8..88cc676 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/platform/discord/DiscordWebhookTest.java @@ -1,6 +1,6 @@ package com.github.imdmk.automessage.platform.discord; -import com.eternalcode.multification.notice.Notice; +import com.github.imdmk.automessage.notice.Notice; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; @@ -68,7 +68,7 @@ void rendersChatAsPlainText() { @DisplayName("skips notice types a text channel cannot show") void skipsNonChatNotices() { List message = message( - Notice.actionbar("action bar"), + Notice.actionBar("action bar"), Notice.title("title", "subtitle"), Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(1), "boss"), Notice.sound(Key.key("entity.experience_orb.pickup"), Sound.Source.MASTER, 1.0F, 1.0F) diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/scheduler/TaskSchedulerSeamTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/platform/scheduler/TaskSchedulerSeamTest.java new file mode 100644 index 0000000..d1d2de4 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/platform/scheduler/TaskSchedulerSeamTest.java @@ -0,0 +1,182 @@ +package com.github.imdmk.automessage.platform.scheduler; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext; +import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; +import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter; +import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; +import com.github.imdmk.automessage.scheduled.dispatcher.DispatchObserver; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcher; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherConfig; +import com.github.imdmk.automessage.scheduled.dispatcher.MessageDispatcherService; +import com.github.imdmk.automessage.scheduled.selector.MessageSelectorType; +import com.github.imdmk.automessage.scheduled.trigger.MessageTrigger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class TaskSchedulerSeamTest { + + private static final class RecordingScheduler implements TaskScheduler { + + private final List repeating = new ArrayList<>(); + private final List delays = new ArrayList<>(); + private final List asyncWork = new ArrayList<>(); + + private int cancelled; + private boolean shutDown; + + @Override + public TaskHandle runAsync(Runnable runnable) { + asyncWork.add(runnable); + return () -> cancelled++; + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + delays.add(delay); + return () -> cancelled++; + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + repeating.add(task); + return () -> cancelled++; + } + + @Override + public void shutdown() { + shutDown = true; + } + } + + private static final class EmptyRepository implements ScheduledMessageRepository { + + @Override + public List findAll() { + return List.of(); + } + + @Override + public List findScheduled() { + return List.of(); + } + + @Override + public List findByChannel(AnnouncementChannel channel) { + return List.of(); + } + + @Override + public List findByTrigger(MessageTrigger.Type type) { + return List.of(); + } + + @Override + public Optional findByName(String name) { + return Optional.empty(); + } + + @Override + public List names() { + return List.of(); + } + } + + private static final ViewerRegistry EMPTY_REGISTRY = new ViewerRegistry() { + + @Override + public java.util.Collection online() { + return List.of(); + } + + @Override + public int onlineCount() { + return 0; + } + + @Override + public int maxPlayers() { + return 0; + } + }; + + private static final AudienceContext CONTEXT = + AudienceContext.of(EMPTY_REGISTRY, PlaytimeSource.unavailable()); + + private final RecordingScheduler scheduler = new RecordingScheduler(); + + private MessageDispatcherService serviceWith(AnnouncementChannel... channels) { + MessageDispatcherConfig config = new MessageDispatcherConfig(); + config.channels = List.of(channels); + + return new MessageDispatcherService( + mock(PluginLogger.class), + EMPTY_REGISTRY, + scheduler, + config, + new EmptyRepository(), + selector -> new MessageDispatcher( + null, selector, AudienceFilter.ruleFilter(), CONTEXT, DispatchObserver.none()) + ); + } + + private static AnnouncementChannel channel(String name, boolean enabled) { + return new AnnouncementChannel( + name, enabled, Duration.ofMinutes(1), Duration.ofMinutes(5), MessageSelectorType.SHUFFLE + ); + } + + @Test + @DisplayName("schedules one repeating task per enabled channel, through the abstraction alone") + void schedulesThroughTheSeam() { + serviceWith(channel("default", true), channel("ads", true)).start(); + + assertThat(scheduler.repeating).hasSize(2); + assertThat(scheduler.repeating.getFirst().period()).isEqualTo(Duration.ofMinutes(5)); + assertThat(scheduler.repeating.getFirst().delay()).isEqualTo(Duration.ofMinutes(1)); + } + + @Test + @DisplayName("a disabled channel is not scheduled at all") + void skipsDisabledChannels() { + serviceWith(channel("default", true), channel("ads", false)).start(); + + assertThat(scheduler.repeating).hasSize(1); + } + + @Test + @DisplayName("stopping cancels every handle it was given") + void cancelsThroughTheHandle() { + MessageDispatcherService service = serviceWith(channel("default", true), channel("ads", true)); + + service.start(); + service.stop(); + + assertThat(scheduler.cancelled).isEqualTo(2); + } + + @Test + @DisplayName("a reload reschedules rather than leaving the old tasks running") + void restartCancelsBeforeScheduling() { + MessageDispatcherService service = serviceWith(channel("default", true)); + + service.start(); + service.onConfigReload(); + + assertThat(scheduler.cancelled).isEqualTo(1); + assertThat(scheduler.repeating).hasSize(2); + } + +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/time/DurationFormatterTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/platform/time/DurationFormatterTest.java deleted file mode 100644 index c9c91ef..0000000 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/time/DurationFormatterTest.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.github.imdmk.automessage.platform.time; - -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; - -import java.time.Duration; - -import static org.junit.jupiter.api.Assertions.assertEquals; - -class DurationFormatterTest { - - @Test - @DisplayName("Should format durations using the notation accepted by the config files") - void shouldFormatUsingConfigNotation() { - assertEquals("10s", DurationFormatter.format(Duration.ofSeconds(10))); - assertEquals("5m", DurationFormatter.format(Duration.ofMinutes(5))); - assertEquals("1h", DurationFormatter.format(Duration.ofHours(1))); - assertEquals("500ms", DurationFormatter.format(Duration.ofMillis(500))); - assertEquals("1m30s", DurationFormatter.format(Duration.ofSeconds(90))); - } - - @Test - @DisplayName("Should format zero and negative durations") - void shouldFormatZeroAndNegative() { - assertEquals("0s", DurationFormatter.format(Duration.ZERO)); - assertEquals("-5s", DurationFormatter.format(Duration.ofSeconds(-5))); - } - - @Test - @DisplayName("Should describe a missing duration instead of throwing") - void shouldDescribeMissingDuration() { - assertEquals("unset", DurationFormatter.format(null)); - } -} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepositoryTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepositoryTest.java index a83cd01..d9012dc 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepositoryTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/ScheduledMessageRepositoryTest.java @@ -50,16 +50,7 @@ void findByName_shouldReturnEmptyForMissingNames() { assertTrue(repository.findByName(null).isEmpty()); } - @Test - @DisplayName("Should observe messages added by a configuration reload") - void shouldObserveReloadedMessages() { - config.messages = List.of(message("third")); - - assertEquals(List.of("third"), repository.names()); - assertTrue(repository.findByName("first").isEmpty()); - } - - @Test +@Test @DisplayName("Should tolerate a configuration without any messages") void shouldTolerateMissingMessages() { config.messages = null; diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/audience/optout/AnnouncementOptOutTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/audience/optout/AnnouncementOptOutTest.java new file mode 100644 index 0000000..e12ae66 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/audience/optout/AnnouncementOptOutTest.java @@ -0,0 +1,140 @@ +package com.github.imdmk.automessage.scheduled.audience.optout; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class AnnouncementOptOutTest { + + private static final UUID STEVE = UUID.randomUUID(); + private static final UUID ALEX = UUID.randomUUID(); + + @TempDir + Path dataFolder; + + private AnnouncementOptOut load() { + return AnnouncementOptOut.load(mock(PluginLogger.class), dataFolder.toFile()); + } + + private Path file() { + return dataFolder.resolve("data").resolve("muted.txt"); + } + + @Test + @DisplayName("nobody is muted until somebody asks to be") + void nobodyIsMutedToStartWith() { + final AnnouncementOptOut optOut = load(); + + assertThat(optOut.isMuted(STEVE)).isFalse(); + assertThat(optOut.mutedCount()).isZero(); + } + + @Test + @DisplayName("toggling answers with the state the player ends in") + void togglingAnswersWithTheResultingState() { + final AnnouncementOptOut optOut = load(); + + assertThat(optOut.toggle(STEVE)).isTrue(); + assertThat(optOut.isMuted(STEVE)).isTrue(); + + assertThat(optOut.toggle(STEVE)).isFalse(); + assertThat(optOut.isMuted(STEVE)).isFalse(); + } + + @Test + @DisplayName("one player asking says nothing about another") + void mutingOnePlayerLeavesOthersAlone() { + final AnnouncementOptOut optOut = load(); + optOut.toggle(STEVE); + + assertThat(optOut.isMuted(ALEX)).isFalse(); + } + + @Test + @DisplayName("the choice survives a restart") + void theChoiceSurvivesARestart() { + final AnnouncementOptOut before = load(); + before.toggle(STEVE); + before.save(); + + // The whole point of a file rather than a set in memory: a player who turned + // announcements off should not have them back after the next restart. + final AnnouncementOptOut after = load(); + assertThat(after.isMuted(STEVE)).isTrue(); + assertThat(after.isMuted(ALEX)).isFalse(); + } + + @Test + @DisplayName("turning them back on is written out too") + void turningBackOnIsPersisted() { + final AnnouncementOptOut before = load(); + before.toggle(STEVE); + before.save(); + before.toggle(STEVE); + before.save(); + + assertThat(load().isMuted(STEVE)).isFalse(); + } + + @Test + @DisplayName("a line that is not a player id costs only that line") + void anUnreadableLineIsSkipped() throws IOException { + Files.createDirectories(file().getParent()); + Files.write(file(), List.of(STEVE.toString(), "not-a-uuid", "", ALEX.toString())); + + final AnnouncementOptOut optOut = load(); + + assertThat(optOut.isMuted(STEVE)).isTrue(); + assertThat(optOut.isMuted(ALEX)).isTrue(); + assertThat(optOut.mutedCount()).isEqualTo(2); + } + + @Test + @DisplayName("writing leaves no half-written file behind") + void writingLeavesNoTemporaryFile() { + final AnnouncementOptOut optOut = load(); + optOut.toggle(STEVE); + optOut.save(); + + // Written beside the file and moved over it, so a server that dies mid-write keeps the + // previous list rather than half of a new one. + assertThat(file()).exists(); + assertThat(file().resolveSibling("muted.txt.tmp")).doesNotExist(); + } + + @Test + @DisplayName("a server where nobody asked for quiet grows no file") + void savingNothingWritesNothing() { + load().save(); + + // A file saying that nobody is muted carries no more than its absence does. + assertThat(file()).doesNotExist(); + } + + @Test + @DisplayName("a change that could not be written is tried again next time") + void anUnwritableChangeIsRetried() throws IOException { + final AnnouncementOptOut optOut = load(); + optOut.toggle(STEVE); + + // The parent path taken by a file, so creating the directory fails. + Files.createDirectories(dataFolder); + Files.write(dataFolder.resolve("data"), List.of("not a directory")); + optOut.save(); + + Files.delete(dataFolder.resolve("data")); + optOut.save(); + + assertThat(load().isMuted(STEVE)).isTrue(); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleTest.java index 47440ed..44433f0 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/audience/rule/AudienceRuleTest.java @@ -1,55 +1,115 @@ package com.github.imdmk.automessage.scheduled.audience.rule; -import org.bukkit.Server; -import org.bukkit.Statistic; -import org.bukkit.World; -import org.bukkit.entity.Player; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import net.kyori.adventure.audience.Audience; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.time.Duration; -import java.util.Collections; +import java.util.Collection; import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.RETURNS_DEEP_STUBS; -import static org.mockito.Mockito.doReturn; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; class AudienceRuleTest { - private static final int TICKS_PER_HOUR = 20 * 60 * 60; - - private static Player playerInWorld(String worldName) { - Player player = mock(Player.class, RETURNS_DEEP_STUBS); - World world = mock(World.class); - when(world.getName()).thenReturn(worldName); - when(player.getWorld()).thenReturn(world); - return player; + private record FakeViewer(String name, String worldName, Set permissions) implements Viewer { + + static FakeViewer inWorld(String world) { + return new FakeViewer("Steve", world, Set.of()); + } + + static FakeViewer with(String... permissions) { + return new FakeViewer("Steve", "world", Set.of(permissions)); + } + + static FakeViewer plain() { + return new FakeViewer("Steve", null, Set.of()); + } + + @Override + public UUID uniqueId() { + return UUID.nameUUIDFromBytes(name.getBytes()); + } + + @Override + public String displayName() { + return name; + } + + @Override + public String locale() { + return "en_us"; + } + + @Override + public Optional world() { + return Optional.ofNullable(worldName); + } + + @Override + public boolean hasPermission(String permission) { + return permissions.contains(permission); + } + + @Override + public boolean isPlayer() { + return true; + } + + @Override + public boolean isOnline() { + return true; + } + + @Override + public Audience audience() { + return Audience.empty(); + } } - private static Player playerWithOnlineCount(int online) { - Player player = mock(Player.class); - Server server = mock(Server.class); - // getOnlinePlayers() is declared as Collection; the captured wildcard - // makes thenReturn() unusable here, so the stub is installed the untyped way. - doReturn(List.copyOf(Collections.nCopies(online, player))).when(server).getOnlinePlayers(); - when(player.getServer()).thenReturn(server); - return player; - } + private static AudienceContext context(int online, Duration playtime) { + ViewerRegistry viewers = new ViewerRegistry() { + + @Override + public Collection online() { + return List.of(); + } + + @Override + public int onlineCount() { + return online; + } - private static Player playerWithHoursPlayed(int hours) { - Player player = mock(Player.class); - when(player.getStatistic(Statistic.PLAY_ONE_MINUTE)).thenReturn(hours * TICKS_PER_HOUR); - return player; + @Override + public int maxPlayers() { + return 100; + } + }; + + PlaytimeSource playtimeSource = playtime == null + ? PlaytimeSource.unavailable() + : viewer -> Optional.of(playtime); + + return AudienceContext.of(viewers, playtimeSource); } - private static Player playerWithPermission(String permission) { - Player player = mock(Player.class); - when(player.hasPermission(permission)).thenReturn(true); - return player; + private static final AudienceContext ANY = context(1, Duration.ofHours(1)); + + @Test + @DisplayName("permission and group rules read the viewer's permissions") + void permissionRules() { + assertThat(AudienceRule.permission("rank.vip").test(FakeViewer.with("rank.vip"), ANY)).isTrue(); + assertThat(AudienceRule.permission("rank.vip").test(FakeViewer.with("other"), ANY)).isFalse(); + + // A group rule is a permission rule with a prefix, which is how permission plugins grant them. + assertThat(AudienceRule.group("vip").test(FakeViewer.with("group.vip"), ANY)).isTrue(); } @Test @@ -57,8 +117,15 @@ private static Player playerWithPermission(String permission) { void worldRuleIsCaseInsensitive() { AudienceRule rule = AudienceRule.worlds("World_Nether"); - assertThat(rule.test(playerInWorld("world_nether"))).isTrue(); - assertThat(rule.test(playerInWorld("world"))).isFalse(); + assertThat(rule.test(FakeViewer.inWorld("world_nether"), ANY)).isTrue(); + assertThat(rule.test(FakeViewer.inWorld("world"), ANY)).isFalse(); + } + + @Test + @DisplayName("a viewer with no world matches no world rule instead of throwing") + void worldRuleWithoutAWorld() { + // The console, and everyone on a proxy. + assertThat(AudienceRule.worlds("world").test(FakeViewer.plain(), ANY)).isFalse(); } @Test @@ -66,30 +133,33 @@ void worldRuleIsCaseInsensitive() { void playerCountRuleRespectsBothBounds() { AudienceRule rule = AudienceRule.playerCount(2, 10); - assertThat(rule.test(playerWithOnlineCount(1))).isFalse(); - assertThat(rule.test(playerWithOnlineCount(2))).isTrue(); - assertThat(rule.test(playerWithOnlineCount(10))).isTrue(); - assertThat(rule.test(playerWithOnlineCount(11))).isFalse(); + assertThat(rule.test(FakeViewer.plain(), context(1, null))).isFalse(); + assertThat(rule.test(FakeViewer.plain(), context(2, null))).isTrue(); + assertThat(rule.test(FakeViewer.plain(), context(10, null))).isTrue(); + assertThat(rule.test(FakeViewer.plain(), context(11, null))).isFalse(); } @Test @DisplayName("an open-ended player-count rule has no upper bound") void playerCountRuleCanBeOpenEnded() { - assertThat(AudiencePlayerCountRule.atLeast(50).test(playerWithOnlineCount(5_000))).isTrue(); + assertThat(new AudiencePlayerCountRule(50, AudiencePlayerCountRule.UNBOUNDED).test(FakeViewer.plain(), context(5_000, null))).isTrue(); } @Test @DisplayName("playtime rule separates newcomers from veterans") void playTimeRuleBoundsPlaytime() { - AudienceRule newcomers = AudiencePlayTimeRule.below(Duration.ofHours(2)); + AudienceRule newcomers = new AudiencePlayTimeRule(Duration.ZERO, Duration.ofHours(2)); - assertThat(newcomers.test(playerWithHoursPlayed(1))).isTrue(); - assertThat(newcomers.test(playerWithHoursPlayed(3))).isFalse(); - - AudienceRule veterans = AudiencePlayTimeRule.atLeast(Duration.ofHours(100)); + assertThat(newcomers.test(FakeViewer.plain(), context(1, Duration.ofHours(1)))).isTrue(); + assertThat(newcomers.test(FakeViewer.plain(), context(1, Duration.ofHours(3)))).isFalse(); + } - assertThat(veterans.test(playerWithHoursPlayed(150))).isTrue(); - assertThat(veterans.test(playerWithHoursPlayed(10))).isFalse(); + @Test + @DisplayName("a platform that keeps no playtime matches nobody rather than everybody") + void playTimeRuleWithoutASource() { + // Treating "unknown" as zero would make every veteran on a proxy look like a newcomer. + assertThat(new AudiencePlayTimeRule(Duration.ZERO, Duration.ofHours(2)) + .test(FakeViewer.plain(), context(1, null))).isFalse(); } @Test @@ -100,8 +170,8 @@ void anyOfIsAnOr() { AudienceRule.permission("rank.mod") ); - assertThat(rule.test(playerWithPermission("rank.mod"))).isTrue(); - assertThat(rule.test(playerWithPermission("rank.none"))).isFalse(); + assertThat(rule.test(FakeViewer.with("rank.mod"), ANY)).isTrue(); + assertThat(rule.test(FakeViewer.with("rank.none"), ANY)).isFalse(); } @Test @@ -109,8 +179,8 @@ void anyOfIsAnOr() { void noneOfIsANor() { AudienceRule rule = AudienceRule.noneOf(AudienceRule.permission("rank.vip")); - assertThat(rule.test(playerWithPermission("rank.vip"))).isFalse(); - assertThat(rule.test(playerWithPermission("something.else"))).isTrue(); + assertThat(rule.test(FakeViewer.with("rank.vip"), ANY)).isFalse(); + assertThat(rule.test(FakeViewer.with("something.else"), ANY)).isTrue(); } @Test @@ -118,14 +188,13 @@ void noneOfIsANor() { void notInverts() { AudienceRule rule = AudienceRule.not(AudienceRule.permission("hide.ads")); - assertThat(rule.test(playerWithPermission("hide.ads"))).isFalse(); - assertThat(rule.test(playerWithPermission("other"))).isTrue(); + assertThat(rule.test(FakeViewer.with("hide.ads"), ANY)).isFalse(); + assertThat(rule.test(FakeViewer.with("other"), ANY)).isTrue(); } @Test @DisplayName("combinators nest to any depth") void combinatorsNest() { - // VIP or moderator, but never someone who opted out. AudienceRule rule = AudienceRule.anyOf(List.of( AudienceRule.permission("rank.vip"), AudienceRule.anyOf( @@ -134,8 +203,8 @@ void combinatorsNest() { ) )); - assertThat(rule.test(playerWithPermission("rank.vip"))).isTrue(); - assertThat(rule.test(playerWithPermission("rank.mod"))).isTrue(); + assertThat(rule.test(FakeViewer.with("rank.vip"), ANY)).isTrue(); + assertThat(rule.test(FakeViewer.with("rank.mod"), ANY)).isTrue(); } @Test diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/ChannelScheduleTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/ChannelScheduleTest.java new file mode 100644 index 0000000..0c56edd --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/ChannelScheduleTest.java @@ -0,0 +1,216 @@ +package com.github.imdmk.automessage.scheduled.dispatcher; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; +import com.github.imdmk.automessage.scheduled.ScheduledMessageSender; +import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig; +import com.github.imdmk.automessage.scheduled.audience.rule.AudienceContext; +import com.github.imdmk.automessage.scheduled.audience.filter.AudienceFilter; +import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; +import com.github.imdmk.automessage.scheduled.selector.MessageSelectorType; +import com.github.imdmk.automessage.config.ConfigReloadService; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +// /automessage next has to say how long is left, and /automessage send has to be able to push one +// out now. Neither answer exists anywhere but here, because a scheduler will not say what is left +// on a timer it owns. +class ChannelScheduleTest { + + private static final Duration PERIOD = Duration.ofMinutes(5); + private static final Duration INITIAL = Duration.ofMinutes(1); + + private final AtomicLong clock = new AtomicLong(Duration.ofDays(2).toNanos()); + private final List timers = new ArrayList<>(); + private final List online = new ArrayList<>(); + private final ScheduledMessagesConfig messages = new ScheduledMessagesConfig(); + private final MessageDispatcherConfig config = new MessageDispatcherConfig(); + + private void advance(Duration duration) { + clock.addAndGet(duration.toNanos()); + } + + private static AnnouncementChannel channel(String name, boolean enabled) { + return new AnnouncementChannel(name, enabled, INITIAL, PERIOD, MessageSelectorType.SEQUENTIAL); + } + + private MessageDispatcherService service() { + final ViewerRegistry viewers = new ViewerRegistry() { + @Override + public Collection online() { + return List.copyOf(online); + } + + @Override + public int onlineCount() { + return online.size(); + } + + @Override + public int maxPlayers() { + return 20; + } + }; + + final TaskScheduler scheduler = new TaskScheduler() { + @Override + public TaskHandle runAsync(Runnable runnable) { + return TaskHandle.done(); + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + return TaskHandle.done(); + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + timers.add(task); + return () -> timers.remove(task); + } + + @Override + public void shutdown() { + } + }; + + final ScheduledMessageRepository repository = + ScheduledMessageRepository.config(messages, new ConfigReloadService(null)); + + final MessageDispatcherService service = new MessageDispatcherService( + mock(PluginLogger.class), viewers, scheduler, config, repository, + selector -> new MessageDispatcher( + mock(ScheduledMessageSender.class), selector, AudienceFilter.ruleFilter(), + AudienceContext.of(viewers, PlaytimeSource.unavailable()), DispatchObserver.none() + ), + clock::get + ); + service.start(); + return service; + } + + private ChannelPreview only(MessageDispatcherService service) { + return service.upcoming().getFirst(); + } + + @Test + @DisplayName("counts down from the initial delay before the first announcement") + void countsDownFromTheInitialDelay() { + messages.messages = List.of(new ScheduledMessage("alpha", List.of())); + config.channels = List.of(channel("default", true)); + final MessageDispatcherService service = service(); + + assertThat(only(service).due()).isEqualTo(INITIAL); + + advance(Duration.ofSeconds(50)); + assertThat(only(service).due()).isEqualTo(Duration.ofSeconds(10)); + } + + @Test + @DisplayName("starts the full period again once the channel has fired") + void restartsThePeriodAfterFiring() { + messages.messages = List.of(new ScheduledMessage("alpha", List.of())); + config.channels = List.of(channel("default", true)); + final MessageDispatcherService service = service(); + + advance(INITIAL); + timers.getFirst().run(); + + assertThat(only(service).due()).isEqualTo(PERIOD); + } + + @Test + @DisplayName("counts down even when nobody heard the last one") + void countsDownWithNobodyOnline() { + messages.messages = List.of(new ScheduledMessage("alpha", List.of())); + config.channels = List.of(channel("default", true)); + final MessageDispatcherService service = service(); + + // The timer fired and sent nothing, but the channel is still due again in a period. + advance(INITIAL); + timers.getFirst().run(); + advance(Duration.ofMinutes(2)); + + assertThat(only(service).due()).isEqualTo(Duration.ofMinutes(3)); + } + + @Test + @DisplayName("sends on demand and starts the interval again from that moment") + void sendsOnDemandAndRestartsTheInterval() { + messages.messages = List.of(new ScheduledMessage("alpha", List.of())); + config.channels = List.of(channel("default", true)); + online.add(mock(Viewer.class)); + final MessageDispatcherService service = service(); + final PluginTask before = timers.getFirst(); + + advance(Duration.ofSeconds(55)); + final ForcedSend result = service.forceNext(config.channels().getFirst()); + + assertThat(result.kind()).isEqualTo(ForcedSend.Kind.SENT); + assertThat(result.message()).map(ScheduledMessage::name).contains("alpha"); + // Five seconds were left; pushing one out by hand must not be followed by that one. + assertThat(only(service).due()).isEqualTo(PERIOD); + // Replaced rather than joined: one timer, and not the one that was ticking before. + assertThat(timers).hasSize(1).doesNotContain(before); + } + + @Test + @DisplayName("nobody online means nothing sent and the schedule left alone") + void nobodyOnlineLeavesTheScheduleAlone() { + messages.messages = List.of(new ScheduledMessage("alpha", List.of())); + config.channels = List.of(channel("default", true)); + final MessageDispatcherService service = service(); + + advance(Duration.ofSeconds(55)); + final ForcedSend result = service.forceNext(config.channels().getFirst()); + + assertThat(result.kind()).isEqualTo(ForcedSend.Kind.NOBODY_ONLINE); + assertThat(only(service).due()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + @DisplayName("a channel with nothing assigned says so and keeps its place") + void emptyChannelKeepsItsPlace() { + config.channels = List.of(channel("ads", true)); + online.add(mock(Viewer.class)); + final MessageDispatcherService service = service(); + + final PluginTask before = timers.getFirst(); + advance(Duration.ofSeconds(55)); + final ForcedSend result = service.forceNext(config.channels().getFirst()); + + assertThat(result.kind()).isEqualTo(ForcedSend.Kind.NO_MESSAGES); + // Nothing went out, so the timer that was already ticking is the same one, untouched. + assertThat(timers).containsExactly(before); + } + + @Test + @DisplayName("a channel switched off in the file cannot be pushed") + void disabledChannelRefuses() { + messages.messages = List.of(new ScheduledMessage("alpha", List.of())); + config.channels = List.of(channel("default", false)); + online.add(mock(Viewer.class)); + final MessageDispatcherService service = service(); + + final ForcedSend result = service.forceNext(config.channels().getFirst()); + + assertThat(result.kind()).isEqualTo(ForcedSend.Kind.DISABLED); + assertThat(only(service).due()).isNull(); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchStatisticsTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchStatisticsTest.java new file mode 100644 index 0000000..46ae559 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/DispatchStatisticsTest.java @@ -0,0 +1,144 @@ +package com.github.imdmk.automessage.scheduled.dispatcher; + +import com.github.imdmk.automessage.scheduled.ScheduledMessage; +import com.github.imdmk.automessage.scheduled.ScheduledMessageBuilder; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; + +import static org.assertj.core.api.Assertions.assertThat; + +class DispatchStatisticsTest { + + // Deliberately not zero: a clock starting at zero cannot tell "never recorded" apart from + // "recorded at the very first instant", and would hide a sentinel bug in the counter. + private final AtomicLong clock = new AtomicLong(Duration.ofDays(3).toNanos()); + private final DispatchStatistics statistics = new DispatchStatistics(clock::get); + + private void dispatched(String name) { + statistics.onDispatched(new ScheduledMessage(name, List.of()), null); + } + + private void dispatched(String name, String channel) { + statistics.onDispatched( + ScheduledMessageBuilder.create().name(name).channel(channel).build(), null + ); + } + + private void advance(Duration duration) { + clock.addAndGet(duration.toNanos()); + } + + @Test + @DisplayName("counts each message separately and everything together") + void countsPerMessageAndInTotal() { + dispatched("vote"); + dispatched("vote"); + dispatched("shop"); + + assertThat(statistics.total()).isEqualTo(3); + assertThat(statistics.snapshot()) + .extracting(DispatchStatistics.Entry::name, DispatchStatistics.Entry::count) + .containsExactly( + org.assertj.core.groups.Tuple.tuple("vote", 2L), + org.assertj.core.groups.Tuple.tuple("shop", 1L) + ); + } + + @Test + @DisplayName("says how long ago each message was last sent") + void reportsHowLongAgo() { + dispatched("vote"); + advance(Duration.ofMinutes(5)); + dispatched("shop"); + advance(Duration.ofMinutes(1)); + + assertThat(statistics.snapshot()) + .extracting(DispatchStatistics.Entry::name, DispatchStatistics.Entry::since) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple("vote", Duration.ofMinutes(6)), + org.assertj.core.groups.Tuple.tuple("shop", Duration.ofMinutes(1)) + ); + } + + @Test + @DisplayName("a message sent again is timed from the last time, not the first") + void repeatsResetTheClock() { + dispatched("vote"); + advance(Duration.ofMinutes(10)); + dispatched("vote"); + advance(Duration.ofSeconds(30)); + + assertThat(statistics.snapshot()) + .singleElement() + .extracting(DispatchStatistics.Entry::since) + .isEqualTo(Duration.ofSeconds(30)); + } + + @Test + @DisplayName("reports nothing before anything has been announced") + void emptyBeforeAnythingHappens() { + assertThat(statistics.total()).isZero(); + assertThat(statistics.snapshot()).isEmpty(); + } + + @Test + @DisplayName("every observer is told, and the cheap one is told first") + void combinatorTellsEveryObserverInOrder() { + final List told = new ArrayList<>(); + final DispatchObserver combined = DispatchObserver.of( + (message, placeholders) -> told.add("first"), + (message, placeholders) -> told.add("second") + ); + + combined.onDispatched(new ScheduledMessage("vote", List.of()), null); + + assertThat(told).containsExactly("first", "second"); + } + + @Test + @DisplayName("counts what each channel carried, not only what each message did") + void countsPerChannel() { + dispatched("advert", "ads"); + dispatched("another-advert", "ads"); + dispatched("tip", "default"); + + assertThat(statistics.channel("ads")).map(DispatchStatistics.Entry::count).contains(2L); + assertThat(statistics.channel("default")).map(DispatchStatistics.Entry::count).contains(1L); + } + + @Test + @DisplayName("a channel is found however it was typed") + void channelLookupIsNormalised() { + dispatched("advert", "ads"); + + // The name comes from a command argument, so it arrives however somebody typed it. + assertThat(statistics.channel(" ADS ")).map(DispatchStatistics.Entry::count).contains(1L); + } + + @Test + @DisplayName("a channel that has carried nothing is absent rather than zero") + void unusedChannelIsAbsent() { + dispatched("tip", "default"); + + assertThat(statistics.channel("ads")).isEmpty(); + } + + @Test + @DisplayName("a channel is timed from its own last announcement") + void channelTimesFromItsOwnLast() { + dispatched("tip", "default"); + advance(Duration.ofMinutes(4)); + dispatched("advert", "ads"); + advance(Duration.ofMinutes(1)); + + assertThat(statistics.channel("default")).map(DispatchStatistics.Entry::since) + .contains(Duration.ofMinutes(5)); + assertThat(statistics.channel("ads")).map(DispatchStatistics.Entry::since) + .contains(Duration.ofMinutes(1)); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfigTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfigTest.java index 7075c93..ce05559 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfigTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/dispatcher/MessageDispatcherConfigTest.java @@ -2,7 +2,7 @@ import com.github.imdmk.automessage.config.ConfigManager; import com.github.imdmk.automessage.platform.logger.PluginLogger; -import com.github.imdmk.automessage.platform.time.DurationFormatter; +import com.github.imdmk.automessage.notice.time.DurationFormatter; import com.github.imdmk.automessage.scheduled.channel.AnnouncementChannel; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -17,11 +17,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.mock; -/** - * Guards the shape and the time values of {@code config.yml}. - * - * @see GH-101 - */ class MessageDispatcherConfigTest { private final PluginLogger logger = mock(PluginLogger.class); @@ -47,7 +42,7 @@ void shipsOneExplicitChannel() { List channels = load().channels(); assertThat(channels).hasSize(1); - assertThat(channels.getFirst().isDefault()).isTrue(); + assertThat(channels.getFirst().matches(AnnouncementChannel.DEFAULT_NAME)).isTrue(); assertThat(channels.getFirst().enabled()).isTrue(); } diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScannerTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScannerTest.java index 98c0eda..614cd03 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScannerTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/placeholder/PlaceholderScannerTest.java @@ -1,8 +1,9 @@ package com.github.imdmk.automessage.scheduled.placeholder; -import com.eternalcode.multification.notice.Notice; +import com.github.imdmk.automessage.notice.Notice; import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; -import org.bukkit.Server; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; import net.kyori.adventure.bossbar.BossBar; import net.kyori.adventure.key.Key; import net.kyori.adventure.sound.Sound; @@ -15,12 +16,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; class PlaceholderScannerTest { - /** The scanner now works on the notice lists of every language, not on the message. */ private static List> message(Notice... notices) { return List.of(List.of(notices)); } @@ -42,7 +41,7 @@ void findsBuiltinsInChat() { @DisplayName("looks inside titles, actionbars and bossbars, not just chat") void scansEveryTextCarryingNoticeType() { List> message = message( - Notice.actionbar("{WORLD}"), + Notice.actionBar("{WORLD}"), Notice.title("{DATE}", "{TIME}"), Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(1), "{MAX_PLAYERS}") ); @@ -71,7 +70,7 @@ void soundNoticesAreIgnored() { void findsExternalTokens() { List> message = message( Notice.chat("%vault_eco_balance% and %server_tps%"), - Notice.actionbar("%server_tps% again") + Notice.actionBar("%server_tps% again") ); assertThat(PlaceholderScanner.externalTokensIn(message)) @@ -114,15 +113,29 @@ void plainMessageHasNoTokens() { @Test @DisplayName("without a viewer, server values resolve and viewer values drop out") void resolvesWhatItCanWithoutAViewer() { - Server server = mock(Server.class); - when(server.getOnlinePlayers()).thenAnswer(invocation -> List.of()); - when(server.getMaxPlayers()).thenReturn(100); + ViewerRegistry viewers = new ViewerRegistry() { + + @Override + public java.util.Collection online() { + return List.of(); + } + + @Override + public int onlineCount() { + return 0; + } + + @Override + public int maxPlayers() { + return 100; + } + }; List> message = message(Notice.chat("{ONLINE}/{MAX_PLAYERS} - hi {PLAYER} in {WORLD}")); Map resolved = MessagePlaceholders .scan(message, ExternalPlaceholderResolver.disabled()) - .resolveWithoutViewer(server); + .resolveWithoutViewer(viewers); assertThat(resolved).containsEntry("{ONLINE}", "0").containsEntry("{MAX_PLAYERS}", "100"); @@ -148,7 +161,7 @@ void scopesAreDeclared() { @Test @DisplayName("asking a viewer-scoped placeholder to resolve without one is a programming error") void viewerScopedRefusesToResolveWithoutAViewer() { - assertThatThrownBy(() -> BuiltinPlaceholder.PLAYER.resolveForServer(mock(Server.class))) + assertThatThrownBy(() -> BuiltinPlaceholder.PLAYER.resolveForServer(null)) .isInstanceOf(IllegalStateException.class); } diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorFactoryTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorFactoryTest.java index f80107a..d539b3c 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorFactoryTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/MessageSelectorFactoryTest.java @@ -1,38 +1,26 @@ package com.github.imdmk.automessage.scheduled.selector; import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.assertj.core.api.Assertions.assertThat; class MessageSelectorFactoryTest { - @Test - @DisplayName("Factory should create a RandomMessageSelector when RANDOM is chosen") - void shouldCreateRandomSelector() { - MessageSelector selector = MessageSelectorFactory.create(MessageSelectorType.RANDOM); - - assertNotNull(selector); - assertEquals(RandomMessageSelector.class, selector.getClass()); - } - - @Test - @DisplayName("Factory should create a SequentialMessageSelector when SEQUENTIAL is chosen") - void shouldCreateSequentialSelector() { - MessageSelector selector = MessageSelectorFactory.create(MessageSelectorType.SEQUENTIAL); - - assertNotNull(selector); - assertEquals(SequentialMessageSelector.class, selector.getClass()); - } - - @Test - @DisplayName("Factory should reject null type") - void shouldRejectNullType() { - assertThrows(NullPointerException.class, () -> - MessageSelectorFactory.create(null) - ); + // Every value is listed, because the failure this guards against is a branch of the switch + // pointing at the neighbouring selector - which a test covering half the enum would miss. + @ParameterizedTest(name = "{0} selects with {1}") + @CsvSource({ + "RANDOM, RandomMessageSelector", + "SHUFFLE, ShuffleMessageSelector", + "SEQUENTIAL, SequentialMessageSelector", + "WEIGHTED, WeightedMessageSelector", + }) + @DisplayName("builds the selector the configured strategy names") + void buildsTheNamedSelector(MessageSelectorType type, String expected) { + assertThat(MessageSelectorFactory.create(type)) + .extracting(selector -> selector.getClass().getSimpleName()) + .isEqualTo(expected); } } - diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/SequentialMessageSelectorTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/SequentialMessageSelectorTest.java index 6a7b508..fd2e44a 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/SequentialMessageSelectorTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/selector/SequentialMessageSelectorTest.java @@ -23,19 +23,7 @@ void shouldReturnEmptyOnEmptyList() { assertTrue(selector.selectNext(List.of(), true).isEmpty()); } - @Test - @DisplayName("Sequential selector should iterate in order") - void shouldIterateSequentially() { - SequentialMessageSelector selector = new SequentialMessageSelector(); - List messages = List.of(msg("a"), msg("b"), msg("c")); - - assertEquals("a", selector.selectNext(messages).get().name()); - assertEquals("b", selector.selectNext(messages).get().name()); - assertEquals("c", selector.selectNext(messages).get().name()); - assertEquals("a", selector.selectNext(messages).get().name()); // wraps around - } - - @Test +@Test @DisplayName("Sequential selector should not advance index when advanceIndex=false") void shouldNotAdvanceIndexWhenFlagFalse() { SequentialMessageSelector selector = new SequentialMessageSelector(); @@ -57,21 +45,5 @@ void shouldAdvanceIndexWhenTrue() { assertEquals("a", selector.selectNext(messages, true).get().name()); // wraps } - @Test - @DisplayName("Sequential selector should reset index after reaching threshold") - void shouldResetAfterThreshold() { - SequentialMessageSelector selector = new SequentialMessageSelector(); - List messages = List.of(msg("a")); - - // simulate state: index = threshold - 1 - for (int i = 0; i < 1_000_000_000 - 1; i++) { - selector.selectNext(messages, true); - } - - // next call should reset to 0, still producing valid output - ScheduledMessage result = selector.selectNext(messages, true).orElseThrow(); - - assertEquals("a", result.name()); - } } diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializerTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializerTest.java index 390869a..d17e704 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializerTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerSerializerTest.java @@ -18,7 +18,6 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; -/** Round-trips every trigger shape through the real configuration machinery. */ class MessageTriggerSerializerTest { @TempDir @@ -40,7 +39,6 @@ private List reload() { .messages; } - /** @return the file below its comment header, so assertions cannot match the documentation */ private String dataSection() throws Exception { String content = Files.readString(dataFolder.resolve("scheduledMessages.yml")); diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerTest.java b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerTest.java index 3c8ca70..dd93e24 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerTest.java +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/scheduled/trigger/MessageTriggerTest.java @@ -5,7 +5,6 @@ import com.github.imdmk.automessage.config.ConfigReloadService; import com.github.imdmk.automessage.scheduled.ScheduledMessageRepository; import com.github.imdmk.automessage.scheduled.ScheduledMessagesConfig; -import org.bukkit.entity.Player; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -14,8 +13,6 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; class MessageTriggerTest { @@ -26,12 +23,6 @@ private static ScheduledMessage message(String name, MessageTrigger trigger) { .build(); } - private static Player player(boolean playedBefore) { - Player player = mock(Player.class); - when(player.hasPlayedBefore()).thenReturn(playedBefore); - return player; - } - @Test @DisplayName("a triggered message is kept out of the timed rotation") void triggeredMessagesLeaveTheRotation() { @@ -57,8 +48,8 @@ void triggeredMessagesLeaveTheRotation() { void firstJoinExcludesReturningPlayers() { JoinTrigger firstJoin = MessageTrigger.firstJoin(Duration.ZERO); - assertThat(firstJoin.appliesTo(player(false))).isTrue(); - assertThat(firstJoin.appliesTo(player(true))).isFalse(); + assertThat(firstJoin.appliesTo(true)).isTrue(); + assertThat(firstJoin.appliesTo(false)).isFalse(); assertThat(firstJoin.type()).isEqualTo(MessageTrigger.Type.FIRST_JOIN); } @@ -67,8 +58,8 @@ void firstJoinExcludesReturningPlayers() { void plainJoinAppliesToEveryone() { JoinTrigger join = MessageTrigger.join(Duration.ofSeconds(3)); - assertThat(join.appliesTo(player(true))).isTrue(); - assertThat(join.appliesTo(player(false))).isTrue(); + assertThat(join.appliesTo(false)).isTrue(); + assertThat(join.appliesTo(true)).isTrue(); assertThat(join.type()).isEqualTo(MessageTrigger.Type.JOIN); } diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/support/RecordingViewer.java b/automessage-core/src/test/java/com/github/imdmk/automessage/support/RecordingViewer.java new file mode 100644 index 0000000..072d551 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/support/RecordingViewer.java @@ -0,0 +1,145 @@ +package com.github.imdmk.automessage.support; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.kyori.adventure.title.Title; +import org.jetbrains.annotations.Nullable; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; + +// A player that remembers everything sent to it, so a test can assert on what a reader would +// actually have seen rather than on the calls that produced it. +public final class RecordingViewer implements Viewer, Audience { + + private final String name; + private final String locale; + private final @Nullable String world; + private final Set permissions; + + public final List chat = new ArrayList<>(); + public final List actionBars = new ArrayList<>(); + public final List titles = new ArrayList<>(); + public final List<BossBar> bossBars = new ArrayList<>(); + public final List<Sound> sounds = new ArrayList<>(); + + private boolean online = true; + + public RecordingViewer(String name, String locale, @Nullable String world, String... permissions) { + this.name = name; + this.locale = locale; + this.world = world; + this.permissions = Set.of(permissions); + } + + public static RecordingViewer english(String name, String... permissions) { + return new RecordingViewer(name, "en_us", "world", permissions); + } + + public List<String> everythingSeen() { + final List<String> seen = new ArrayList<>(chat); + seen.addAll(actionBars); + titles.forEach(title -> seen.add(plain(title.title()))); + bossBars.forEach(bar -> seen.add(plain(bar.name()))); + + return seen; + } + + public void forget() { + chat.clear(); + actionBars.clear(); + titles.clear(); + bossBars.clear(); + sounds.clear(); + } + + public void disconnect() { + this.online = false; + } + + private static String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + @Override + public String name() { + return name; + } + + @Override + public UUID uniqueId() { + return UUID.nameUUIDFromBytes(name.getBytes()); + } + + @Override + public String displayName() { + return name; + } + + @Override + public String locale() { + return locale; + } + + @Override + public Optional<String> world() { + return Optional.ofNullable(world); + } + + @Override + public boolean hasPermission(String permission) { + return permissions.contains(permission); + } + + @Override + public boolean isPlayer() { + return true; + } + + @Override + public boolean isOnline() { + return online; + } + + @Override + public Audience audience() { + return this; + } + + @Override + public void sendMessage(Component message) { + chat.add(plain(message)); + } + + @Override + public void sendActionBar(Component message) { + actionBars.add(plain(message)); + } + + @Override + public void showTitle(Title title) { + titles.add(title); + } + + @Override + public void showBossBar(BossBar bar) { + bossBars.add(bar); + } + + @Override + public void hideBossBar(BossBar bar) { + bossBars.remove(bar); + } + + @Override + public void playSound(Sound sound) { + sounds.add(sound); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/support/TestPlatform.java b/automessage-core/src/test/java/com/github/imdmk/automessage/support/TestPlatform.java new file mode 100644 index 0000000..9562878 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/support/TestPlatform.java @@ -0,0 +1,88 @@ +package com.github.imdmk.automessage.support; + +import com.github.imdmk.automessage.platform.Platform; +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +// A whole server, as far as the plugin can tell. Everything a platform module implements is here +// in one place, so the core can be built and driven exactly as it is in production. +public final class TestPlatform implements Platform { + + public static final int MAX_PLAYERS = 20; + + private final Capabilities capabilities; + private final TestScheduler scheduler = new TestScheduler(); + private final List<RecordingViewer> online = new ArrayList<>(); + + private Duration playtime = Duration.ofHours(1); + + public TestPlatform(Capabilities capabilities) { + this.capabilities = capabilities; + } + + public static TestPlatform fullServer() { + return new TestPlatform(Capabilities.all()); + } + + public TestScheduler scheduler() { + return scheduler; + } + + public TestPlatform join(RecordingViewer viewer) { + online.add(viewer); + return this; + } + + public void leave(RecordingViewer viewer) { + online.remove(viewer); + } + + public void setPlaytime(Duration playtime) { + this.playtime = playtime; + } + + @Override + public String name() { + return "Test"; + } + + @Override + public Capabilities capabilities() { + return capabilities; + } + + @Override + public ViewerRegistry viewers() { + return new ViewerRegistry() { + + @Override + public Collection<Viewer> online() { + return List.copyOf(online); + } + + @Override + public int onlineCount() { + return online.size(); + } + + @Override + public int maxPlayers() { + return MAX_PLAYERS; + } + }; + } + + @Override + public PlaytimeSource playtime() { + return viewer -> Optional.of(playtime); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/support/TestScheduler.java b/automessage-core/src/test/java/com/github/imdmk/automessage/support/TestScheduler.java new file mode 100644 index 0000000..616dc88 --- /dev/null +++ b/automessage-core/src/test/java/com/github/imdmk/automessage/support/TestScheduler.java @@ -0,0 +1,84 @@ +package com.github.imdmk.automessage.support; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +// A clock the test winds by hand. Timing is not what these tests are about - whether the work +// scheduled is the right work is. +public final class TestScheduler implements TaskScheduler { + + private final List<Entry> timers = new ArrayList<>(); + private final List<Entry> delayed = new ArrayList<>(); + + private boolean shutdown; + + public int runningTimers() { + return (int) timers.stream().filter(entry -> !entry.cancelled).count(); + } + + public boolean isShutdown() { + return shutdown; + } + + // One pass of every repeating task that is still alive. + public void tick() { + List.copyOf(timers).stream() + .filter(entry -> !entry.cancelled) + .forEach(entry -> entry.action.run()); + } + + // Everything scheduled with a delay, as though the delay had elapsed. + public void runDelayed() { + final List<Entry> due = List.copyOf(delayed); + delayed.clear(); + + due.stream().filter(entry -> !entry.cancelled).forEach(entry -> entry.action.run()); + } + + @Override + public TaskHandle runAsync(Runnable runnable) { + runnable.run(); + return TaskHandle.done(); + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + final Entry entry = new Entry(runnable); + delayed.add(entry); + + return entry::cancel; + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + final Entry entry = new Entry(task); + timers.add(entry); + + return entry::cancel; + } + + @Override + public void shutdown() { + this.shutdown = true; + timers.forEach(Entry::cancel); + } + + private static final class Entry { + + private final Runnable action; + private boolean cancelled; + + private Entry(Runnable action) { + this.action = action; + } + + private void cancel() { + this.cancelled = true; + } + } +} diff --git a/automessage-fabric/build.gradle.kts b/automessage-fabric/build.gradle.kts new file mode 100644 index 0000000..986c6a1 --- /dev/null +++ b/automessage-fabric/build.gradle.kts @@ -0,0 +1,143 @@ +plugins { + `automessage-platform` + `automessage-testing` + + id("fabric-loom") +} + +// Fabric, and with it Quilt - whose loader reads `fabric.mod.json` and loads a Fabric mod as it +// is, so there is one jar rather than two identical ones. +// +// Pinned to Minecraft 1.21.11 by the ecosystem rather than by preference: it is the last version +// Fabric publishes intermediary and Yarn mappings for, and LiteCommands' Fabric binding is +// compiled against intermediary names, which 26.x no longer has. +// +// Nothing another mod already provides is shaded here. LiteCommands and Adventure both arrive as +// mods carrying their own libraries inside them, so shading a second copy - let alone a relocated +// one - would leave the plugin building Components nothing renders and calling into a framework +// nobody registered. Only okaeri and SnakeYAML, which no mod provides, travel inside this jar. +val shade: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true + + // Provided by the mods below, in the exact versions they were built against. + exclude(group = "net.kyori") + exclude(group = "dev.rollczi") + exclude(group = "org.slf4j") +} + +dependencies { + api(project(":automessage-core")) + api(project(":automessage-slf4j")) + + shade(project(":automessage-core")) + shade(project(":automessage-slf4j")) + + minecraft("com.mojang:minecraft:${Versions.FABRIC_MINECRAFT}") + mappings("net.fabricmc:yarn:${Versions.FABRIC_YARN}:v2") + + modImplementation("net.fabricmc:fabric-loader:${Versions.FABRIC_LOADER}") + modImplementation("net.fabricmc.fabric-api:fabric-api:${Versions.FABRIC_API}") + + // Both are bundled, so a server administrator installs one jar rather than three. + modImplementation("net.kyori:adventure-platform-fabric:${Versions.ADVENTURE_PLATFORM_FABRIC}") + include("net.kyori:adventure-platform-fabric:${Versions.ADVENTURE_PLATFORM_FABRIC}") + + modImplementation("dev.rollczi:litecommands-fabric:${Versions.LITECOMMANDS}") + include("dev.rollczi:litecommands-fabric:${Versions.LITECOMMANDS}") + +} + +// The mod metadata names versions the build already knows; they are filled in rather than typed twice. +tasks.processResources { + val properties = mapOf( + "version" to project.version.toString(), + "loader_version" to Versions.FABRIC_LOADER, + "minecraft_version" to Versions.FABRIC_MINECRAFT, + "java_version" to Versions.JAVA_RELEASE.toString() + ) + + inputs.properties(properties) + + filesMatching("fabric.mod.json") { + expand(properties) + } +} + +automessagePlatform { + runPort = 25597 + platformName = "Fabric" + supportedVersions = Versions.FABRIC_MINECRAFT + + // Nothing another mod already provides is shaded here: LiteCommands and Adventure both arrive + // as mods carrying their own libraries inside them, so a second, relocated copy would leave + // the plugin building Components nothing renders and calling a framework nobody registered. + providedByPlatform("net.kyori", "dev.rollczi", "org.slf4j") + + // remapJar has the last word, so shadowJar's output is a step, not the product. + shadowIsIntermediate = true + + shadowJar { + configurations = listOf(shade) + } +} + +// The plugin is compiled against Adventure ${Versions.KYORI_ADVENTURE} but this mod runs on the +// ${Versions.ADVENTURE_FABRIC} that adventure-platform-fabric carries inside it. That gap is +// deliberate - see Versions.ADVENTURE_PLATFORM_FABRIC - and this is what keeps it honest rather +// than hopeful: every Adventure member the shipped bytecode calls is resolved against the jars +// the loader will really provide. +val adventureProvided: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + listOf( + "adventure-api", + "adventure-key", + "adventure-text-minimessage", + "adventure-text-serializer-plain", + ).forEach { module -> + adventureProvided("net.kyori:$module:${Versions.ADVENTURE_FABRIC}") + } +} + +val checkAdventureLinkage = tasks.register<CheckLinkageTask>("checkAdventureLinkage") { + + description = "Verifies the shipped mod links against the Adventure adventure-platform-fabric " + + "actually bundles." + group = LifecycleBasePlugin.VERIFICATION_GROUP + + // The shaded jar, not the class directory: this is the bytecode that ships, core and all. + subjects.from(tasks.shadowJar.flatMap { it.archiveFile }) + provided.from(adventureProvided) + + packagePrefix.set("net/kyori/adventure/") + + // The Fabric bridge is versioned with the platform, not with Adventure, and is published + // against different mappings again - it is the library underneath that is in question here. + ignoredPrefixes.set(setOf("net/kyori/adventure/platform/")) + providedDescription.set("Adventure ${Versions.ADVENTURE_FABRIC}") + advice.set( + "Either adventure-platform-fabric has a release on Adventure ${Versions.KYORI_ADVENTURE} " + + "for a Minecraft the Fabric command binding can follow, or the plugin has to stop " + + "calling what the older line does not have." + ) + + report.set(layout.buildDirectory.file("reports/linkage/adventure.txt")) +} + +tasks.named("check") { + dependsOn(checkAdventureLinkage) +} + +// Loom remaps the mod from the mappings it was written against into the namespace the loader +// expects, and it has to be the shaded jar it remaps - otherwise the shipped mod is missing +// everything the plugin depends on. +tasks.remapJar { + inputFile.set(tasks.shadowJar.flatMap { it.archiveFile }) + dependsOn(tasks.shadowJar) + + archiveFileName.set(automessagePlatform.artifactFileName()) +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/AutoMessageFabricMod.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/AutoMessageFabricMod.java new file mode 100644 index 0000000..c4af0ab --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/AutoMessageFabricMod.java @@ -0,0 +1,75 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.AutoMessage; +import com.github.imdmk.automessage.command.CommandRegistrar; +import com.github.imdmk.automessage.logging.Slf4jPluginLogger; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import dev.rollczi.litecommands.LiteCommands; +import dev.rollczi.litecommands.fabric.LiteFabricFactory; +import net.fabricmc.api.DedicatedServerModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.loader.api.FabricLoader; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.command.ServerCommandSource; +import org.slf4j.LoggerFactory; + +// Server-side on purpose: this broadcasts to the people connected to a server, which a client has +// none of, and a dedicated-server initializer means a client that installs it does nothing rather +// than half of something. +public final class AutoMessageFabricMod implements DedicatedServerModInitializer { + + private static final String MOD_ID = "automessage"; + + private final FabricTaskScheduler scheduler = new FabricTaskScheduler(); + private final FabricServerHolder holder = new FabricServerHolder(); + + private AutoMessage automessage; + private LiteCommands<ServerCommandSource> liteCommands; + + // Everything is built here rather than once the server is up, because LiteCommands hooks + // Fabric's command registration callback the moment it is built - and that callback has + // already fired by the time SERVER_STARTED arrives, so commands registered then are simply + // not there. Nothing here touches a MinecraftServer; what needs one goes through the holder. + @Override + public void onInitializeServer() { + final FabricViewerFactory viewers = new FabricViewerFactory(holder); + + this.automessage = new AutoMessage( + new FabricPlatform(scheduler, new FabricViewerRegistry(holder)), + new Slf4jPluginLogger(LoggerFactory.getLogger(AutoMessage.NAME)), + FabricLoader.getInstance().getConfigDir().resolve(MOD_ID).toFile(), + // PlaceholderAPI is a Bukkit plugin; nothing here owns another mod's %tokens%. + ExternalPlaceholderResolver.disabled() + ); + + final var commandBuilder = LiteFabricFactory.server(); + CommandRegistrar.configure(commandBuilder, automessage, viewers); + this.liteCommands = commandBuilder.build(); + + ServerLifecycleEvents.SERVER_STARTED.register(this::onServerStarted); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> shutdown()); + + // The scheduler has no clock of its own; this is it. + ServerTickEvents.END_SERVER_TICK.register(server -> scheduler.tick()); + } + + private void onServerStarted(MinecraftServer server) { + holder.started(server); + + new FabricTriggerListener(server, new FabricViewerFactory(holder), automessage.triggerService()) + .register(); + } + + private void shutdown() { + if (automessage == null) { + return; + } + + automessage.shutdown(); + liteCommands.unregister(); + holder.stopped(); + + this.automessage = null; + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricPlatform.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricPlatform.java new file mode 100644 index 0000000..a19daa2 --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricPlatform.java @@ -0,0 +1,48 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.Platform; +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; + +public final class FabricPlatform implements Platform { + + private final TaskScheduler scheduler; + private final ViewerRegistry viewers; + private final PlaytimeSource playtime = new FabricPlaytimeSource(); + + public FabricPlatform(TaskScheduler scheduler, ViewerRegistry viewers) { + this.scheduler = scheduler; + this.viewers = viewers; + } + + @Override + public String name() { + return "Fabric"; + } + + @Override + public Capabilities capabilities() { + return Capabilities.allExcept( + Capability.EXTERNAL_PLACEHOLDERS, + Capability.METRICS + ); + } + + @Override + public ViewerRegistry viewers() { + return viewers; + } + + @Override + public TaskScheduler scheduler() { + return scheduler; + } + + @Override + public PlaytimeSource playtime() { + return playtime; + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricPlaytimeSource.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricPlaytimeSource.java new file mode 100644 index 0000000..328183c --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricPlaytimeSource.java @@ -0,0 +1,24 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import net.minecraft.stat.Stats; + +import java.time.Duration; +import java.util.Optional; + +public final class FabricPlaytimeSource implements PlaytimeSource { + + private static final long MILLIS_PER_TICK = 50L; + + @Override + public Optional<Duration> playtimeOf(Viewer viewer) { + if (!(viewer instanceof FabricViewer fabric)) { + return Optional.empty(); + } + + return fabric.player() + .map(player -> player.getStatHandler().getStat(Stats.CUSTOM, Stats.PLAY_TIME)) + .map(ticks -> Duration.ofMillis((long) ticks * MILLIS_PER_TICK)); + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricServerHolder.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricServerHolder.java new file mode 100644 index 0000000..68ad8fa --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricServerHolder.java @@ -0,0 +1,37 @@ +package com.github.imdmk.automessage.fabric; + +import net.kyori.adventure.platform.modcommon.MinecraftServerAudiences; +import net.minecraft.server.MinecraftServer; +import org.jetbrains.annotations.Nullable; + +// The server, once there is one. +// +// The mod is built before the server exists, because Fabric asks for commands while it is still +// starting - registering them any later means they are simply not there. Everything that genuinely +// needs a MinecraftServer is reached through here instead, and answers as though nobody were +// online until it arrives. +final class FabricServerHolder { + + private volatile @Nullable MinecraftServer server; + private volatile @Nullable MinecraftServerAudiences audiences; + + void started(MinecraftServer server) { + this.audiences = MinecraftServerAudiences.of(server); + this.server = server; + } + + void stopped() { + this.server = null; + this.audiences = null; + } + + @Nullable + MinecraftServer server() { + return server; + } + + @Nullable + MinecraftServerAudiences audiences() { + return audiences; + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricTaskScheduler.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricTaskScheduler.java new file mode 100644 index 0000000..ced4d39 --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricTaskScheduler.java @@ -0,0 +1,128 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class FabricTaskScheduler implements TaskScheduler { + + private static final long MILLIS_PER_TICK = 50L; + + // Minecraft hands a mod no scheduler; the tick is the only clock there is, so this keeps its + // own list and walks it from tick(). That is also what makes "sync" mean here what it means + // everywhere else: work runs on the server thread, where touching the world is safe. + private final Queue<TickTask> tasks = new ConcurrentLinkedQueue<>(); + + private volatile long currentTick; + private volatile @Nullable ExecutorService asyncExecutor; + + @Override + public TaskHandle runAsync(Runnable runnable) { + asyncExecutor().execute(runnable); + + // Deliberately not cancellable: nothing calls cancel on this one, and a handle over a + // Future would promise an interruption the plugin never asks for. + return TaskHandle.done(); + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + return submit(runnable, ticks(delay), 0L); + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + // A period of zero ticks would run the task every tick forever; a repeating broadcast + // configured that fast is a mistake, and one tick is the closest honest answer. + return submit(task, ticks(task.delay()), Math.max(1L, ticks(task.period()))); + } + + public void tick() { + final long tick = ++currentTick; + + for (final TickTask task : tasks) { + if (task.cancelled) { + tasks.remove(task); + continue; + } + + if (tick < task.nextRun) { + continue; + } + + if (task.period > 0L) { + task.nextRun = tick + task.period; + } else { + tasks.remove(task); + } + + task.runnable.run(); + } + } + + @Override + public void shutdown() { + tasks.forEach(task -> task.cancelled = true); + tasks.clear(); + + final ExecutorService executor = asyncExecutor; + if (executor != null) { + executor.shutdown(); + } + } + + private TaskHandle submit(Runnable runnable, long delayTicks, long periodTicks) { + final TickTask task = new TickTask(runnable, currentTick + Math.max(1L, delayTicks), periodTicks); + tasks.add(task); + + return () -> { + task.cancelled = true; + tasks.remove(task); + }; + } + + private static long ticks(Duration duration) { + return duration.toMillis() / MILLIS_PER_TICK; + } + + private ExecutorService asyncExecutor() { + ExecutorService executor = asyncExecutor; + if (executor != null) { + return executor; + } + + synchronized (this) { + if (asyncExecutor == null) { + // Created on first use: the only caller is a configuration reload an + // administrator may never run. + this.asyncExecutor = Executors.newSingleThreadExecutor( + runnable -> Thread.ofPlatform().name("AutoMessage Async").unstarted(runnable) + ); + } + + return asyncExecutor; + } + } + + private static final class TickTask { + + private final Runnable runnable; + private final long period; + + private volatile long nextRun; + private volatile boolean cancelled; + + private TickTask(Runnable runnable, long nextRun, long period) { + this.runnable = runnable; + this.nextRun = nextRun; + this.period = period; + } + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricTriggerListener.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricTriggerListener.java new file mode 100644 index 0000000..5a83c1e --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricTriggerListener.java @@ -0,0 +1,50 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.stat.Stats; + +public final class FabricTriggerListener { + + private final MinecraftServer server; + private final ViewerFactory<ServerCommandSource> viewers; + private final MessageTriggerService triggerService; + + public FabricTriggerListener( + MinecraftServer server, + ViewerFactory<ServerCommandSource> viewers, + MessageTriggerService triggerService + ) { + this.server = server; + this.viewers = viewers; + this.triggerService = triggerService; + } + + public void register() { + ServerPlayConnectionEvents.JOIN.register((handler, sender, ignored) -> onJoin(handler.getPlayer())); + ServerPlayConnectionEvents.DISCONNECT.register((handler, ignored) -> onDisconnect()); + } + + private void onJoin(ServerPlayerEntity player) { + triggerService.onJoin(viewers.of(player.getCommandSource()), isFirstJoin(player)); + + // The joining player is already on the server's list by the time this fires. + triggerService.onPlayerCountChanged(server.getPlayerManager().getCurrentPlayerCount()); + } + + private void onDisconnect() { + // The leaving player is still counted here, so the post-quit population is one lower. + // Milestones need that number to rearm at the right moment. + final int remaining = server.getPlayerManager().getCurrentPlayerCount() - 1; + + triggerService.onPlayerCountChanged(Math.max(0, remaining)); + } + + private boolean isFirstJoin(ServerPlayerEntity player) { + return player.getStatHandler().getStat(Stats.CUSTOM, Stats.PLAY_TIME) == 0; + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewer.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewer.java new file mode 100644 index 0000000..8013557 --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewer.java @@ -0,0 +1,130 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.platform.modcommon.MinecraftServerAudiences; +import net.minecraft.command.permission.Permission; +import net.minecraft.command.permission.PermissionLevel; +import net.minecraft.command.permission.PermissionPredicate; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.Identifier; +import org.jetbrains.annotations.Nullable; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +public final class FabricViewer implements Viewer { + + private static final String CONSOLE_NAME = "CONSOLE"; + + private static final PermissionLevel OPERATOR = PermissionLevel.GAMEMASTERS; + + private final MinecraftServerAudiences audiences; + private final PermissionPredicate permissions; + private final @Nullable ServerPlayerEntity player; + private final Audience audience; + + private FabricViewer( + MinecraftServerAudiences audiences, + PermissionPredicate permissions, + @Nullable ServerPlayerEntity player, + Audience audience + ) { + this.audiences = audiences; + this.permissions = permissions; + this.player = player; + this.audience = audience; + } + + public static FabricViewer of(MinecraftServerAudiences audiences, ServerPlayerEntity player) { + return new FabricViewer( + audiences, + player.getPermissions(), + player, + audiences.audience(player) + ); + } + + public static FabricViewer of(MinecraftServerAudiences audiences, ServerCommandSource source) { + final ServerPlayerEntity player = source.getPlayer(); + + return player != null + ? of(audiences, player) + : new FabricViewer(audiences, source.getPermissions(), null, audiences.audience(source)); + } + + public Optional<ServerPlayerEntity> player() { + return Optional.ofNullable(player); + } + + @Override + public String name() { + return player != null ? player.getGameProfile().name() : CONSOLE_NAME; + } + + @Override + public UUID uniqueId() { + return player != null ? player.getUuid() : CONSOLE_ID; + } + + @Override + public String displayName() { + // Minecraft's display name is a Text; flattened because the rest of the plugin treats a + // display name as something to substitute into a message, not as markup to nest. + return player != null ? player.getDisplayName().getString() : CONSOLE_NAME; + } + + @Override + public String locale() { + // Lowercased, matching the raw Bukkit client string, so one language file name works + // whichever platform reads it - see Viewer#locale. + return player != null + ? player.getClientOptions().language().toLowerCase(Locale.ROOT) + : ""; + } + + @Override + public Optional<String> world() { + return player != null + ? Optional.of(player.getEntityWorld().getRegistryKey().getValue().toString()) + : Optional.empty(); + } + + @Override + public boolean hasPermission(String permission) { + final Identifier identifier = Identifier.trySplitOn(permission.toLowerCase(Locale.ROOT), '.'); + + if (identifier != null && permissions.hasPermission(Permission.Atom.of(identifier))) { + return true; + } + + return permissions.hasPermission(new Permission.Level(OPERATOR)); + } + + @Override + public boolean isPlayer() { + return player != null; + } + + @Override + public boolean isOnline() { + return player == null || !player.isDisconnected(); + } + + @Override + public Audience audience() { + return audience; + } + + @Override + public boolean equals(Object other) { + return other instanceof FabricViewer viewer && uniqueId().equals(viewer.uniqueId()); + } + + @Override + public int hashCode() { + return uniqueId().hashCode(); + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewerFactory.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewerFactory.java new file mode 100644 index 0000000..3fc79ac --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewerFactory.java @@ -0,0 +1,19 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import net.minecraft.server.command.ServerCommandSource; + +public final class FabricViewerFactory implements ViewerFactory<ServerCommandSource> { + + private final FabricServerHolder holder; + + FabricViewerFactory(FabricServerHolder holder) { + this.holder = holder; + } + + @Override + public Viewer of(ServerCommandSource source) { + return FabricViewer.of(holder.audiences(), source); + } +} diff --git a/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewerRegistry.java b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewerRegistry.java new file mode 100644 index 0000000..4ad5b91 --- /dev/null +++ b/automessage-fabric/src/main/java/com/github/imdmk/automessage/fabric/FabricViewerRegistry.java @@ -0,0 +1,45 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import net.minecraft.server.MinecraftServer; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Collection; +import java.util.List; + +public final class FabricViewerRegistry implements ViewerRegistry { + + private final FabricServerHolder holder; + + FabricViewerRegistry(FabricServerHolder holder) { + this.holder = holder; + } + + @Override + @Unmodifiable + public Collection<Viewer> online() { + final MinecraftServer server = holder.server(); + if (server == null) { + return List.of(); + } + + // Copied rather than wrapped: the player list is the server's own, and it changes as + // people join and leave while the dispatcher is walking it. + return server.getPlayerManager().getPlayerList().stream() + .map(player -> (Viewer) FabricViewer.of(holder.audiences(), player)) + .toList(); + } + + @Override + public int onlineCount() { + final MinecraftServer server = holder.server(); + return server == null ? 0 : server.getPlayerManager().getCurrentPlayerCount(); + } + + @Override + public int maxPlayers() { + final MinecraftServer server = holder.server(); + return server == null ? -1 : server.getPlayerManager().getMaxPlayerCount(); + } +} diff --git a/automessage-fabric/src/main/resources/fabric.mod.json b/automessage-fabric/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..3fe2a27 --- /dev/null +++ b/automessage-fabric/src/main/resources/fabric.mod.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "id": "automessage", + "version": "${version}", + "name": "AutoMessage", + "description": "High-performance mod for fully customizable automatic server-wide broadcasts.", + "authors": ["imDMK"], + "contact": { + "homepage": "https://github.com/imDMK/AutoMessage", + "sources": "https://github.com/imDMK/AutoMessage", + "issues": "https://github.com/imDMK/AutoMessage/issues" + }, + "license": "MIT", + "environment": "server", + "entrypoints": { + "server": ["com.github.imdmk.automessage.fabric.AutoMessageFabricMod"] + }, + "depends": { + "fabricloader": ">=${loader_version}", + "minecraft": "${minecraft_version}", + "java": ">=${java_version}", + "fabric-api": "*", + "adventure-platform-fabric": "*", + "litecommands": "*" + } +} diff --git a/automessage-fabric/src/test/java/com/github/imdmk/automessage/fabric/FabricTaskSchedulerTest.java b/automessage-fabric/src/test/java/com/github/imdmk/automessage/fabric/FabricTaskSchedulerTest.java new file mode 100644 index 0000000..432aa65 --- /dev/null +++ b/automessage-fabric/src/test/java/com/github/imdmk/automessage/fabric/FabricTaskSchedulerTest.java @@ -0,0 +1,128 @@ +package com.github.imdmk.automessage.fabric; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; + +class FabricTaskSchedulerTest { + + private static final Duration TICK = Duration.ofMillis(50); + + private FabricTaskScheduler scheduler; + private AtomicInteger runs; + + @BeforeEach + void setUp() { + this.scheduler = new FabricTaskScheduler(); + this.runs = new AtomicInteger(); + } + + private void tick(int times) { + for (int i = 0; i < times; i++) { + scheduler.tick(); + } + } + + private PluginTask repeating(Duration delay, Duration period) { + return new PluginTask() { + + @Override + public void run() { + runs.incrementAndGet(); + } + + @Override + public Duration delay() { + return delay; + } + + @Override + public Duration period() { + return period; + } + }; + } + + @Test + @DisplayName("should wait out a delay before running once") + void shouldWaitOutADelay() { + scheduler.runLaterSync(runs::incrementAndGet, TICK.multipliedBy(3)); + + tick(2); + assertThat(runs).hasValue(0); + + tick(1); + assertThat(runs).hasValue(1); + + // One shot: still one, however long the server runs afterwards. + tick(10); + assertThat(runs).hasValue(1); + } + + @Test + @DisplayName("should repeat on its period, not on every tick") + void shouldRepeatOnItsPeriod() { + scheduler.runTimerSync(repeating(TICK.multipliedBy(2), TICK.multipliedBy(5))); + + tick(2); + assertThat(runs).hasValue(1); + + tick(4); + assertThat(runs).hasValue(1); + + tick(1); + assertThat(runs).hasValue(2); + } + + @Test + @DisplayName("should never run a task that was cancelled before its first tick") + void shouldNotRunACancelledTask() { + final TaskHandle handle = scheduler.runLaterSync(runs::incrementAndGet, TICK.multipliedBy(3)); + handle.cancel(); + + tick(10); + assertThat(runs).hasValue(0); + } + + @Test + @DisplayName("should stop a repeating task where it was cancelled") + void shouldStopARepeatingTask() { + final TaskHandle handle = scheduler.runTimerSync(repeating(Duration.ZERO, TICK)); + + tick(3); + assertThat(runs).hasValue(3); + + handle.cancel(); + + tick(5); + assertThat(runs).hasValue(3); + } + + @Test + @DisplayName("should treat a sub-tick period as one tick rather than as no period at all") + void shouldFloorASubTickPeriodToOneTick() { + scheduler.runTimerSync(repeating(Duration.ZERO, Duration.ofMillis(1))); + + tick(4); + assertThat(runs).hasValue(4); + } + + @Test + @DisplayName("should stop everything it started when the server does") + void shouldStopEverythingOnShutdown() { + scheduler.runTimerSync(repeating(Duration.ZERO, TICK)); + scheduler.runLaterSync(runs::incrementAndGet, TICK.multipliedBy(2)); + + scheduler.shutdown(); + + tick(10); + assertThat(runs).hasValue(0); + } +} diff --git a/automessage-folia/build.gradle.kts b/automessage-folia/build.gradle.kts new file mode 100644 index 0000000..e4a7733 --- /dev/null +++ b/automessage-folia/build.gradle.kts @@ -0,0 +1,39 @@ +plugins { + `automessage-platform` + `automessage-bukkit-plugin` + `automessage-testing` + + id("xyz.jpenilla.run-paper") +} + +// Folia: Bukkit's API, but no global scheduler - the server is regionised and every task has to +// say which region it belongs to. Everything except the scheduler and the entry point is shared +// with the Bukkit module, which is why this depends on it rather than copying it. +dependencies { + api(project(":automessage-bukkit-common")) + + compileOnly("dev.folia:folia-api:${Versions.FOLIA_API}") + compileOnly("me.clip:placeholderapi:${Versions.PLACEHOLDER_API}") + + api("dev.rollczi:litecommands-folia:${Versions.LITECOMMANDS}") +} + +automessagePlatform { + runPort = 25595 + platformName = "Folia" +} + +bukkit { + main = "com.github.imdmk.automessage.folia.AutoMessageFoliaPlugin" + foliaSupported = true +} + +// Applying run-paper registers runServer for Paper; this repoints it at Folia so the module has +// one run task and it starts the server the module exists to test. run-paper's own runFolia is +// deliberately not registered - two names for one thing is what made this confusing. +tasks.named<xyz.jpenilla.runpaper.task.RunServer>("runServer") { + description = "Run a Folia server for plugin testing." + + downloadsApiService.set(xyz.jpenilla.runtask.service.DownloadsAPIService.folia(project)) + version(Versions.FOLIA_RUN_MINECRAFT) +} diff --git a/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/AutoMessageFoliaPlugin.java b/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/AutoMessageFoliaPlugin.java new file mode 100644 index 0000000..88e721d --- /dev/null +++ b/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/AutoMessageFoliaPlugin.java @@ -0,0 +1,38 @@ +package com.github.imdmk.automessage.folia; + +import com.github.imdmk.automessage.bukkit.AutoMessagePlugin; +import dev.rollczi.litecommands.folia.FoliaExtension; +import org.bukkit.Bukkit; +import org.bukkit.plugin.java.JavaPlugin; + +public final class AutoMessageFoliaPlugin extends JavaPlugin { + + private AutoMessagePlugin plugin; + + @Override + public void onEnable() { + this.plugin = StaleFoliaWarning.silenced(getLogger(), () -> new AutoMessagePlugin( + this, + "Folia", + new FoliaTaskScheduler( + this, + Bukkit.getGlobalRegionScheduler(), + Bukkit.getAsyncScheduler() + ), + // Without this, a command run from a region thread reaches Bukkit's global + // scheduler, which Folia removed. The console never hits it - LiteCommands runs + // inline when it is already on the primary thread - but a player would. + // + // See StaleFoliaWarning for why its startup warning is dropped rather than fixed. + builder -> builder.extension(new FoliaExtension(this)) + )); + } + + @Override + public void onDisable() { + if (this.plugin != null) { + this.plugin.disable(); + this.plugin = null; + } + } +} diff --git a/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/FoliaTaskScheduler.java b/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/FoliaTaskScheduler.java new file mode 100644 index 0000000..2bebca1 --- /dev/null +++ b/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/FoliaTaskScheduler.java @@ -0,0 +1,60 @@ +package com.github.imdmk.automessage.folia; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import io.papermc.paper.threadedregions.scheduler.AsyncScheduler; +import io.papermc.paper.threadedregions.scheduler.GlobalRegionScheduler; +import org.bukkit.plugin.Plugin; + +import java.time.Duration; + +public final class FoliaTaskScheduler implements TaskScheduler { + + private static final long MILLIS_PER_TICK = 50L; + + private final Plugin plugin; + private final GlobalRegionScheduler globalScheduler; + private final AsyncScheduler asyncScheduler; + + public FoliaTaskScheduler( + Plugin plugin, + GlobalRegionScheduler globalScheduler, + AsyncScheduler asyncScheduler + ) { + this.plugin = plugin; + this.globalScheduler = globalScheduler; + this.asyncScheduler = asyncScheduler; + } + + @Override + public TaskHandle runAsync(Runnable runnable) { + return asyncScheduler.runNow(plugin, task -> runnable.run())::cancel; + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + // Folia refuses a delay of zero ticks, where Bukkit read it as "next tick". + return globalScheduler.runDelayed(plugin, task -> runnable.run(), atLeastOneTick(delay))::cancel; + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + return globalScheduler.runAtFixedRate( + plugin, + scheduled -> task.run(), + atLeastOneTick(task.delay()), + atLeastOneTick(task.period()) + )::cancel; + } + + @Override + public void shutdown() { + globalScheduler.cancelTasks(plugin); + asyncScheduler.cancelTasks(plugin); + } + + private static long atLeastOneTick(Duration duration) { + return Math.max(1L, duration.toMillis() / MILLIS_PER_TICK); + } +} diff --git a/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/StaleFoliaWarning.java b/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/StaleFoliaWarning.java new file mode 100644 index 0000000..ba8ffa0 --- /dev/null +++ b/automessage-folia/src/main/java/com/github/imdmk/automessage/folia/StaleFoliaWarning.java @@ -0,0 +1,40 @@ +package com.github.imdmk.automessage.folia; + +import java.util.function.Supplier; +import java.util.logging.Filter; +import java.util.logging.Logger; + +final class StaleFoliaWarning { + + private static final String TEXT = "Folia extension is not enabled"; + + private StaleFoliaWarning() { + } + + // LiteCommands warns that the Folia extension is missing from BukkitScheduler's constructor, + // and LiteBukkitFactory builds that scheduler before it hands back the builder .extension() is + // called on - so the warning is already false when it is printed. None of the factory's six + // builder(..) overloads accept a scheduler, so there is no way to be early enough; the record + // is dropped instead. + // + // This lives in the Folia module, the only one that installs the extension, so the Bukkit jar + // run on Folia keeps the warning - there it is true. Scoped to the call and restored after, so + // the filter cannot outlive the one message it exists for. + static <T> T silenced(Logger logger, Supplier<T> action) { + final Filter previous = logger.getFilter(); + + logger.setFilter(record -> { + final String message = record.getMessage(); + if (message != null && message.contains(TEXT)) { + return false; + } + return previous == null || previous.isLoggable(record); + }); + + try { + return action.get(); + } finally { + logger.setFilter(previous); + } + } +} diff --git a/automessage-folia/src/test/java/com/github/imdmk/automessage/folia/StaleFoliaWarningTest.java b/automessage-folia/src/test/java/com/github/imdmk/automessage/folia/StaleFoliaWarningTest.java new file mode 100644 index 0000000..4fab140 --- /dev/null +++ b/automessage-folia/src/test/java/com/github/imdmk/automessage/folia/StaleFoliaWarningTest.java @@ -0,0 +1,104 @@ +package com.github.imdmk.automessage.folia; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Filter; +import java.util.logging.Handler; +import java.util.logging.Level; +import java.util.logging.LogRecord; +import java.util.logging.Logger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class StaleFoliaWarningTest { + + private static final String WARNING = + "[LiteCommands] Folia detected, but Folia extension is not enabled."; + + private Logger logger; + private List<String> logged; + + @BeforeEach + void setUp() { + this.logged = new ArrayList<>(); + this.logger = Logger.getLogger(StaleFoliaWarningTest.class.getName() + System.nanoTime()); + this.logger.setUseParentHandlers(false); + this.logger.addHandler(new Handler() { + @Override + public void publish(LogRecord record) { + logged.add(record.getMessage()); + } + + @Override + public void flush() { + } + + @Override + public void close() { + } + }); + } + + @Test + @DisplayName("the stale Folia warning does not reach the console") + void dropsTheStaleWarning() { + StaleFoliaWarning.silenced(logger, () -> { + logger.log(Level.WARNING, WARNING); + return null; + }); + + assertThat(logged).isEmpty(); + } + + @Test + @DisplayName("everything else logged while it is active still gets through") + void keepsEverythingElse() { + StaleFoliaWarning.silenced(logger, () -> { + logger.log(Level.WARNING, "Something actually went wrong"); + return null; + }); + + assertThat(logged).containsExactly("Something actually went wrong"); + } + + @Test + @DisplayName("the warning is silenced only for the call, never after it") + void stopsSilencingAfterwards() { + StaleFoliaWarning.silenced(logger, () -> null); + logger.log(Level.WARNING, WARNING); + + assertThat(logged).containsExactly(WARNING); + } + + @Test + @DisplayName("a failing call leaves the logger as it found it") + void restoresOnFailure() { + final Filter original = record -> true; + logger.setFilter(original); + + assertThatThrownBy(() -> StaleFoliaWarning.silenced(logger, () -> { + throw new IllegalStateException("enable failed"); + })).isInstanceOf(IllegalStateException.class); + + assertThat(logger.getFilter()).isSameAs(original); + } + + @Test + @DisplayName("a filter that was already there keeps deciding") + void defersToAnExistingFilter() { + logger.setFilter(record -> !record.getMessage().contains("hidden")); + + StaleFoliaWarning.silenced(logger, () -> { + logger.log(Level.WARNING, "hidden by the original filter"); + logger.log(Level.WARNING, "allowed through"); + return null; + }); + + assertThat(logged).containsExactly("allowed through"); + } +} diff --git a/automessage-loader/build.gradle.kts b/automessage-loader/build.gradle.kts deleted file mode 100644 index 49d88c0..0000000 --- a/automessage-loader/build.gradle.kts +++ /dev/null @@ -1,70 +0,0 @@ -import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar - -plugins { - `automessage-spigot-compat` - - id("net.minecrell.plugin-yml.bukkit") version "0.6.0" - id("com.gradleup.shadow") version "9.6.1" - id("xyz.jpenilla.run-paper") version "3.1.0" -} - -dependencies { - implementation(project(":automessage-core")) -} - -tasks.build { - dependsOn(tasks.test) - dependsOn(tasks.shadowJar) -} - -tasks.withType<ShadowJar> { - archiveFileName.set("AutoMessage v${project.version} (MC ${Versions.SUPPORTED_MINECRAFT_RANGE}).jar") - - // Shadow's transformers only see entries that reach them; with the Jar default of EXCLUDE a - // second provider file of the same name would be dropped before mergeServiceFiles() runs. - duplicatesStrategy = DuplicatesStrategy.INCLUDE - - mergeServiceFiles() - - exclude( - "META-INF/*.SF", - "META-INF/*.DSA", - "META-INF/*.RSA", - "module-info.class", - "org/intellij/lang/annotations/**", - "org/jetbrains/annotations/**" - ) - - val relocationPrefix = "com.github.imdmk.automessage.lib" - listOf( - "com.eternalcode.multification", - "dev.rollczi.litecommands", - "eu.okaeri.configs", - "net.kyori", - "org.bstats", - "org.yaml.snakeyaml", - ).forEach { pkg -> - relocate(pkg, "$relocationPrefix.$pkg") - } - - // No minimize(): Adventure reaches its CraftBukkit serializers and its ServiceLoader - // providers reflectively, so the reachability analysis drops classes the plugin needs — - // including JSONComponentSerializerProviderImpl, which META-INF/services still points at. -} - -bukkit { - name = "AutoMessage" - version = project.version.toString() - apiVersion = Versions.SPIGOT_API_VERSION - softDepend = listOf("PlaceholderAPI") - main = "com.github.imdmk.automessage.AutoMessagePluginLoader" - author = "imDMK (dominiks8318@gmail.com)" - description = "High-performance plugin for fully customizable automatic server-wide broadcasts." - website = "https://github.com/imDMK/AutoMessage" -} - -tasks { - runServer { - minecraftVersion("26.2") - } -} diff --git a/automessage-minestom/build.gradle.kts b/automessage-minestom/build.gradle.kts new file mode 100644 index 0000000..dcd16a3 --- /dev/null +++ b/automessage-minestom/build.gradle.kts @@ -0,0 +1,109 @@ +plugins { + `automessage-platform` + `automessage-adventure-compat` + `automessage-testing` +} + +// Minestom. +// +// Not a server with a plugin folder: a library the server author writes a `main` around. So this +// module ships a jar to put on a classpath and a class to call, not something a platform loads - +// see AutoMessageMinestom. +// +// Pinned deliberately, and for the same reason Velocity is pinned to 3.x: this is the last +// Minestom release built against Adventure 4.26.1. Everything newer moved to Adventure 5, which +// `checkAdventureAlignment` exists to catch - adventure-platform-bukkit still links against types +// 5.x removed, so the whole stack stays on 4.x until it does not. +dependencies { + api(project(":automessage-core")) + api(project(":automessage-slf4j")) + + compileOnly("net.minestom:minestom:${Versions.MINESTOM}") + + api("dev.rollczi:litecommands-minestom:${Versions.LITECOMMANDS_MINESTOM}") + + testImplementation("net.minestom:minestom:${Versions.MINESTOM}") +} + +// LiteCommands publishes no Minestom binding past 3.10.9, so this jar carries an older binding on +// a newer framework - Gradle resolves the framework up to the version the core is compiled +// against, and only the binding stays behind. +// +// The alternative was to drag the whole stack down to 3.10.9, which would have run the entire core +// against a framework it was not compiled against to spare one small jar. `checkLiteCommandsBinding` +// is what makes the smaller risk a measured one rather than a hope: it resolves every LiteCommands +// member the binding calls against the framework that actually lands on the classpath. +val checkLiteCommandsBinding = tasks.register<CheckLinkageTask>("checkLiteCommandsBinding") { + + description = "Verifies the held-back LiteCommands Minestom binding links against the " + + "framework version resolution actually settles on." + group = LifecycleBasePlugin.VERIFICATION_GROUP + + val runtimeClasspath = configurations.named("runtimeClasspath") + + subjects.from(runtimeClasspath.map { it.filter { file -> file.name.startsWith("litecommands-minestom-") } }) + provided.from(runtimeClasspath.map { it.filter { file -> file.name.startsWith("litecommands-") && + !file.name.startsWith("litecommands-minestom-") } }) + + packagePrefix.set("dev/rollczi/litecommands/") + providedDescription.set("LiteCommands ${Versions.LITECOMMANDS}") + advice.set( + "Either the Minestom binding has caught up and Versions.LITECOMMANDS_MINESTOM can be " + + "bumped, or the framework has to stay where the binding can follow it." + ) + + report.set(layout.buildDirectory.file("reports/linkage/litecommands.txt")) +} + +tasks.named("check") { + dependsOn(checkLiteCommandsBinding) +} + +// The one module that is not built for Java 21 - see Versions.MINESTOM_JAVA_RELEASE. Minestom's +// own bytecode is 25, so this is the level a Minestom server already runs at. +tasks.withType<JavaCompile>().configureEach { + options.release.set(Versions.MINESTOM_JAVA_RELEASE) +} + +automessagePlatform { + platformName = "Minestom" + + // A Minestom build speaks exactly one protocol; naming the Bukkit range here would be a + // compatibility claim nobody made. + supportedVersions = Versions.MINESTOM_MINECRAFT + + providedByPlatform("net.kyori", "org.slf4j") +} + +// A source set of its own, not main and not test: the launcher must not ship inside the library +// jar, and it is not a test either - it is a server somebody starts and watches. +val dev: SourceSet by sourceSets.creating { + compileClasspath += sourceSets.main.get().output + runtimeClasspath += sourceSets.main.get().output +} + +configurations["devImplementation"].extendsFrom(configurations["implementation"], configurations["api"]) +configurations["devRuntimeOnly"].extendsFrom(configurations["runtimeOnly"]) + +dependencies { + "devImplementation"("net.minestom:minestom:${Versions.MINESTOM}") + "devRuntimeOnly"("org.slf4j:slf4j-simple:${Versions.SLF4J}") +} + +tasks.register<JavaExec>("runServer") { + group = "run" + description = "Run a Minestom server for plugin testing." + + mainClass.set("com.github.imdmk.automessage.minestom.dev.MinestomDevServer") + classpath = dev.runtimeClasspath + + // Minestom itself is Java 25, so this cannot borrow the launcher a Java 21 target would use. + javaLauncher.set(javaToolchains.launcherFor { + languageVersion.set(JavaLanguageVersion.of(Versions.MINESTOM_JAVA_RELEASE)) + }) + + workingDir = layout.projectDirectory.dir("run").asFile + standardInput = System.`in` + + doFirst { workingDir.mkdirs() } +} diff --git a/automessage-minestom/src/dev/java/com/github/imdmk/automessage/minestom/dev/MinestomDevServer.java b/automessage-minestom/src/dev/java/com/github/imdmk/automessage/minestom/dev/MinestomDevServer.java new file mode 100644 index 0000000..c6a56b8 --- /dev/null +++ b/automessage-minestom/src/dev/java/com/github/imdmk/automessage/minestom/dev/MinestomDevServer.java @@ -0,0 +1,46 @@ +package com.github.imdmk.automessage.minestom.dev; + +import com.github.imdmk.automessage.minestom.AutoMessageMinestom; +import net.minestom.server.MinecraftServer; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.event.player.AsyncPlayerConfigurationEvent; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.server.instance.block.Block; + +import java.nio.file.Path; + +// A Minestom server that exists only to try the plugin on. +// +// Every other platform is a program you drop a jar into; Minestom is a library you write the +// program around, so there is nothing to run until somebody writes this. It is the smallest server +// that can hold a player: one flat instance, one spawn point, and AutoMessage started the way the +// README tells an embedder to start it. +public final class MinestomDevServer { + + private static final int PORT = 25594; + private static final int GROUND = 40; + + private MinestomDevServer() { + } + + public static void main(String[] args) { + final MinecraftServer server = MinecraftServer.init(); + + final InstanceContainer instance = MinecraftServer.getInstanceManager().createInstanceContainer(); + instance.setGenerator(unit -> unit.modifier().fillHeight(0, GROUND, Block.GRASS_BLOCK)); + + MinecraftServer.getGlobalEventHandler().addListener(AsyncPlayerConfigurationEvent.class, event -> { + event.setSpawningInstance(instance); + event.getPlayer().setRespawnPoint(new Pos(0, GROUND, 0)); + }); + + final AutoMessageMinestom automessage = AutoMessageMinestom.builder() + .dataDirectory(Path.of("automessage")) + .enable(); + + Runtime.getRuntime().addShutdownHook(new Thread(automessage::shutdown)); + + server.start("0.0.0.0", PORT); + MinecraftServer.LOGGER.info("AutoMessage development server listening on {}", PORT); + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/AutoMessageMinestom.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/AutoMessageMinestom.java new file mode 100644 index 0000000..6618b67 --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/AutoMessageMinestom.java @@ -0,0 +1,121 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.AutoMessage; +import com.github.imdmk.automessage.command.CommandRegistrar; +import com.github.imdmk.automessage.logging.Slf4jPluginLogger; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import dev.rollczi.litecommands.LiteCommands; +import dev.rollczi.litecommands.minestom.LiteMinestomFactory; +import dev.rollczi.litecommands.permission.PermissionResolver; +import net.minestom.server.MinecraftServer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.network.ConnectionManager; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.File; +import java.nio.file.Path; + +public final class AutoMessageMinestom { + + private final AutoMessage automessage; + private final LiteCommands<CommandSender> liteCommands; + private final EventNode<Event> parent; + private final EventNode<Event> events; + + private AutoMessageMinestom( + AutoMessage automessage, + LiteCommands<CommandSender> liteCommands, + EventNode<Event> parent, + EventNode<Event> events + ) { + this.automessage = automessage; + this.liteCommands = liteCommands; + this.parent = parent; + this.events = events; + } + + public static Builder builder() { + return new Builder(); + } + + public void shutdown() { + parent.removeChild(events); + liteCommands.unregister(); + automessage.shutdown(); + } + + public static final class Builder { + + private static final String EVENT_NODE_NAME = "automessage"; + private static final String DEFAULT_DIRECTORY = "automessage"; + + private File dataDirectory = new File(DEFAULT_DIRECTORY); + private @Nullable MinestomPermissions permissions; + private @Nullable Logger logger; + + private Builder() { + } + + public Builder dataDirectory(Path dataDirectory) { + this.dataDirectory = dataDirectory.toFile(); + return this; + } + + public Builder permissions(MinestomPermissions permissions) { + this.permissions = permissions; + return this; + } + + public Builder logger(Logger logger) { + this.logger = logger; + return this; + } + + public AutoMessageMinestom enable() { + final boolean realPermissions = permissions != null; + final MinestomPermissions resolved = realPermissions + ? permissions + : MinestomPermissions.operatorLevel(); + + final ConnectionManager connections = MinecraftServer.getConnectionManager(); + final EventNode<Event> parent = MinecraftServer.getGlobalEventHandler(); + + final MinestomTaskScheduler taskScheduler = + new MinestomTaskScheduler(MinecraftServer.getSchedulerManager()); + final ViewerRegistry viewers = new MinestomViewerRegistry(connections, resolved); + + final AutoMessage automessage = new AutoMessage( + new MinestomPlatform(taskScheduler, viewers, realPermissions), + new Slf4jPluginLogger(logger != null ? logger : LoggerFactory.getLogger(AutoMessage.NAME)), + dataDirectory, + // PlaceholderAPI is a Bukkit plugin; nothing here owns another plugin's %tokens%. + ExternalPlaceholderResolver.disabled() + ); + + final MinestomViewerFactory viewerFactory = new MinestomViewerFactory(resolved); + + // Its own node rather than listeners on the global one, so shutdown can take them all + // back off again - which a server that restarts the plugin in-process needs. + final EventNode<Event> node = EventNode.all(EVENT_NODE_NAME); + new MinestomTriggerListener(connections, viewerFactory, automessage.triggerService()) + .register(node); + parent.addChild(node); + + final var commandBuilder = LiteMinestomFactory.builder(); + CommandRegistrar.configure(commandBuilder, automessage, viewerFactory); + + // LiteCommands has no permission binding for Minestom - there is nothing on the + // platform for it to bind to - so without this every command would be open to anyone. + commandBuilder.permissionResolver(PermissionResolver.createDefault( + CommandSender.class, resolved::has + )); + + return new AutoMessageMinestom(automessage, commandBuilder.build(), parent, node); + } + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomPermissions.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomPermissions.java new file mode 100644 index 0000000..5b32cc6 --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomPermissions.java @@ -0,0 +1,23 @@ +package com.github.imdmk.automessage.minestom; + +import net.minestom.server.command.CommandSender; +import net.minestom.server.entity.Player; + +// Minestom ships no permission system - a sender has an operator level and nothing else, which +// answers whether somebody is an administrator rather than who they are. The server author +// supplies the real answer; without one the platform declares no permission capability, so the +// rules that would need it are left out of the configuration instead of never matching. +@FunctionalInterface +public interface MinestomPermissions { + + int OPERATOR_LEVEL = 2; + + boolean has(CommandSender sender, String permission); + + // A stand-in, not a permission system: it cannot tell one node from another, so it is right + // for gating the plugin's own commands and wrong for anything that asks who a player is. + static MinestomPermissions operatorLevel() { + return (sender, permission) -> !(sender instanceof Player player) + || player.getPermissionLevel() >= OPERATOR_LEVEL; + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomPlatform.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomPlatform.java new file mode 100644 index 0000000..2f427dd --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomPlatform.java @@ -0,0 +1,60 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.platform.Platform; +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; + +import java.util.EnumSet; +import java.util.Set; + +public final class MinestomPlatform implements Platform { + + private final TaskScheduler scheduler; + private final ViewerRegistry viewers; + private final boolean realPermissions; + + public MinestomPlatform(TaskScheduler scheduler, ViewerRegistry viewers, boolean realPermissions) { + this.scheduler = scheduler; + this.viewers = viewers; + this.realPermissions = realPermissions; + } + + @Override + public String name() { + return "Minestom"; + } + + @Override + public Capabilities capabilities() { + final Set<Capability> supported = EnumSet.of( + Capability.SOUND_NOTICE, + Capability.BOSSBAR_NOTICE, + Capability.TITLE_NOTICE + ); + + if (realPermissions) { + supported.add(Capability.PERMISSION_RULE); + supported.add(Capability.GROUP_RULE); + } + + return new Capabilities(supported); + } + + @Override + public ViewerRegistry viewers() { + return viewers; + } + + @Override + public TaskScheduler scheduler() { + return scheduler; + } + + @Override + public PlaytimeSource playtime() { + return PlaytimeSource.unavailable(); + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomTaskScheduler.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomTaskScheduler.java new file mode 100644 index 0000000..2fbb29e --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomTaskScheduler.java @@ -0,0 +1,90 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import net.minestom.server.timer.ExecutionType; +import net.minestom.server.timer.Scheduler; +import net.minestom.server.timer.Task; + +import java.time.Duration; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public final class MinestomTaskScheduler implements TaskScheduler { + + private final Scheduler scheduler; + + // Held because Minestom's scheduler cancels per task, not per owner: nothing here belongs to + // a plugin it could be asked about, so the plugin has to remember what it started. + private final Set<Task> tasks = ConcurrentHashMap.newKeySet(); + + private volatile ExecutorService asyncExecutor; + + public MinestomTaskScheduler(Scheduler scheduler) { + this.scheduler = scheduler; + } + + @Override + public TaskHandle runAsync(Runnable runnable) { + asyncExecutor().execute(runnable); + + // Deliberately not cancellable. Handing back a handle over a Future would promise an + // interruption this plugin never asks for, and nothing calls cancel on this one. + return TaskHandle.done(); + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + return submit(scheduler.buildTask(runnable).delay(delay)); + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + return submit(scheduler.buildTask(task) + .delay(task.delay()) + .repeat(task.period())); + } + + @Override + public void shutdown() { + tasks.forEach(Task::cancel); + tasks.clear(); + + final ExecutorService executor = asyncExecutor; + if (executor != null) { + executor.shutdown(); + } + } + + private TaskHandle submit(Task.Builder builder) { + // TICK_START, so a broadcast is written before the tick's own work rather than after it, + // which is where Bukkit's scheduled tasks run too. + final Task task = builder.executionType(ExecutionType.TICK_START).schedule(); + tasks.add(task); + + return () -> { + task.cancel(); + tasks.remove(task); + }; + } + + private ExecutorService asyncExecutor() { + ExecutorService executor = asyncExecutor; + if (executor != null) { + return executor; + } + + synchronized (this) { + if (asyncExecutor == null) { + this.asyncExecutor = Executors.newSingleThreadExecutor( + runnable -> Thread.ofPlatform().name("AutoMessage Async").unstarted(runnable) + ); + } + + return asyncExecutor; + } + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomTriggerListener.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomTriggerListener.java new file mode 100644 index 0000000..521ffd2 --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomTriggerListener.java @@ -0,0 +1,48 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService; +import net.minestom.server.command.CommandSender; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.event.player.PlayerSpawnEvent; +import net.minestom.server.network.ConnectionManager; + +public final class MinestomTriggerListener { + + private final ConnectionManager connections; + private final ViewerFactory<CommandSender> viewers; + private final MessageTriggerService triggerService; + + public MinestomTriggerListener( + ConnectionManager connections, + ViewerFactory<CommandSender> viewers, + MessageTriggerService triggerService + ) { + this.connections = connections; + this.viewers = viewers; + this.triggerService = triggerService; + } + + public void register(EventNode<net.minestom.server.event.Event> events) { + events.addListener(PlayerSpawnEvent.class, this::onSpawn); + events.addListener(PlayerDisconnectEvent.class, this::onDisconnect); + } + + private void onSpawn(PlayerSpawnEvent event) { + // Fires again on every world change; only the first spawn of a connection is a join. + if (!event.isFirstSpawn()) { + return; + } + + triggerService.onJoin(viewers.of(event.getPlayer()), false); + triggerService.onPlayerCountChanged(connections.getOnlinePlayerCount()); + } + + private void onDisconnect(PlayerDisconnectEvent event) { + // The leaving player is still counted here, so the remaining population is one lower. + final int remaining = connections.getOnlinePlayerCount() - 1; + + triggerService.onPlayerCountChanged(Math.max(0, remaining)); + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewer.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewer.java new file mode 100644 index 0000000..3b1a148 --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewer.java @@ -0,0 +1,99 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.minestom.server.command.CommandSender; +import net.minestom.server.entity.Player; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +public final class MinestomViewer implements Viewer { + + private static final String CONSOLE_NAME = "CONSOLE"; + + private final CommandSender sender; + private final MinestomPermissions permissions; + + public MinestomViewer(CommandSender sender, MinestomPermissions permissions) { + this.sender = sender; + this.permissions = permissions; + } + + @Override + public String name() { + return sender instanceof Player player ? player.getUsername() : CONSOLE_NAME; + } + + @Override + public UUID uniqueId() { + return sender instanceof Player player ? player.getUuid() : CONSOLE_ID; + } + + @Override + public String displayName() { + if (!(sender instanceof Player player)) { + return CONSOLE_NAME; + } + + // Null until something sets one - Minestom decorates nobody by itself. + final Component displayName = player.getDisplayName(); + + return displayName != null + ? PlainTextComponentSerializer.plainText().serialize(displayName) + : player.getUsername(); + } + + @Override + public String locale() { + if (!(sender instanceof Player player)) { + return ""; + } + + // Lowercased with an underscore, matching the raw Bukkit client string, so one language + // file name works whichever platform reads it - see Viewer#locale. Null until the client + // has sent its settings. + return Optional.ofNullable(player.getLocale()) + .map(locale -> locale.toString().toLowerCase(Locale.ROOT)) + .orElse(""); + } + + @Override + public Optional<String> world() { + return Optional.empty(); + } + + @Override + public boolean hasPermission(String permission) { + return permissions.has(sender, permission); + } + + @Override + public boolean isPlayer() { + return sender instanceof Player; + } + + @Override + public boolean isOnline() { + return !(sender instanceof Player player) || player.isOnline(); + } + + @Override + public Audience audience() { + // Minestom speaks Adventure natively: a CommandSender already is an Audience. + return sender; + } + + @Override + public boolean equals(Object other) { + return other instanceof MinestomViewer viewer && uniqueId().equals(viewer.uniqueId()); + } + + @Override + public int hashCode() { + return uniqueId().hashCode(); + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewerFactory.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewerFactory.java new file mode 100644 index 0000000..0a13bd5 --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewerFactory.java @@ -0,0 +1,19 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import net.minestom.server.command.CommandSender; + +public final class MinestomViewerFactory implements ViewerFactory<CommandSender> { + + private final MinestomPermissions permissions; + + public MinestomViewerFactory(MinestomPermissions permissions) { + this.permissions = permissions; + } + + @Override + public Viewer of(CommandSender sender) { + return new MinestomViewer(sender, permissions); + } +} diff --git a/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewerRegistry.java b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewerRegistry.java new file mode 100644 index 0000000..be577ce --- /dev/null +++ b/automessage-minestom/src/main/java/com/github/imdmk/automessage/minestom/MinestomViewerRegistry.java @@ -0,0 +1,38 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import net.minestom.server.network.ConnectionManager; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Collection; + +public final class MinestomViewerRegistry implements ViewerRegistry { + + private final ConnectionManager connections; + private final MinestomPermissions permissions; + + public MinestomViewerRegistry(ConnectionManager connections, MinestomPermissions permissions) { + this.connections = connections; + this.permissions = permissions; + } + + @Override + @Unmodifiable + public Collection<Viewer> online() { + // Copied rather than wrapped: the dispatcher walks this while players connect and leave. + return connections.getOnlinePlayers().stream() + .map(player -> (Viewer) new MinestomViewer(player, permissions)) + .toList(); + } + + @Override + public int onlineCount() { + return connections.getOnlinePlayerCount(); + } + + @Override + public int maxPlayers() { + return -1; + } +} diff --git a/automessage-minestom/src/test/java/com/github/imdmk/automessage/minestom/MinestomPlatformTest.java b/automessage-minestom/src/test/java/com/github/imdmk/automessage/minestom/MinestomPlatformTest.java new file mode 100644 index 0000000..f1fee53 --- /dev/null +++ b/automessage-minestom/src/test/java/com/github/imdmk/automessage/minestom/MinestomPlatformTest.java @@ -0,0 +1,54 @@ +package com.github.imdmk.automessage.minestom; + +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +class MinestomPlatformTest { + + private MinestomPlatform platform(boolean realPermissions) { + return new MinestomPlatform( + mock(TaskScheduler.class), + mock(ViewerRegistry.class), + realPermissions + ); + } + + @Test + @DisplayName("should claim no permission capability when falling back to the operator level") + void shouldNotClaimPermissionsWithoutARealSystem() { + final var capabilities = platform(false).capabilities(); + + assertThat(capabilities.supports(Capability.PERMISSION_RULE)).isFalse(); + assertThat(capabilities.supports(Capability.GROUP_RULE)).isFalse(); + } + + @Test + @DisplayName("should claim permissions once the server supplies a permission system") + void shouldClaimPermissionsWithARealSystem() { + final var capabilities = platform(true).capabilities(); + + assertThat(capabilities.supports(Capability.PERMISSION_RULE)).isTrue(); + assertThat(capabilities.supports(Capability.GROUP_RULE)).isTrue(); + } + + @Test + @DisplayName("should never claim what Minestom keeps no record of") + void shouldNeverClaimWhatMinestomDoesNotKeep() { + // True either way: no profiles means no first join, no statistics means no playtime, and + // an instance is not a world with a name somebody could write in a rule. + for (final boolean realPermissions : new boolean[] {false, true}) { + final var capabilities = platform(realPermissions).capabilities(); + + assertThat(capabilities.supports(Capability.FIRST_JOIN_TRIGGER)).isFalse(); + assertThat(capabilities.supports(Capability.PLAYTIME_RULE)).isFalse(); + assertThat(capabilities.supports(Capability.WORLD_RULE)).isFalse(); + assertThat(capabilities.supports(Capability.EXTERNAL_PLACEHOLDERS)).isFalse(); + } + } +} diff --git a/automessage-minestom/src/test/java/com/github/imdmk/automessage/minestom/MinestomStartupTest.java b/automessage-minestom/src/test/java/com/github/imdmk/automessage/minestom/MinestomStartupTest.java new file mode 100644 index 0000000..fbcd1d4 --- /dev/null +++ b/automessage-minestom/src/test/java/com/github/imdmk/automessage/minestom/MinestomStartupTest.java @@ -0,0 +1,46 @@ +package com.github.imdmk.automessage.minestom; + +import net.minestom.server.MinecraftServer; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.File; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; + +// Minestom is a library, so the only platform here whose real runtime can be started inside a +// test - no server jar, no agreement with anybody, no port to bind. +class MinestomStartupTest { + + @TempDir + Path dataDirectory; + + @Test + @DisplayName("should start and stop against a real Minestom runtime") + void shouldStartAgainstARealRuntime() { + MinecraftServer.init(); + + final AutoMessageMinestom automessage = AutoMessageMinestom.builder() + .dataDirectory(dataDirectory) + .enable(); + + try { + // The files a server administrator would find, written by the real okaeri against a + // real Minestom scheduler and connection manager. + assertThat(new File(dataDirectory.toFile(), "config.yml")).exists(); + assertThat(new File(dataDirectory.toFile(), "scheduledMessages.yml")).exists(); + assertThat(new File(dataDirectory.toFile(), "lang/en.yml")).exists(); + + // Falling back to the operator level, so the permission-gated example is not offered. + assertThat(new File(dataDirectory.toFile(), "scheduledMessages.yml")) + .content() + .doesNotContain("vip-perk-reminder") + .contains("vote-reminder"); + } finally { + assertThatCode(automessage::shutdown).doesNotThrowAnyException(); + } + } +} diff --git a/automessage-notice/build.gradle.kts b/automessage-notice/build.gradle.kts new file mode 100644 index 0000000..817d945 --- /dev/null +++ b/automessage-notice/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + `automessage-testing` +} + +// The message vocabulary: what an announcement can be, how it is written in YAML, and how it +// reaches a reader. No server API of any kind belongs here - that is what lets every platform +// share one implementation, and the moment this module learns about a platform they all inherit it. +dependencies { + api("net.kyori:adventure-api:${Versions.KYORI_ADVENTURE}") + api("net.kyori:adventure-text-minimessage:${Versions.KYORI_ADVENTURE}") + api("net.kyori:adventure-text-serializer-plain:${Versions.KYORI_ADVENTURE}") + + api("eu.okaeri:okaeri-configs-yaml-snakeyaml:${Versions.OKAERI_CONFIGS}") +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/ActionBarPart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/ActionBarPart.java new file mode 100644 index 0000000..99aa97d --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/ActionBarPart.java @@ -0,0 +1,17 @@ +package com.github.imdmk.automessage.notice; + +import java.util.Objects; + +public record ActionBarPart(String text) implements NoticePart { + + public static final String KEY = "actionbar"; + + public ActionBarPart { + Objects.requireNonNull(text, "text"); + } + + @Override + public String key() { + return KEY; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/BossBarPart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/BossBarPart.java new file mode 100644 index 0000000..b80ff65 --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/BossBarPart.java @@ -0,0 +1,47 @@ +package com.github.imdmk.automessage.notice; + +import net.kyori.adventure.bossbar.BossBar; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.Objects; + +public record BossBarPart( + String message, + Duration duration, + BossBar.Color color, + BossBar.Overlay overlay, + @Nullable Double progress +) implements NoticePart { + + public static final String KEY = "bossbar"; + + public static final BossBar.Color DEFAULT_COLOR = BossBar.Color.WHITE; + public static final BossBar.Overlay DEFAULT_OVERLAY = BossBar.Overlay.PROGRESS; + public static final Duration DEFAULT_DURATION = Duration.ofSeconds(5); + + public BossBarPart { + Objects.requireNonNull(message, "message"); + + duration = duration == null ? DEFAULT_DURATION : duration; + color = color == null ? DEFAULT_COLOR : color; + overlay = overlay == null ? DEFAULT_OVERLAY : overlay; + + if (duration.isNegative()) { + throw new IllegalArgumentException("bossbar duration must not be negative"); + } + + if (progress != null && (progress < 0.0D || progress > 1.0D)) { + throw new IllegalArgumentException("bossbar progress must be between 0.0 and 1.0, got " + progress); + } + } + + public float progressOrFull() { + return progress == null ? BossBar.MAX_PROGRESS : progress.floatValue(); + } + + @Override + public String key() { + return KEY; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/ChatPart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/ChatPart.java new file mode 100644 index 0000000..029645f --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/ChatPart.java @@ -0,0 +1,25 @@ +package com.github.imdmk.automessage.notice; + +import org.jetbrains.annotations.Unmodifiable; + +import java.util.List; + +public record ChatPart(@Unmodifiable List<String> lines) implements NoticePart { + + public ChatPart { + if (lines.isEmpty()) { + throw new IllegalArgumentException("a chat notice needs at least one line"); + } + + lines = List.copyOf(lines); + } + + public static ChatPart of(String... lines) { + return new ChatPart(List.of(lines)); + } + + @Override + public String key() { + return null; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/HideTitlePart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/HideTitlePart.java new file mode 100644 index 0000000..dc4b0ad --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/HideTitlePart.java @@ -0,0 +1,11 @@ +package com.github.imdmk.automessage.notice; + +public record HideTitlePart() implements NoticePart { + + public static final String KEY = "hideTitle"; + + @Override + public String key() { + return KEY; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/Notice.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/Notice.java new file mode 100644 index 0000000..400ccf2 --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/Notice.java @@ -0,0 +1,123 @@ +package com.github.imdmk.automessage.notice; + +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.jetbrains.annotations.Unmodifiable; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +public record Notice(@Unmodifiable List<NoticePart> parts) { + + public Notice { + if (parts.isEmpty()) { + throw new IllegalArgumentException("a notice needs at least one part"); + } + + parts = List.copyOf(parts); + } + + public static Notice of(NoticePart... parts) { + return new Notice(List.of(parts)); + } + + public static Notice chat(String... lines) { + return of(ChatPart.of(lines)); + } + + public static Notice actionBar(String text) { + return of(new ActionBarPart(text)); + } + + public static Notice title(String title) { + return of(new TitlePart(title)); + } + + public static Notice subtitle(String subtitle) { + return of(new SubtitlePart(subtitle)); + } + + public static Notice title(String title, String subtitle) { + return of(new TitlePart(title), new SubtitlePart(subtitle)); + } + + public static Notice title( + String title, + String subtitle, + Duration fadeIn, + Duration stay, + Duration fadeOut + ) { + return of( + new TitlePart(title), + new SubtitlePart(subtitle), + new TitleTimesPart(fadeIn, stay, fadeOut) + ); + } + + public static Notice hideTitle() { + return of(new HideTitlePart()); + } + + public static Notice bossBar( + BossBar.Color color, + BossBar.Overlay overlay, + Duration duration, + String message + ) { + return of(new BossBarPart(message, duration, color, overlay, null)); + } + + public static Notice bossBar( + BossBar.Color color, + BossBar.Overlay overlay, + Duration duration, + double progress, + String message + ) { + return of(new BossBarPart(message, duration, color, overlay, progress)); + } + + public static Notice sound(Key sound) { + return of(SoundPart.of(sound)); + } + + public static Notice sound(Key sound, Sound.Source source, float volume, float pitch) { + return of(new SoundPart(sound, source, volume, pitch)); + } + + @Unmodifiable + public List<String> texts() { + final List<String> texts = new ArrayList<>(); + + for (final NoticePart part : parts) { + switch (part) { + case ChatPart chat -> texts.addAll(chat.lines()); + case ActionBarPart actionBar -> texts.add(actionBar.text()); + case TitlePart title -> texts.add(title.text()); + case SubtitlePart subtitle -> texts.add(subtitle.text()); + case BossBarPart bossBar -> texts.add(bossBar.message()); + case TitleTimesPart ignored -> { } + case HideTitlePart ignored -> { } + case SoundPart ignored -> { } + } + } + + return List.copyOf(texts); + } + + @Unmodifiable + public List<String> chatTexts() { + final List<String> texts = new ArrayList<>(); + + for (final NoticePart part : parts) { + if (part instanceof ChatPart chat) { + texts.addAll(chat.lines()); + } + } + + return List.copyOf(texts); + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeDelayer.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeDelayer.java new file mode 100644 index 0000000..71d1dd1 --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeDelayer.java @@ -0,0 +1,13 @@ +package com.github.imdmk.automessage.notice; + +import java.time.Duration; + +@FunctionalInterface +public interface NoticeDelayer { + + void runLater(Duration delay, Runnable action); + + static NoticeDelayer immediate() { + return (delay, action) -> action.run(); + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticePart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticePart.java new file mode 100644 index 0000000..4e23f29 --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticePart.java @@ -0,0 +1,8 @@ +package com.github.imdmk.automessage.notice; + +public sealed interface NoticePart + permits ChatPart, ActionBarPart, TitlePart, SubtitlePart, TitleTimesPart, HideTitlePart, + BossBarPart, SoundPart { + + String key(); +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeRenderer.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeRenderer.java new file mode 100644 index 0000000..c1cfd92 --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeRenderer.java @@ -0,0 +1,134 @@ +package com.github.imdmk.automessage.notice; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.ComponentSerializer; +import net.kyori.adventure.title.Title; + +import java.time.Duration; + +import java.util.Objects; +import java.util.function.UnaryOperator; + +public final class NoticeRenderer { + + private static final long MILLIS_PER_STEP = 100L; + private static final int MAX_COUNTDOWN_STEPS = 40; + + private final ComponentSerializer<Component, Component, String> serializer; + private final NoticeDelayer delayer; + + public NoticeRenderer( + ComponentSerializer<Component, Component, String> serializer, + NoticeDelayer delayer + ) { + this.serializer = Objects.requireNonNull(serializer, "serializer"); + this.delayer = Objects.requireNonNull(delayer, "delayer"); + } + + public static NoticeRenderer miniMessage(NoticeDelayer delayer) { + return new NoticeRenderer(MiniMessage.miniMessage(), delayer); + } + + public void render(Notice notice, Audience audience) { + render(notice, audience, UnaryOperator.identity()); + } + + public void render(Notice notice, Audience audience, UnaryOperator<String> placeholders) { + // Collected first: a title, its subtitle and its timing are separate parts of one entry + // and Adventure wants them as a single Title. + Component title = null; + Component subtitle = null; + Title.Times times = null; + + for (final NoticePart part : notice.parts()) { + switch (part) { + case ChatPart chat -> { + for (final String line : chat.lines()) { + audience.sendMessage(parse(line, placeholders)); + } + } + case ActionBarPart actionBar -> audience.sendActionBar(parse(actionBar.text(), placeholders)); + case TitlePart part1 -> title = parse(part1.text(), placeholders); + case SubtitlePart part1 -> subtitle = parse(part1.text(), placeholders); + case TitleTimesPart part1 -> times = Title.Times.times(part1.fadeIn(), part1.stay(), part1.fadeOut()); + case HideTitlePart ignored -> audience.clearTitle(); + case BossBarPart bossBar -> showBossBar(bossBar, audience, placeholders); + case SoundPart sound -> audience.playSound(toSound(sound)); + } + } + + if (title != null || subtitle != null) { + audience.showTitle(Title.title( + title == null ? Component.empty() : title, + subtitle == null ? Component.empty() : subtitle, + times == null ? Title.DEFAULT_TIMES : times + )); + } + } + + private void showBossBar(BossBarPart part, Audience audience, UnaryOperator<String> placeholders) { + final BossBar bossBar = BossBar.bossBar( + parse(part.message(), placeholders), + part.progressOrFull(), + part.color(), + part.overlay() + ); + + audience.showBossBar(bossBar); + + // A bar with a duration and no progress of its own is a countdown, and reads as broken + // when it sits full until it vanishes. One that names a progress was asked for that exact + // fill, so it keeps it and simply disappears when its time is up. + if (part.progress() != null || part.duration().isZero()) { + delayer.runLater(part.duration(), () -> audience.hideBossBar(bossBar)); + return; + } + + final int steps = countdownSteps(part.duration()); + drain(bossBar, audience, part.duration().dividedBy(steps), steps, 1); + } + + /** + * Each step is one packet to one player, so the count is capped rather than tied to the + * duration: a minute-long bar drawn every tick would be three thousand updates per viewer. + */ + private static int countdownSteps(Duration duration) { + final long byRate = duration.toMillis() / MILLIS_PER_STEP; + + return (int) Math.max(1L, Math.min(MAX_COUNTDOWN_STEPS, byRate)); + } + + // Re-scheduled rather than repeated, because runLater is the only clock this module is given - + // see NoticeDelayer, and the reason it is the only one. + private void drain(BossBar bossBar, Audience audience, Duration step, int steps, int done) { + delayer.runLater(step, () -> { + // Set first, hide second: the last step is the one that reaches empty, and hiding + // before it would leave the bar visibly short of zero for its whole final frame. + bossBar.progress(Math.max(BossBar.MIN_PROGRESS, 1.0F - (float) done / steps)); + + if (done >= steps) { + audience.hideBossBar(bossBar); + return; + } + + drain(bossBar, audience, step, steps, done + 1); + }); + } + + private static Sound toSound(SoundPart part) { + return Sound.sound( + part.sound(), + part.sourceOrDefault(), + part.volumeOrDefault(), + part.pitchOrDefault() + ); + } + + private Component parse(String text, UnaryOperator<String> placeholders) { + return serializer.deserialize(placeholders.apply(text)); + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeSerializer.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeSerializer.java new file mode 100644 index 0000000..73cb7dd --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/NoticeSerializer.java @@ -0,0 +1,234 @@ +package com.github.imdmk.automessage.notice; + +import com.github.imdmk.automessage.notice.time.DurationFormatter; +import com.github.imdmk.automessage.notice.time.DurationParser; +import eu.okaeri.configs.schema.GenericsDeclaration; +import eu.okaeri.configs.serdes.DeserializationData; +import eu.okaeri.configs.serdes.ObjectSerializer; +import eu.okaeri.configs.serdes.SerializationData; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.jetbrains.annotations.NotNull; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; + +public final class NoticeSerializer implements ObjectSerializer<Notice> { + + private static final String TIMES_SEPARATOR = " "; + private static final int TITLE_TIMES_COUNT = 3; + + @Override + public boolean supports(@NotNull Class<? super Notice> type) { + return Notice.class.isAssignableFrom(type); + } + + @Override + public void serialize(Notice notice, SerializationData data, @NotNull GenericsDeclaration generics) { + // A notice that is nothing but chat is written without any key at all - one line as a + // bare string, several as a list. That is the entry an administrator writes most often. + final ChatPart onlyChat = onlyChatOf(notice); + + if (onlyChat != null) { + if (onlyChat.lines().size() == 1) { + data.setValue(onlyChat.lines().getFirst(), String.class); + } else { + data.setValueCollection(onlyChat.lines(), String.class); + } + return; + } + + for (final NoticePart part : notice.parts()) { + write(part, data); + } + } + + private void write(NoticePart part, SerializationData data) { + switch (part) { + case ChatPart chat -> data.addCollection(ChatKey.MIXED, chat.lines(), String.class); + case ActionBarPart actionBar -> data.add(ActionBarPart.KEY, actionBar.text(), String.class); + case TitlePart title -> data.add(TitlePart.KEY, title.text(), String.class); + case SubtitlePart subtitle -> data.add(SubtitlePart.KEY, subtitle.text(), String.class); + case HideTitlePart ignored -> data.add(HideTitlePart.KEY, true, Boolean.class); + case TitleTimesPart times -> data.add(TitleTimesPart.KEY, formatTimes(times), String.class); + case BossBarPart bossBar -> writeBossBar(bossBar, data); + case SoundPart sound -> data.add(SoundPart.KEY, formatSound(sound), String.class); + } + } + + private void writeBossBar(BossBarPart bossBar, SerializationData data) { + data.addAsMap(BossBarPart.KEY, bossBarFields(bossBar), String.class, Object.class); + } + + private java.util.Map<String, Object> bossBarFields(BossBarPart bossBar) { + final java.util.LinkedHashMap<String, Object> fields = new java.util.LinkedHashMap<>(); + + fields.put("message", bossBar.message()); + fields.put("duration", DurationFormatter.format(bossBar.duration())); + fields.put("color", bossBar.color().name()); + fields.put("overlay", bossBar.overlay().name()); + + // Left out when unset: a bar written purely as a banner is full, and a progress line + // that always reads 1.0 is noise in every entry that uses one. + if (bossBar.progress() != null) { + fields.put("progress", String.valueOf(bossBar.progress())); + } + + return fields; + } + + private static String formatTimes(TitleTimesPart times) { + return DurationFormatter.format(times.fadeIn()) + + TIMES_SEPARATOR + DurationFormatter.format(times.stay()) + + TIMES_SEPARATOR + DurationFormatter.format(times.fadeOut()); + } + + private static String formatSound(SoundPart sound) { + if (sound.isBare()) { + return keyOf(sound.sound()); + } + + return keyOf(sound.sound()) + + " " + sound.sourceOrDefault().name() + + " " + sound.volumeOrDefault() + + " " + sound.pitchOrDefault(); + } + + @Override + public Notice deserialize(DeserializationData data, @NotNull GenericsDeclaration generics) { + // A bare value is chat: either one line or a list of them. + if (data.isValue()) { + final Object raw = data.getValueRaw(); + + if (raw instanceof Iterable<?>) { + return Notice.of(new ChatPart(data.getValueAsList(String.class))); + } + + return Notice.of(ChatPart.of(data.getValue(String.class))); + } + + final List<NoticePart> parts = new ArrayList<>(); + + if (data.containsKey(ChatKey.MIXED)) { + parts.add(new ChatPart(data.getAsList(ChatKey.MIXED, String.class))); + } + + if (data.containsKey(ActionBarPart.KEY)) { + parts.add(new ActionBarPart(data.get(ActionBarPart.KEY, String.class))); + } + + if (data.containsKey(TitlePart.KEY)) { + parts.add(new TitlePart(data.get(TitlePart.KEY, String.class))); + } + + if (data.containsKey(SubtitlePart.KEY)) { + parts.add(new SubtitlePart(data.get(SubtitlePart.KEY, String.class))); + } + + if (data.containsKey(TitleTimesPart.KEY)) { + parts.add(parseTimes(data.get(TitleTimesPart.KEY, String.class))); + } + + if (data.containsKey(HideTitlePart.KEY)) { + parts.add(new HideTitlePart()); + } + + if (data.containsKey(BossBarPart.KEY)) { + parts.add(readBossBar(data)); + } + + if (data.containsKey(SoundPart.KEY)) { + parts.add(parseSound(data.get(SoundPart.KEY, String.class))); + } + + if (parts.isEmpty()) { + throw new IllegalArgumentException( + "a notice must say something: write a line of chat, or one of " + + "actionbar / title / subtitle / bossbar / sound" + ); + } + + return new Notice(parts); + } + + private BossBarPart readBossBar(DeserializationData data) { + // okaeri hands a nested map back raw, so it is read as one rather than through a + // second serializer that would only exist to unwrap four fields. + @SuppressWarnings("unchecked") + final java.util.Map<String, Object> fields = + (java.util.Map<String, Object>) data.getRaw(BossBarPart.KEY); + + final Object progress = fields.get("progress"); + + return new BossBarPart( + String.valueOf(fields.get("message")), + fields.containsKey("duration") + ? DurationParser.parse(String.valueOf(fields.get("duration"))) + : BossBarPart.DEFAULT_DURATION, + fields.containsKey("color") + ? BossBar.Color.valueOf(String.valueOf(fields.get("color")).toUpperCase(Locale.ROOT)) + : BossBarPart.DEFAULT_COLOR, + fields.containsKey("overlay") + ? BossBar.Overlay.valueOf(String.valueOf(fields.get("overlay")).toUpperCase(Locale.ROOT)) + : BossBarPart.DEFAULT_OVERLAY, + progress == null ? null : Double.valueOf(String.valueOf(progress)) + ); + } + + private static TitleTimesPart parseTimes(String value) { + final String[] parts = value.trim().split("\\s+"); + + if (parts.length != TITLE_TIMES_COUNT) { + throw new IllegalArgumentException( + "title 'times' needs three durations - fade in, stay, fade out - but got: " + value + ); + } + + return new TitleTimesPart( + DurationParser.parse(parts[0]), + DurationParser.parse(parts[1]), + DurationParser.parse(parts[2]) + ); + } + + private static SoundPart parseSound(String value) { + final String[] parts = value.trim().split("\\s+"); + + final Key sound = Key.key(parts[0]); + + if (parts.length == 1) { + return SoundPart.of(sound); + } + + if (parts.length != 4) { + throw new IllegalArgumentException( + "a sound is written as \"key\" or \"key source volume pitch\", but got: " + value + ); + } + + return new SoundPart( + sound, + Sound.Source.valueOf(parts[1].toUpperCase(Locale.ROOT)), + Float.valueOf(parts[2]), + Float.valueOf(parts[3]) + ); + } + + private static String keyOf(Key key) { + return Key.MINECRAFT_NAMESPACE.equals(key.namespace()) ? key.value() : key.asString(); + } + + private static ChatPart onlyChatOf(Notice notice) { + if (notice.parts().size() != 1) { + return null; + } + + return notice.parts().getFirst() instanceof ChatPart chat ? chat : null; + } + + private static final class ChatKey { + private static final String MIXED = "chat"; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/SoundPart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/SoundPart.java new file mode 100644 index 0000000..8cf4378 --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/SoundPart.java @@ -0,0 +1,50 @@ +package com.github.imdmk.automessage.notice; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.jetbrains.annotations.Nullable; + +import java.util.Objects; + +public record SoundPart( + Key sound, + @Nullable Sound.Source source, + @Nullable Float volume, + @Nullable Float pitch +) implements NoticePart { + + public static final String KEY = "sound"; + + public static final Sound.Source DEFAULT_SOURCE = Sound.Source.MASTER; + public static final float DEFAULT_VOLUME = 1.0F; + public static final float DEFAULT_PITCH = 1.0F; + + public SoundPart { + Objects.requireNonNull(sound, "sound"); + } + + public static SoundPart of(Key sound) { + return new SoundPart(sound, null, null, null); + } + + public Sound.Source sourceOrDefault() { + return source == null ? DEFAULT_SOURCE : source; + } + + public float volumeOrDefault() { + return volume == null ? DEFAULT_VOLUME : volume; + } + + public float pitchOrDefault() { + return pitch == null ? DEFAULT_PITCH : pitch; + } + + public boolean isBare() { + return source == null && volume == null && pitch == null; + } + + @Override + public String key() { + return KEY; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/SubtitlePart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/SubtitlePart.java new file mode 100644 index 0000000..ff92fab --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/SubtitlePart.java @@ -0,0 +1,17 @@ +package com.github.imdmk.automessage.notice; + +import java.util.Objects; + +public record SubtitlePart(String text) implements NoticePart { + + public static final String KEY = "subtitle"; + + public SubtitlePart { + Objects.requireNonNull(text, "text"); + } + + @Override + public String key() { + return KEY; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/TitlePart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/TitlePart.java new file mode 100644 index 0000000..35072df --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/TitlePart.java @@ -0,0 +1,17 @@ +package com.github.imdmk.automessage.notice; + +import java.util.Objects; + +public record TitlePart(String text) implements NoticePart { + + public static final String KEY = "title"; + + public TitlePart { + Objects.requireNonNull(text, "text"); + } + + @Override + public String key() { + return KEY; + } +} diff --git a/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/TitleTimesPart.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/TitleTimesPart.java new file mode 100644 index 0000000..fcaaa82 --- /dev/null +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/TitleTimesPart.java @@ -0,0 +1,24 @@ +package com.github.imdmk.automessage.notice; + +import java.time.Duration; +import java.util.Objects; + +public record TitleTimesPart(Duration fadeIn, Duration stay, Duration fadeOut) implements NoticePart { + + public static final String KEY = "times"; + + public TitleTimesPart { + Objects.requireNonNull(fadeIn, "fadeIn"); + Objects.requireNonNull(stay, "stay"); + Objects.requireNonNull(fadeOut, "fadeOut"); + + if (fadeIn.isNegative() || stay.isNegative() || fadeOut.isNegative()) { + throw new IllegalArgumentException("title times must not be negative"); + } + } + + @Override + public String key() { + return KEY; + } +} diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/time/DurationFormatter.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/time/DurationFormatter.java similarity index 51% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/time/DurationFormatter.java rename to automessage-notice/src/main/java/com/github/imdmk/automessage/notice/time/DurationFormatter.java index 7a070e9..0eb0502 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/time/DurationFormatter.java +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/time/DurationFormatter.java @@ -1,17 +1,8 @@ -package com.github.imdmk.automessage.platform.time; +package com.github.imdmk.automessage.notice.time; import java.time.Duration; +import java.time.temporal.ChronoUnit; -/** - * Formats {@link Duration} values using the same notation the configuration files accept, - * for example {@code 10s}, {@code 1m30s} or {@code 500ms}. - * - * <p> - * {@link Duration#toString()} produces ISO-8601 output such as {@code PT1M30S}, which does not - * match what an administrator sees in the YAML files. Logging the configuration back in its own - * notation keeps console output and config files consistent. - * </p> - */ public final class DurationFormatter { private static final String UNSET = "unset"; @@ -50,4 +41,29 @@ private static void appendPart(StringBuilder builder, long amount, String unit) builder.append(amount).append(unit); } } + + /** + * The same duration written for somebody to read rather than for a configuration file. + */ + // Whole seconds, because the milliseconds in "34s572ms" are noise on a line a person reads, + // and spaced, because "1h 34s" is read at a glance where "1h34s" has to be picked apart. + // Anything left under a second still reads as "1s": a countdown that says "0s" while there + // is time left is worse than one that rounds. + public static String formatReadable(Duration duration) { + if (duration == null) { + return UNSET; + } + + final Duration whole = duration.truncatedTo(ChronoUnit.SECONDS); + + if (whole.isZero() && !duration.isZero()) { + return format(Duration.ofSeconds(duration.isNegative() ? -1L : 1L)); + } + + return space(format(whole)); + } + + private static String space(String formatted) { + return formatted.replaceAll("(?<=[a-z])(?=\\d)", " "); + } } diff --git a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/time/DurationParser.java b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/time/DurationParser.java similarity index 69% rename from automessage-core/src/main/java/com/github/imdmk/automessage/platform/time/DurationParser.java rename to automessage-notice/src/main/java/com/github/imdmk/automessage/notice/time/DurationParser.java index e3c2300..0b51cf5 100644 --- a/automessage-core/src/main/java/com/github/imdmk/automessage/platform/time/DurationParser.java +++ b/automessage-notice/src/main/java/com/github/imdmk/automessage/notice/time/DurationParser.java @@ -1,21 +1,10 @@ -package com.github.imdmk.automessage.platform.time; +package com.github.imdmk.automessage.notice.time; import java.time.Duration; import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; -/** - * Reads the same duration syntax the dispatcher configuration documents: {@code 500ms}, {@code - * 30s}, {@code 5m}, {@code 2h}, {@code 1d}, and combinations such as {@code 1h30m}. - * - * <p> - * The dispatcher gets this syntax from okaeri's {@code @DurationSpec}, which only applies to - * mapped config fields. Rules are written by a hand-rolled serializer, so they need their own - * reader — but not their own syntax: an administrator should never have to remember that one part - * of the file counts minutes differently from another. - * </p> - */ public final class DurationParser { private static final Pattern PART = Pattern.compile("(\\d+)(ms|s|m|h|d)"); @@ -25,17 +14,14 @@ private DurationParser() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated."); } - /** - * @param value duration such as {@code 1h30m} - * @return the parsed duration - * @throws IllegalArgumentException when the value is blank or not a duration - */ public static Duration parse(String value) { if (value == null || value.isBlank()) { throw new IllegalArgumentException("duration must not be blank"); } - final String normalized = value.trim().toLowerCase(Locale.ROOT); + // Whitespace anywhere, not just at the ends: durations are shown to operators spaced + // ("1h 34s"), and a value read off the screen has to be typeable back into a file. + final String normalized = value.replaceAll("\\s+", "").toLowerCase(Locale.ROOT); // Matches how 'period: 10' is read in messagesDispatcher.yml. if (BARE_NUMBER.matcher(normalized).matches()) { diff --git a/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/NoticeFormatCompatibilityTest.java b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/NoticeFormatCompatibilityTest.java new file mode 100644 index 0000000..34e0183 --- /dev/null +++ b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/NoticeFormatCompatibilityTest.java @@ -0,0 +1,172 @@ +package com.github.imdmk.automessage.notice; + +import eu.okaeri.configs.ConfigManager; +import eu.okaeri.configs.OkaeriConfig; +import eu.okaeri.configs.yaml.snakeyaml.YamlSnakeYamlConfigurer; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class NoticeFormatCompatibilityTest { + + public static final class Holder extends OkaeriConfig { + public Map<String, List<Notice>> shapes = new LinkedHashMap<>(); + } + + @TempDir + Path dir; + + private String writeAndRead(Map<String, List<Notice>> shapes) { + Path file = dir.resolve("shapes.yml"); + + Holder holder = ConfigManager.create(Holder.class, config -> config + .withConfigurer(new YamlSnakeYamlConfigurer()) + .withSerdesPack(registry -> registry.register(new NoticeSerializer())) + .withBindFile(file) + .withRemoveOrphans(true) + .saveDefaults() + .load(true)); + + holder.shapes = shapes; + holder.save(); + + try { + return Files.readString(file); + } catch (Exception e) { + throw new IllegalStateException(e); + } + } + + private List<Notice> reload(Map<String, List<Notice>> shapes, String key) { + writeAndRead(shapes); + + Holder reloaded = ConfigManager.create(Holder.class, config -> config + .withConfigurer(new YamlSnakeYamlConfigurer()) + .withSerdesPack(registry -> registry.register(new NoticeSerializer())) + .withBindFile(dir.resolve("shapes.yml")) + .withRemoveOrphans(true) + .load(true)); + + return reloaded.shapes.get(key); + } + + private static Map<String, List<Notice>> one(String key, Notice notice) { + Map<String, List<Notice>> shapes = new LinkedHashMap<>(); + shapes.put(key, List.of(notice)); + return shapes; + } + + @Test + @DisplayName("a single chat line is a bare string, exactly as before") + void chatSingle() { + assertThat(writeAndRead(one("m", Notice.chat("<gray>one line")))) + .contains("- <gray>one line"); + } + + @Test + @DisplayName("several chat lines are a nested list, exactly as before") + void chatMulti() { + String yaml = writeAndRead(one("m", Notice.chat("<gray>first", "<gray>second"))); + + assertThat(yaml).contains("- - <gray>first").contains(" - <gray>second"); + } + + @Test + @DisplayName("actionbar, title and subtitle keep their keys") + void keyedParts() { + assertThat(writeAndRead(one("m", Notice.actionBar("<yellow>bar")))).contains("actionbar: <yellow>bar"); + assertThat(writeAndRead(one("m", Notice.title("<red>T")))).contains("title: <red>T"); + assertThat(writeAndRead(one("m", Notice.subtitle("<gray>S")))).contains("subtitle: <gray>S"); + } + + @Test + @DisplayName("a title and its subtitle stay one entry with two keys") + void titleAndSubtitleShareAnEntry() { + String yaml = writeAndRead(one("m", Notice.title("<red>T", "<gray>S"))); + + assertThat(yaml).contains("- title: <red>T").contains(" subtitle: <gray>S"); + } + + @Test + @DisplayName("title times stay three durations on one line") + void titleTimes() { + Notice notice = Notice.title("<red>T", "<gray>S", + Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)); + + // Quoting is the emitter's choice, not this serializer's, and YAML reads both the same + // way - so the assertion is on the value, not on how it is punctuated. + assertThat(writeAndRead(one("m", notice))).contains("times: 500ms 3s 500ms"); + } + + @Test + @DisplayName("a bossbar keeps its nested shape and omits an unset progress") + void bossBar() { + String withProgress = writeAndRead(one("m", Notice.bossBar( + BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(5), 0.5D, "<green>bar"))); + + assertThat(withProgress) + .contains("- bossbar:") + .contains(" message: <green>bar") + .contains(" duration: 5s") + .contains(" color: RED") + .contains(" overlay: PROGRESS") + .contains(" progress: "); + + String withoutProgress = writeAndRead(one("m", Notice.bossBar( + BossBar.Color.BLUE, BossBar.Overlay.NOTCHED_10, Duration.ofSeconds(3), "<green>bar"))); + + assertThat(withoutProgress).doesNotContain("progress:"); + } + + @Test + @DisplayName("a sound with no settings is written as the bare key") + void bareSound() { + assertThat(writeAndRead(one("m", Notice.sound(Key.key("entity.player.levelup"))))) + .contains("sound: entity.player.levelup"); + } + + @Test + @DisplayName("a configured sound keeps the volume-then-pitch order the file has always used") + void soundOrder() { + // The order is the easy thing to get backwards, and getting it backwards would swap + // every configured sound on every server silently. + Notice notice = Notice.sound(Key.key("block.note_block.pling"), Sound.Source.MASTER, 2.0F, 7.0F); + + assertThat(writeAndRead(one("m", notice))) + .contains("sound: block.note_block.pling MASTER 2.0 7.0"); + } + + @Test + @DisplayName("every shape survives a save and a reload unchanged") + void roundTrips() { + Map<String, List<Notice>> shapes = new LinkedHashMap<>(); + shapes.put("all", List.of( + Notice.chat("<gray>one"), + Notice.chat("<gray>first", "<gray>second"), + Notice.actionBar("<yellow>bar"), + Notice.title("<red>T"), + Notice.subtitle("<gray>S"), + Notice.title("<red>T", "<gray>S"), + Notice.title("<red>T", "<gray>S", Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)), + Notice.hideTitle(), + Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(5), 0.5D, "<green>bar"), + Notice.bossBar(BossBar.Color.BLUE, BossBar.Overlay.NOTCHED_10, Duration.ofSeconds(3), "<green>bar"), + Notice.sound(Key.key("entity.player.levelup")), + Notice.sound(Key.key("block.note_block.pling"), Sound.Source.MASTER, 2.0F, 7.0F) + )); + + assertThat(reload(shapes, "all")).isEqualTo(shapes.get("all")); + } +} diff --git a/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/NoticeRendererTest.java b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/NoticeRendererTest.java new file mode 100644 index 0000000..9dcc63a --- /dev/null +++ b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/NoticeRendererTest.java @@ -0,0 +1,296 @@ +package com.github.imdmk.automessage.notice; + +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.bossbar.BossBar; +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import net.kyori.adventure.title.Title; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class NoticeRendererTest { + + // A clock the test winds by hand. immediate() cannot show a countdown: it runs every step + // before the assertion, so the bar is always already empty. + private static final class SteppingDelayer implements NoticeDelayer { + + private final Deque<Runnable> queue = new ArrayDeque<>(); + private final List<Duration> delays = new ArrayList<>(); + + @Override + public void runLater(Duration delay, Runnable action) { + delays.add(delay); + queue.add(action); + } + + int pending() { + return queue.size(); + } + + boolean step() { + Runnable next = queue.poll(); + if (next == null) { + return false; + } + + next.run(); + return true; + } + + int runAll() { + int ran = 0; + while (step()) { + ran++; + } + + return ran; + } + + Duration scheduled() { + return delays.stream().reduce(Duration.ZERO, Duration::plus); + } + } + + private static final class RecordingAudience implements Audience { + + private final List<String> chat = new ArrayList<>(); + private final List<String> actionBars = new ArrayList<>(); + private final List<Title> titles = new ArrayList<>(); + private final List<BossBar> shownBars = new ArrayList<>(); + private final List<BossBar> hiddenBars = new ArrayList<>(); + private final List<Sound> sounds = new ArrayList<>(); + private int titlesCleared; + + private static String plain(Component component) { + return PlainTextComponentSerializer.plainText().serialize(component); + } + + @Override + public void sendMessage(Component message) { + chat.add(plain(message)); + } + + @Override + public void sendActionBar(Component message) { + actionBars.add(plain(message)); + } + + @Override + public void showTitle(Title title) { + titles.add(title); + } + + @Override + public void clearTitle() { + titlesCleared++; + } + + @Override + public void showBossBar(BossBar bar) { + shownBars.add(bar); + } + + @Override + public void hideBossBar(BossBar bar) { + hiddenBars.add(bar); + } + + @Override + public void playSound(Sound sound) { + sounds.add(sound); + } + } + + private final RecordingAudience audience = new RecordingAudience(); + private final NoticeRenderer renderer = NoticeRenderer.miniMessage(NoticeDelayer.immediate()); + + @Test + @DisplayName("sends every chat line, with the formatting parsed rather than shown") + void rendersChat() { + renderer.render(Notice.chat("<red>first", "<gray>second"), audience); + + assertThat(audience.chat).containsExactly("first", "second"); + } + + @Test + @DisplayName("sends an action bar") + void rendersActionBar() { + renderer.render(Notice.actionBar("<yellow>above the hotbar"), audience); + + assertThat(audience.actionBars).containsExactly("above the hotbar"); + } + + @Test + @DisplayName("a title and its subtitle arrive as one title, not two") + void rendersTitleAsOne() { + renderer.render(Notice.title("<red>Title", "<gray>Sub"), audience); + + assertThat(audience.titles).hasSize(1); + assertThat(RecordingAudience.plain(audience.titles.getFirst().title())).isEqualTo("Title"); + assertThat(RecordingAudience.plain(audience.titles.getFirst().subtitle())).isEqualTo("Sub"); + } + + @Test + @DisplayName("a title written alone still arrives, with an empty subtitle") + void rendersTitleWithoutSubtitle() { + renderer.render(Notice.title("<red>Only"), audience); + + assertThat(audience.titles).hasSize(1); + assertThat(RecordingAudience.plain(audience.titles.getFirst().subtitle())).isEmpty(); + } + + @Test + @DisplayName("title times reach the title rather than being dropped") + void rendersTitleTimes() { + renderer.render( + Notice.title("<red>T", "<gray>S", Duration.ofMillis(500), Duration.ofSeconds(3), Duration.ofMillis(500)), + audience + ); + + Title.Times times = audience.titles.getFirst().times(); + + assertThat(times).isNotNull(); + assertThat(times.stay()).isEqualTo(Duration.ofSeconds(3)); + } + + @Test + @DisplayName("hideTitle clears whatever is on screen") + void clearsTitle() { + renderer.render(Notice.hideTitle(), audience); + + assertThat(audience.titlesCleared).isEqualTo(1); + } + + @Test + @DisplayName("a boss bar goes up and is taken away again") + void showsAndHidesBossBar() { + List<Duration> delays = new ArrayList<>(); + NoticeRenderer timed = NoticeRenderer.miniMessage((delay, action) -> { + delays.add(delay); + action.run(); + }); + + timed.render( + Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(5), 0.5D, "<green>bar"), + audience + ); + + assertThat(audience.shownBars).hasSize(1); + assertThat(audience.hiddenBars).containsExactlyElementsOf(audience.shownBars); + assertThat(delays).containsExactly(Duration.ofSeconds(5)); + assertThat(audience.shownBars.getFirst().progress()).isEqualTo(0.5F); + } + + @Test + @DisplayName("a boss bar with no progress of its own drains over its duration") + void bossBarWithoutProgressDrains() { + SteppingDelayer clock = new SteppingDelayer(); + NoticeRenderer timed = NoticeRenderer.miniMessage(clock); + + timed.render( + Notice.bossBar(BossBar.Color.BLUE, BossBar.Overlay.NOTCHED_10, Duration.ofSeconds(4), "<green>bar"), + audience + ); + + BossBar bar = audience.shownBars.getFirst(); + assertThat(bar.progress()).isEqualTo(BossBar.MAX_PROGRESS); + + clock.step(); + assertThat(bar.progress()).isLessThan(BossBar.MAX_PROGRESS); + + clock.runAll(); + + // Empty and gone, and the steps between them add up to exactly the configured duration. + assertThat(bar.progress()).isEqualTo(BossBar.MIN_PROGRESS); + assertThat(audience.hiddenBars).containsExactly(bar); + assertThat(clock.scheduled()).isEqualTo(Duration.ofSeconds(4)); + } + + @Test + @DisplayName("a boss bar that names a progress keeps it and simply disappears") + void bossBarWithProgressStaysPut() { + SteppingDelayer clock = new SteppingDelayer(); + NoticeRenderer timed = NoticeRenderer.miniMessage(clock); + + timed.render( + Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofSeconds(4), 0.25D, "<green>bar"), + audience + ); + + // One action, not a countdown: an administrator who wrote a progress asked for that fill. + assertThat(clock.pending()).isEqualTo(1); + + clock.runAll(); + + assertThat(audience.shownBars.getFirst().progress()).isEqualTo(0.25F); + assertThat(audience.hiddenBars).hasSize(1); + } + + @Test + @DisplayName("a long boss bar is not redrawn once per tick") + void longBossBarIsRateLimited() { + SteppingDelayer clock = new SteppingDelayer(); + NoticeRenderer timed = NoticeRenderer.miniMessage(clock); + + timed.render( + Notice.bossBar(BossBar.Color.RED, BossBar.Overlay.PROGRESS, Duration.ofMinutes(10), "<green>bar"), + audience + ); + + // Every step is a packet to one player, so ten minutes must not mean twelve thousand of + // them. The cap is what keeps a long countdown affordable on a full server. + assertThat(clock.runAll()).isLessThanOrEqualTo(41); + assertThat(audience.hiddenBars).hasSize(1); + } + + @Test + @DisplayName("plays a sound with the volume and pitch it was given") + void playsSound() { + renderer.render( + Notice.sound(Key.key("block.note_block.pling"), Sound.Source.MASTER, 2.0F, 7.0F), + audience + ); + + Sound sound = audience.sounds.getFirst(); + + assertThat(sound.name()).isEqualTo(Key.key("block.note_block.pling")); + assertThat(sound.volume()).isEqualTo(2.0F); + assertThat(sound.pitch()).isEqualTo(7.0F); + } + + @Test + @DisplayName("placeholders are substituted before the text is parsed") + void substitutesBeforeParsing() { + renderer.render(Notice.chat("<gray>online: {ONLINE}"), audience, + text -> text.replace("{ONLINE}", "47")); + + assertThat(audience.chat).containsExactly("online: 47"); + } + + @Test + @DisplayName("delivers every part of a multi-part notice") + void rendersEveryPart() { + renderer.render( + Notice.of( + ChatPart.of("<gray>chat"), + new ActionBarPart("<yellow>bar"), + SoundPart.of(Key.key("entity.experience_orb.pickup")) + ), + audience + ); + + assertThat(audience.chat).hasSize(1); + assertThat(audience.actionBars).hasSize(1); + assertThat(audience.sounds).hasSize(1); + } +} diff --git a/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/time/DurationFormatterTest.java b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/time/DurationFormatterTest.java new file mode 100644 index 0000000..4d355ab --- /dev/null +++ b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/time/DurationFormatterTest.java @@ -0,0 +1,63 @@ +package com.github.imdmk.automessage.notice.time; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.Duration; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class DurationFormatterTest { + + @Test + @DisplayName("Should format durations using the notation accepted by the config files") + void shouldFormatUsingConfigNotation() { + assertEquals("10s", DurationFormatter.format(Duration.ofSeconds(10))); + assertEquals("5m", DurationFormatter.format(Duration.ofMinutes(5))); + assertEquals("1h", DurationFormatter.format(Duration.ofHours(1))); + assertEquals("500ms", DurationFormatter.format(Duration.ofMillis(500))); + assertEquals("1m30s", DurationFormatter.format(Duration.ofSeconds(90))); + } + + @Test + @DisplayName("Should format zero and negative durations") + void shouldFormatZeroAndNegative() { + assertEquals("0s", DurationFormatter.format(Duration.ZERO)); + assertEquals("-5s", DurationFormatter.format(Duration.ofSeconds(-5))); + } + + @Test + @DisplayName("Should describe a missing duration instead of throwing") + void shouldDescribeMissingDuration() { + assertEquals("unset", DurationFormatter.format(null)); + } + + @Test + @DisplayName("written for a person: whole seconds, spaced units, no milliseconds") + void readableDropsMillisAndSpacesUnits() { + assertEquals("34s", DurationFormatter.formatReadable(Duration.ofMillis(34_572))); + assertEquals("1h 34s", DurationFormatter.formatReadable(Duration.ofSeconds(3_634))); + assertEquals("1h 2m 3s", DurationFormatter.formatReadable(Duration.ofSeconds(3_723))); + assertEquals("5m", DurationFormatter.formatReadable(Duration.ofMinutes(5))); + } + + @Test + @DisplayName("a fraction of a second left still reads as a second, never as none") + void readableNeverRoundsAwayARemainder() { + // "next in 0s" on a channel that has not fired yet reads as broken. + assertEquals("1s", DurationFormatter.formatReadable(Duration.ofMillis(400))); + assertEquals("1s", DurationFormatter.formatReadable(Duration.ofMillis(1))); + assertEquals("0s", DurationFormatter.formatReadable(Duration.ZERO)); + assertEquals("unset", DurationFormatter.formatReadable(null)); + } + + @Test + @DisplayName("the configuration notation keeps its milliseconds and stays unspaced") + void configNotationIsUntouched() { + // format() is what gets written into config.yml and read back by the parser; a space or + // a dropped unit there would change files on disk, not just a line in chat. + assertEquals("500ms", DurationFormatter.format(Duration.ofMillis(500))); + assertEquals("1m30s", DurationFormatter.format(Duration.ofSeconds(90))); + assertEquals("1s500ms", DurationFormatter.format(Duration.ofMillis(1_500))); + } +} diff --git a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/time/DurationParserTest.java b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/time/DurationParserTest.java similarity index 82% rename from automessage-core/src/test/java/com/github/imdmk/automessage/platform/time/DurationParserTest.java rename to automessage-notice/src/test/java/com/github/imdmk/automessage/notice/time/DurationParserTest.java index 4be9b35..29c4beb 100644 --- a/automessage-core/src/test/java/com/github/imdmk/automessage/platform/time/DurationParserTest.java +++ b/automessage-notice/src/test/java/com/github/imdmk/automessage/notice/time/DurationParserTest.java @@ -1,4 +1,4 @@ -package com.github.imdmk.automessage.platform.time; +package com.github.imdmk.automessage.notice.time; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -35,9 +35,12 @@ void bareNumberIsSeconds() { } @Test - @DisplayName("is case-insensitive and tolerates surrounding whitespace") + @DisplayName("is case-insensitive and tolerates whitespace, including between the units") void normalisesInput() { assertThat(DurationParser.parse(" 1H30M ")).isEqualTo(Duration.ofMinutes(90)); + // This is the shape a duration is shown in, so it has to be the shape one can be typed in. + assertThat(DurationParser.parse("1h 34s")).isEqualTo(Duration.ofSeconds(3_634)); + assertThat(DurationParser.parse("1h 2m 3s")).isEqualTo(Duration.ofSeconds(3_723)); } @Test diff --git a/automessage-slf4j/build.gradle.kts b/automessage-slf4j/build.gradle.kts new file mode 100644 index 0000000..2524839 --- /dev/null +++ b/automessage-slf4j/build.gradle.kts @@ -0,0 +1,11 @@ +// One SLF4J adapter for the three platforms handed an SLF4J logger - Velocity injects one, +// Minestom builds on one, Fabric inherits Minecraft's. Sponge is deliberately not among them: it +// hands out a Log4j logger, which is a different interface. +// +// compileOnly, because all three already provide SLF4J at runtime; a shaded second copy would be +// a logger nobody is reading. +dependencies { + api(project(":automessage-api")) + + compileOnly("org.slf4j:slf4j-api:${Versions.SLF4J}") +} diff --git a/automessage-slf4j/src/main/java/com/github/imdmk/automessage/logging/Slf4jPluginLogger.java b/automessage-slf4j/src/main/java/com/github/imdmk/automessage/logging/Slf4jPluginLogger.java new file mode 100644 index 0000000..25255b2 --- /dev/null +++ b/automessage-slf4j/src/main/java/com/github/imdmk/automessage/logging/Slf4jPluginLogger.java @@ -0,0 +1,25 @@ +package com.github.imdmk.automessage.logging; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; + +public final class Slf4jPluginLogger implements PluginLogger { + + private final Logger logger; + + public Slf4jPluginLogger(Logger logger) { + this.logger = logger; + } + + // The message is already formatted, so these overloads are the ones that do no substitution + // of their own - SLF4J would otherwise read a brace in the text as a placeholder. + @Override + public void log(Level level, String message, @Nullable Throwable throwable) { + switch (level) { + case INFO -> logger.info(message, throwable); + case WARN -> logger.warn(message, throwable); + case ERROR -> logger.error(message, throwable); + } + } +} diff --git a/automessage-sponge/build.gradle.kts b/automessage-sponge/build.gradle.kts new file mode 100644 index 0000000..8ba29b4 --- /dev/null +++ b/automessage-sponge/build.gradle.kts @@ -0,0 +1,70 @@ +import org.spongepowered.gradle.plugin.config.PluginLoaders +import org.spongepowered.plugin.metadata.model.PluginDependency + +plugins { + `automessage-platform` + `automessage-adventure-compat` + + id("org.spongepowered.gradle.plugin") +} + +// Sponge. +// +// A full server, and the closest of the non-Bukkit platforms to one: it keeps vanilla statistics +// and player profiles, its worlds have identities, and it speaks Adventure natively - against +// 4.26.1, the very version this plugin pins. What it does not have is PlaceholderAPI, which is a +// Bukkit plugin, and that is the only capability the configuration written here leaves out. +dependencies { + api(project(":automessage-core")) + + api("dev.rollczi:litecommands-sponge:${Versions.LITECOMMANDS}") + api("org.bstats:bstats-sponge:${Versions.BSTATS_SPONGE}") +} + +// Generates `META-INF/sponge_plugins.json`, which is what Sponge reads to find the entry point. +// +// Also contributes the `spongeapi` dependency, so it is not declared above: naming the version +// twice is how the metadata and the compile classpath come to disagree. +sponge { + apiVersion(Versions.SPONGE_API) + license("MIT") + + loader { + name(PluginLoaders.JAVA_PLAIN) + version("1.0") + } + + plugin("automessage") { + displayName("AutoMessage") + entrypoint("com.github.imdmk.automessage.sponge.AutoMessageSpongePlugin") + description("High-performance plugin for fully customizable automatic server-wide broadcasts.") + + contributor("imDMK") { + description("Author") + } + + links { + homepage("https://github.com/imDMK/AutoMessage") + source("https://github.com/imDMK/AutoMessage") + issues("https://github.com/imDMK/AutoMessage/issues") + } + + dependency("spongeapi") { + loadOrder(PluginDependency.LoadOrder.AFTER) + optional(false) + } + } +} + +automessagePlatform { + runPort = 25596 + platformName = "Sponge" + + // One API version is one Minecraft version here, so the jar says which rather than inheriting + // the Bukkit family's range, which it has nothing to do with. + supportedVersions = Versions.SPONGE_MINECRAFT + + // Sponge provides Adventure itself, against the very version this plugin pins. + providedByPlatform("net.kyori") +} + diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/AutoMessageSpongePlugin.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/AutoMessageSpongePlugin.java new file mode 100644 index 0000000..925f015 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/AutoMessageSpongePlugin.java @@ -0,0 +1,106 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.AutoMessage; +import com.github.imdmk.automessage.command.CommandRegistrar; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.google.inject.Inject; +import dev.rollczi.litecommands.LiteCommands; +import org.bstats.sponge.Metrics; +import org.spongepowered.api.Game; +import org.spongepowered.api.Server; +import org.spongepowered.api.config.ConfigDir; +import org.spongepowered.api.event.Listener; +import org.spongepowered.api.event.lifecycle.ConstructPluginEvent; +import org.spongepowered.api.event.lifecycle.StartedEngineEvent; +import org.spongepowered.api.event.lifecycle.StoppingEngineEvent; +import org.spongepowered.plugin.PluginContainer; +import org.spongepowered.plugin.builtin.jvm.Plugin; + +import java.lang.invoke.MethodHandles; +import java.nio.file.Path; + +@Plugin("automessage") +public final class AutoMessageSpongePlugin { + + private static final int BSTATS_PLUGIN_ID = 20910; + + private final PluginContainer container; + private final Game game; + private final Path configDirectory; + private final Metrics metrics; + + private AutoMessage automessage; + private SpongeTaskScheduler scheduler; + private LiteCommands<?> liteCommands; + + @Inject + public AutoMessageSpongePlugin( + PluginContainer container, + Game game, + @ConfigDir(sharedRoot = false) Path configDirectory, + Metrics.Factory metricsFactory + ) { + this.container = container; + this.game = game; + this.configDirectory = configDirectory; + this.metrics = metricsFactory.make(BSTATS_PLUGIN_ID); + } + + // Everything is built here, before Sponge asks plugins to register commands - an event whose + // own documentation warns it "does not guarantee that any specific engine is running", and on + // SpongeVanilla none is. The scheduler and the viewer registry are both written to survive + // that, and the command binding is assembled by SpongeCommands for the same reason. + @Listener + public void onConstruct(ConstructPluginEvent event) { + final SpongeTaskScheduler scheduler = new SpongeTaskScheduler(container, game); + this.scheduler = scheduler; + + this.automessage = new AutoMessage( + new SpongePlatform(game, scheduler), + new SpongePluginLogger(container.logger()), + configDirectory.toFile(), + // PlaceholderAPI is a Bukkit plugin and has no Sponge build; nothing here owns + // another plugin's %tokens%. + ExternalPlaceholderResolver.disabled() + ); + + // The lookup overload, not the two-argument one: without it Sponge reflects into the + // listener itself, which is deprecated and fails outright on a module it cannot open. + game.eventManager().registerListeners( + container, + new SpongeTriggerListener(game, automessage.triggerService()), + MethodHandles.lookup() + ); + + final var commandBuilder = SpongeCommands.builder(container, game); + CommandRegistrar.configure(commandBuilder, automessage, new SpongeViewerFactory()); + + this.liteCommands = commandBuilder.build(); + + metrics.startup(event); + } + + // Hands the scheduler the server thread it has been waiting for; anything scheduled during + // construction, the broadcast loop included, has been held until now. + @Listener + public void onStarted(StartedEngineEvent<Server> event) { + scheduler.engineStarted(); + } + + // Each part is checked on its own: startup can fail partway, and a teardown that assumes it + // did not throws over the top of the failure that actually matters. + @Listener + public void onStopping(StoppingEngineEvent<Server> event) { + if (automessage != null) { + automessage.shutdown(); + this.automessage = null; + } + + if (liteCommands != null) { + liteCommands.unregister(); + this.liteCommands = null; + } + + metrics.shutdown(); + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeCommandScheduler.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeCommandScheduler.java new file mode 100644 index 0000000..ac4b2e5 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeCommandScheduler.java @@ -0,0 +1,70 @@ +package com.github.imdmk.automessage.sponge; + +import dev.rollczi.litecommands.scheduler.AbstractMainThreadBasedScheduler; +import org.spongepowered.api.Game; +import org.spongepowered.api.scheduler.ScheduledTask; +import org.spongepowered.api.scheduler.Scheduler; +import org.spongepowered.api.scheduler.Task; +import org.spongepowered.plugin.PluginContainer; + +import java.time.Duration; + +// The scheduler LiteCommands runs commands on, written here for one reason: the upstream Sponge +// one asks Sponge for the main thread in its constructor, and throws when neither a server nor a +// client engine is running. +// +// That matters because the command binding has to exist before RegisterCommandEvent, which Sponge +// fires with no engine at all - so upstream, the binding cannot be built in time to register +// anything. This one asks at the moment a command actually runs, by which point there is always +// an engine, and is otherwise the same thing. +final class SpongeCommandScheduler extends AbstractMainThreadBasedScheduler { + + private final PluginContainer container; + private final Game game; + + SpongeCommandScheduler(PluginContainer container, Game game) { + this.container = container; + this.game = game; + } + + @Override + protected void runSynchronous(Runnable runnable, Duration delay) { + mainScheduler().submit(task(runnable, delay)); + } + + @Override + protected void runAsynchronous(Runnable runnable, Duration delay) { + game.asyncScheduler().submit(task(runnable, delay)); + } + + @Override + public void shutdown() { + game.asyncScheduler().tasks(container).forEach(ScheduledTask::cancel); + + if (game.isServerAvailable()) { + game.server().scheduler().tasks(container).forEach(ScheduledTask::cancel); + } + } + + // Nothing can have run a command before an engine exists, so the last branch is unreachable + // in practice - it is there so a lookup can never be the thing that throws. + private Scheduler mainScheduler() { + if (game.isServerAvailable()) { + return game.server().scheduler(); + } + + if (game.isClientAvailable()) { + return game.client().scheduler(); + } + + return game.asyncScheduler(); + } + + private Task task(Runnable runnable, Duration delay) { + return Task.builder() + .plugin(container) + .execute(runnable) + .delay(delay) + .build(); + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeCommands.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeCommands.java new file mode 100644 index 0000000..2613cee --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeCommands.java @@ -0,0 +1,45 @@ +package com.github.imdmk.automessage.sponge; + +import dev.rollczi.litecommands.LiteCommandsBaseBuilder; +import dev.rollczi.litecommands.LiteCommandsBuilder; +import dev.rollczi.litecommands.LiteCommandsFactory; +import dev.rollczi.litecommands.permission.PermissionResolver; +import dev.rollczi.litecommands.sponge.LiteSpongeSettings; +import dev.rollczi.litecommands.sponge.SpongePlatform; +import org.spongepowered.api.Game; +import org.spongepowered.api.command.CommandCause; +import org.spongepowered.plugin.PluginContainer; + +// What LiteSpongeFactory does, minus the one thing that cannot be done yet. +// +// The factory upstream builds its scheduler eagerly, and that scheduler asks Sponge for the main +// thread in its constructor - which throws when no engine is running. Sponge fires +// RegisterCommandEvent, the only moment it accepts commands, with no engine at all, so calling the +// factory in time to register anything is impossible. Everything else it sets up is either +// something this plugin does not use or something assembled here from public API. +final class SpongeCommands { + + private SpongeCommands() { + } + + // The type parameter is the builder's own, exactly as LiteSpongeFactory declares it, so the + // caller infers it and never has to name it. + static <B extends LiteCommandsBaseBuilder<CommandCause, LiteSpongeSettings, B>> + LiteCommandsBuilder<CommandCause, LiteSpongeSettings, B> builder( + PluginContainer container, + Game game + ) { + final LiteSpongeSettings settings = new LiteSpongeSettings(); + + // The platform is what registers the listener that answers RegisterCommandEvent, so + // constructing this is the whole point of doing it early. + return LiteCommandsFactory.<CommandCause, LiteSpongeSettings, B>builder( + CommandCause.class, + internal -> new SpongePlatform(container, settings, internal.getPermissionService()) + ) + .permissionResolver(PermissionResolver.createDefault( + CommandCause.class, CommandCause::hasPermission + )) + .scheduler(new SpongeCommandScheduler(container, game)); + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePlatform.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePlatform.java new file mode 100644 index 0000000..ba649b1 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePlatform.java @@ -0,0 +1,46 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.platform.Platform; +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import org.spongepowered.api.Game; + +public final class SpongePlatform implements Platform { + + private final TaskScheduler scheduler; + private final ViewerRegistry viewers; + private final PlaytimeSource playtime = new SpongePlaytimeSource(); + + public SpongePlatform(Game game, TaskScheduler scheduler) { + this.scheduler = scheduler; + this.viewers = new SpongeViewerRegistry(game); + } + + @Override + public String name() { + return "Sponge"; + } + + @Override + public Capabilities capabilities() { + return Capabilities.allExcept(Capability.EXTERNAL_PLACEHOLDERS); + } + + @Override + public ViewerRegistry viewers() { + return viewers; + } + + @Override + public TaskScheduler scheduler() { + return scheduler; + } + + @Override + public PlaytimeSource playtime() { + return playtime; + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePlaytimeSource.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePlaytimeSource.java new file mode 100644 index 0000000..01b7da3 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePlaytimeSource.java @@ -0,0 +1,26 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.Viewer; +import org.spongepowered.api.data.Keys; +import org.spongepowered.api.statistic.Statistics; + +import java.time.Duration; +import java.util.Optional; + +public final class SpongePlaytimeSource implements PlaytimeSource { + + private static final long MILLIS_PER_TICK = 50L; + + @Override + public Optional<Duration> playtimeOf(Viewer viewer) { + if (!(viewer instanceof SpongeViewer sponge)) { + return Optional.empty(); + } + + return sponge.player() + .flatMap(player -> player.get(Keys.STATISTICS)) + .map(statistics -> statistics.get(Statistics.PLAY_TIME.get())) + .map(ticks -> Duration.ofMillis(ticks * MILLIS_PER_TICK)); + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePluginLogger.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePluginLogger.java new file mode 100644 index 0000000..bbec420 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongePluginLogger.java @@ -0,0 +1,25 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import org.apache.logging.log4j.Logger; +import org.jetbrains.annotations.Nullable; + +public final class SpongePluginLogger implements PluginLogger { + + private final Logger logger; + + public SpongePluginLogger(Logger logger) { + this.logger = logger; + } + + // The message is already formatted, so these overloads are the ones that do no substitution + // of their own - Log4j would otherwise read a brace in the text as a placeholder. + @Override + public void log(Level level, String message, @Nullable Throwable throwable) { + switch (level) { + case INFO -> logger.info(message, throwable); + case WARN -> logger.warn(message, throwable); + case ERROR -> logger.error(message, throwable); + } + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeTaskScheduler.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeTaskScheduler.java new file mode 100644 index 0000000..31e5257 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeTaskScheduler.java @@ -0,0 +1,137 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.api.Game; +import org.spongepowered.api.scheduler.ScheduledTask; +import org.spongepowered.api.scheduler.Scheduler; +import org.spongepowered.api.scheduler.Task; +import org.spongepowered.plugin.PluginContainer; + +import java.time.Duration; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; + +public final class SpongeTaskScheduler implements TaskScheduler { + + private final PluginContainer container; + private final Game game; + + // Sponge's async scheduler belongs to the game and exists from plugin construction; the + // synchronous one belongs to the server engine, which does not exist yet at that point. The + // plugin has to be built that early because RegisterCommandEvent "does not guarantee that any + // specific engine is running", so submissions made before the engine arrives are held here + // and replayed in engineStarted() rather than quietly running off the server thread. + private final Queue<DeferredTask> deferred = new ConcurrentLinkedQueue<>(); + + public SpongeTaskScheduler(PluginContainer container, Game game) { + this.container = container; + this.game = game; + } + + @Override + public TaskHandle runAsync(Runnable runnable) { + // The game's own pool, available from plugin construction onwards - nothing to defer. + return submit(game.asyncScheduler(), runnable, Duration.ZERO, null); + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + return submitSync(runnable, delay, null); + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + return submitSync(task, task.delay(), task.period()); + } + + public void engineStarted() { + final Scheduler scheduler = game.server().scheduler(); + + DeferredTask task; + while ((task = deferred.poll()) != null) { + task.start(scheduler); + } + } + + @Override + public void shutdown() { + deferred.forEach(DeferredTask::cancel); + deferred.clear(); + + game.asyncScheduler().tasks(container).forEach(ScheduledTask::cancel); + + if (game.isServerAvailable()) { + game.server().scheduler().tasks(container).forEach(ScheduledTask::cancel); + } + } + + private TaskHandle submitSync(Runnable runnable, Duration delay, @Nullable Duration period) { + if (game.isServerAvailable()) { + return submit(game.server().scheduler(), runnable, delay, period); + } + + final DeferredTask task = new DeferredTask(runnable, delay, period); + deferred.add(task); + + return task; + } + + private TaskHandle submit(Scheduler scheduler, Runnable runnable, Duration delay, @Nullable Duration period) { + final Task.Builder builder = Task.builder() + .plugin(container) + .execute(runnable) + .delay(delay); + + if (period != null) { + builder.interval(period); + } + + final ScheduledTask task = scheduler.submit(builder.build()); + return task::cancel; + } + + private final class DeferredTask implements TaskHandle { + + private final Runnable runnable; + private final Duration delay; + private final @Nullable Duration period; + + private volatile boolean cancelled; + private volatile @Nullable TaskHandle started; + + private DeferredTask(Runnable runnable, Duration delay, @Nullable Duration period) { + this.runnable = runnable; + this.delay = delay; + this.period = period; + } + + // Cancelling before the engine starts has to stick: the caller cannot tell its task was + // never really scheduled, and one coming back to life on startup is a broadcast nobody + // asked for. + private void start(Scheduler scheduler) { + if (cancelled) { + return; + } + + this.started = submit(scheduler, runnable, delay, period); + + // Cancelled between the check above and the submission: honour it now. + if (cancelled) { + started.cancel(); + } + } + + @Override + public void cancel() { + this.cancelled = true; + + final TaskHandle handle = started; + if (handle != null) { + handle.cancel(); + } + } + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeTriggerListener.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeTriggerListener.java new file mode 100644 index 0000000..0b74a9c --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeTriggerListener.java @@ -0,0 +1,38 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService; +import org.spongepowered.api.Game; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; +import org.spongepowered.api.event.Listener; +import org.spongepowered.api.event.Order; +import org.spongepowered.api.event.network.ServerSideConnectionEvent; + +public final class SpongeTriggerListener { + + private final Game game; + private final MessageTriggerService triggerService; + + public SpongeTriggerListener(Game game, MessageTriggerService triggerService) { + this.game = game; + this.triggerService = triggerService; + } + + @Listener(order = Order.POST) + public void onJoin(ServerSideConnectionEvent.Join event) { + final ServerPlayer player = event.player(); + + triggerService.onJoin(SpongeViewer.of(player), !player.hasPlayedBefore()); + + // The joining player is already in the server's collection by the time this fires. + triggerService.onPlayerCountChanged(game.server().onlinePlayers().size()); + } + + @Listener(order = Order.POST) + public void onLeave(ServerSideConnectionEvent.Leave event) { + // The leaving player is still counted here, so the post-quit population is one lower. + // Milestones need that number to rearm at the right moment. + final int remaining = game.server().onlinePlayers().size() - 1; + + triggerService.onPlayerCountChanged(Math.max(0, remaining)); + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewer.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewer.java new file mode 100644 index 0000000..ef7147c --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewer.java @@ -0,0 +1,113 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import net.kyori.adventure.audience.Audience; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import org.jetbrains.annotations.Nullable; +import org.spongepowered.api.command.CommandCause; +import org.spongepowered.api.data.Keys; +import org.spongepowered.api.entity.living.player.server.ServerPlayer; +import org.spongepowered.api.service.permission.Subject; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +public final class SpongeViewer implements Viewer { + + private static final String CONSOLE_NAME = "CONSOLE"; + + private final Audience audience; + private final Subject subject; + private final @Nullable ServerPlayer player; + + private SpongeViewer(Audience audience, Subject subject, @Nullable ServerPlayer player) { + this.audience = audience; + this.subject = subject; + this.player = player; + } + + public static SpongeViewer of(ServerPlayer player) { + return new SpongeViewer(player, player, player); + } + + public static SpongeViewer of(CommandCause cause) { + return cause.first(ServerPlayer.class) + .map(SpongeViewer::of) + .orElseGet(() -> new SpongeViewer(cause.audience(), cause.subject(), null)); + } + + public Optional<ServerPlayer> player() { + return Optional.ofNullable(player); + } + + @Override + public String name() { + return player != null ? player.name() : CONSOLE_NAME; + } + + @Override + public UUID uniqueId() { + return player != null ? player.uniqueId() : CONSOLE_ID; + } + + @Override + public String displayName() { + if (player == null) { + return CONSOLE_NAME; + } + + // Sponge's display name is a Component; flattened here because the rest of the plugin + // treats a display name as text to substitute into a message, not as markup to nest. + return player.get(Keys.DISPLAY_NAME) + .map(PlainTextComponentSerializer.plainText()::serialize) + .orElseGet(player::name); + } + + @Override + public String locale() { + // Lowercased with an underscore, matching the raw Bukkit client string, so one language + // file name works whichever platform reads it - see Viewer#locale. + return player != null + ? player.locale().toString().toLowerCase(Locale.ROOT) + : ""; + } + + @Override + public Optional<String> world() { + return player != null + ? Optional.of(player.world().key().asString()) + : Optional.empty(); + } + + @Override + public boolean hasPermission(String permission) { + return subject.hasPermission(permission); + } + + @Override + public boolean isPlayer() { + return player != null; + } + + @Override + public boolean isOnline() { + return player == null || player.isOnline(); + } + + @Override + public Audience audience() { + // Sponge speaks Adventure natively, so there is nothing to adapt. + return audience; + } + + @Override + public boolean equals(Object other) { + return other instanceof SpongeViewer viewer && uniqueId().equals(viewer.uniqueId()); + } + + @Override + public int hashCode() { + return uniqueId().hashCode(); + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewerFactory.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewerFactory.java new file mode 100644 index 0000000..16f2ee2 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewerFactory.java @@ -0,0 +1,13 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import org.spongepowered.api.command.CommandCause; + +public final class SpongeViewerFactory implements ViewerFactory<CommandCause> { + + @Override + public Viewer of(CommandCause cause) { + return SpongeViewer.of(cause); + } +} diff --git a/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewerRegistry.java b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewerRegistry.java new file mode 100644 index 0000000..a2b4522 --- /dev/null +++ b/automessage-sponge/src/main/java/com/github/imdmk/automessage/sponge/SpongeViewerRegistry.java @@ -0,0 +1,41 @@ +package com.github.imdmk.automessage.sponge; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import org.jetbrains.annotations.Unmodifiable; +import org.spongepowered.api.Game; + +import java.util.Collection; +import java.util.List; + +public final class SpongeViewerRegistry implements ViewerRegistry { + + private final Game game; + + public SpongeViewerRegistry(Game game) { + this.game = game; + } + + @Override + @Unmodifiable + public Collection<Viewer> online() { + if (!game.isServerAvailable()) { + return List.of(); + } + + // Copied rather than wrapped: the dispatcher walks this while players join and leave. + return game.server().onlinePlayers().stream() + .map(player -> (Viewer) SpongeViewer.of(player)) + .toList(); + } + + @Override + public int onlineCount() { + return game.isServerAvailable() ? game.server().onlinePlayers().size() : 0; + } + + @Override + public int maxPlayers() { + return game.isServerAvailable() ? game.server().maxPlayers() : -1; + } +} diff --git a/automessage-velocity/build.gradle.kts b/automessage-velocity/build.gradle.kts new file mode 100644 index 0000000..f47470a --- /dev/null +++ b/automessage-velocity/build.gradle.kts @@ -0,0 +1,74 @@ +plugins { + `automessage-platform` + `automessage-adventure-compat` + + id("xyz.jpenilla.run-velocity") +} + +// The Velocity proxy. +// +// Velocity speaks Adventure natively, so there is no platform bridge to shade here - a Player is +// already an Audience. What it does not have is worlds, playtime, or PlaceholderAPI, and the +// configuration it writes says so. +dependencies { + api(project(":automessage-core")) + api(project(":automessage-slf4j")) + + compileOnly("com.velocitypowered:velocity-api:${Versions.VELOCITY_API}") + annotationProcessor("com.velocitypowered:velocity-api:${Versions.VELOCITY_API}") + + api("dev.rollczi:litecommands-velocity:${Versions.LITECOMMANDS}") + api("org.bstats:bstats-velocity:${Versions.BSTATS_VELOCITY}") +} + +automessagePlatform { + platformName = "Velocity" + + // The proxy provides Adventure; a second copy under another package would make every + // Component the plugin builds unusable there. + providedByPlatform("net.kyori") +} + +// Velocity reads the plugin's version from an annotation, which has to be a compile-time +// constant - so it is generated from the project version rather than typed in twice and left to +// drift. +val generateBuildConstants by tasks.registering { + val output = layout.buildDirectory.dir("generated/sources/buildConstants") + val pluginVersion = project.version.toString() + + inputs.property("version", pluginVersion) + outputs.dir(output) + + doLast { + val target = output.get().asFile.resolve("com/github/imdmk/automessage/velocity") + target.mkdirs() + target.resolve("BuildConstants.java").writeText( + """ + package com.github.imdmk.automessage.velocity; + + // Generated from the Gradle project version - do not edit. + public final class BuildConstants { + + public static final String VERSION = "$pluginVersion"; + + private BuildConstants() { + } + } + """.trimIndent() + "\n" + ) + } +} + +sourceSets.main { + java.srcDir(generateBuildConstants) +} + +// run-velocity calls its task runVelocity; this adds the runServer every other module answers to. +// Both are configured together so it does not matter which one gets started. +tasks.register<xyz.jpenilla.runvelocity.task.RunVelocity>("runServer") { + description = "Run a Velocity proxy for plugin testing." +} + +tasks.withType<xyz.jpenilla.runvelocity.task.RunVelocity>().configureEach { + velocityVersion(Versions.VELOCITY_API) +} diff --git a/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/AutoMessageVelocityPlugin.java b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/AutoMessageVelocityPlugin.java new file mode 100644 index 0000000..7dc41c3 --- /dev/null +++ b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/AutoMessageVelocityPlugin.java @@ -0,0 +1,95 @@ +package com.github.imdmk.automessage.velocity; + +import com.github.imdmk.automessage.AutoMessage; +import com.github.imdmk.automessage.command.CommandRegistrar; +import com.github.imdmk.automessage.logging.Slf4jPluginLogger; +import com.github.imdmk.automessage.platform.logger.PluginLogger; +import com.github.imdmk.automessage.platform.placeholder.ExternalPlaceholderResolver; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.google.inject.Inject; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.proxy.ProxyInitializeEvent; +import com.velocitypowered.api.event.proxy.ProxyShutdownEvent; +import com.velocitypowered.api.plugin.Plugin; +import com.velocitypowered.api.plugin.annotation.DataDirectory; +import com.velocitypowered.api.proxy.ProxyServer; +import dev.rollczi.litecommands.LiteCommands; +import dev.rollczi.litecommands.velocity.LiteVelocityFactory; +import org.bstats.velocity.Metrics; +import org.slf4j.Logger; + +import java.nio.file.Path; + +@Plugin( + id = "automessage", + name = AutoMessage.NAME, + version = BuildConstants.VERSION, + description = "High-performance plugin for fully customizable automatic server-wide broadcasts.", + url = "https://github.com/imDMK/AutoMessage", + authors = {"imDMK"} +) +public final class AutoMessageVelocityPlugin { + + private static final int BSTATS_PLUGIN_ID = 20909; + + private final ProxyServer proxy; + private final Logger slf4jLogger; + private final Path dataDirectory; + private final Metrics.Factory metricsFactory; + + private AutoMessage automessage; + private LiteCommands<?> liteCommands; + + @Inject + public AutoMessageVelocityPlugin( + ProxyServer proxy, + Logger slf4jLogger, + @DataDirectory Path dataDirectory, + Metrics.Factory metricsFactory + ) { + this.proxy = proxy; + this.slf4jLogger = slf4jLogger; + this.dataDirectory = dataDirectory; + this.metricsFactory = metricsFactory; + } + + @Subscribe + public void onProxyInitialize(ProxyInitializeEvent event) { + final PluginLogger logger = new Slf4jPluginLogger(slf4jLogger); + final TaskScheduler scheduler = new VelocityTaskScheduler(this, proxy); + + this.automessage = new AutoMessage( + new VelocityPlatform(proxy, scheduler), + logger, + dataDirectory.toFile(), + // PlaceholderAPI is a Bukkit plugin; nothing on a proxy owns %tokens%. + ExternalPlaceholderResolver.disabled() + ); + + final VelocityViewerFactory viewers = new VelocityViewerFactory(); + + proxy.getEventManager().register( + this, + new VelocityTriggerListener(proxy, viewers, automessage.triggerService()) + ); + + final var commandBuilder = LiteVelocityFactory.builder(proxy); + CommandRegistrar.configure(commandBuilder, automessage, viewers); + + this.liteCommands = commandBuilder.build(); + + metricsFactory.make(this, BSTATS_PLUGIN_ID); + } + + @Subscribe + public void onProxyShutdown(ProxyShutdownEvent event) { + if (automessage == null) { + return; + } + + automessage.shutdown(); + liteCommands.unregister(); + + this.automessage = null; + } +} diff --git a/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityPlatform.java b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityPlatform.java new file mode 100644 index 0000000..e165f00 --- /dev/null +++ b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityPlatform.java @@ -0,0 +1,52 @@ +package com.github.imdmk.automessage.velocity; + +import com.github.imdmk.automessage.platform.Platform; +import com.github.imdmk.automessage.platform.capability.Capabilities; +import com.github.imdmk.automessage.platform.capability.Capability; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.github.imdmk.automessage.platform.viewer.PlaytimeSource; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import com.velocitypowered.api.proxy.ProxyServer; + +public final class VelocityPlatform implements Platform { + + private final TaskScheduler scheduler; + private final ViewerRegistry viewers; + + public VelocityPlatform(ProxyServer proxy, TaskScheduler scheduler) { + this.scheduler = scheduler; + this.viewers = new VelocityViewerRegistry(proxy); + } + + @Override + public String name() { + return "Velocity"; + } + + @Override + public Capabilities capabilities() { + return Capabilities.of( + Capability.PERMISSION_RULE, + Capability.GROUP_RULE, + Capability.SOUND_NOTICE, + Capability.BOSSBAR_NOTICE, + Capability.TITLE_NOTICE, + Capability.METRICS + ); + } + + @Override + public ViewerRegistry viewers() { + return viewers; + } + + @Override + public TaskScheduler scheduler() { + return scheduler; + } + + @Override + public PlaytimeSource playtime() { + return PlaytimeSource.unavailable(); + } +} diff --git a/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityTaskScheduler.java b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityTaskScheduler.java new file mode 100644 index 0000000..0087eeb --- /dev/null +++ b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityTaskScheduler.java @@ -0,0 +1,54 @@ +package com.github.imdmk.automessage.velocity; + +import com.github.imdmk.automessage.platform.scheduler.PluginTask; +import com.github.imdmk.automessage.platform.scheduler.TaskHandle; +import com.github.imdmk.automessage.platform.scheduler.TaskScheduler; +import com.velocitypowered.api.proxy.ProxyServer; +import com.velocitypowered.api.scheduler.ScheduledTask; + +import java.time.Duration; + +public final class VelocityTaskScheduler implements TaskScheduler { + + private final Object plugin; + private final ProxyServer proxy; + + public VelocityTaskScheduler(Object plugin, ProxyServer proxy) { + this.plugin = plugin; + this.proxy = proxy; + } + + @Override + public TaskHandle runAsync(Runnable runnable) { + return handle(proxy.getScheduler() + .buildTask(plugin, runnable) + .schedule()); + } + + @Override + public TaskHandle runLaterSync(Runnable runnable, Duration delay) { + return handle(proxy.getScheduler() + .buildTask(plugin, runnable) + .delay(delay) + .schedule()); + } + + @Override + public TaskHandle runTimerSync(PluginTask task) { + return handle(proxy.getScheduler() + .buildTask(plugin, (Runnable) task) + .delay(task.delay()) + .repeat(task.period()) + .schedule()); + } + + @Override + public void shutdown() { + // Velocity cancels a plugin's tasks when it unregisters the plugin, and offers no handle + // to do it early, so there is nothing to do here that would not be done anyway. + } + + private TaskHandle handle(ScheduledTask task) { + return task::cancel; + } +} diff --git a/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityTriggerListener.java b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityTriggerListener.java new file mode 100644 index 0000000..b02bdce --- /dev/null +++ b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityTriggerListener.java @@ -0,0 +1,40 @@ +package com.github.imdmk.automessage.velocity; + +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import com.github.imdmk.automessage.scheduled.trigger.MessageTriggerService; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.event.Subscribe; +import com.velocitypowered.api.event.connection.DisconnectEvent; +import com.velocitypowered.api.event.connection.PostLoginEvent; +import com.velocitypowered.api.proxy.ProxyServer; + +public final class VelocityTriggerListener { + + private final ProxyServer proxy; + private final ViewerFactory<CommandSource> viewers; + private final MessageTriggerService triggerService; + + public VelocityTriggerListener( + ProxyServer proxy, + ViewerFactory<CommandSource> viewers, + MessageTriggerService triggerService + ) { + this.proxy = proxy; + this.viewers = viewers; + this.triggerService = triggerService; + } + + @Subscribe + public void onLogin(PostLoginEvent event) { + triggerService.onJoin(viewers.of(event.getPlayer()), false); + + // PostLoginEvent fires once the player is counted, so this is the population including them. + triggerService.onPlayerCountChanged(proxy.getPlayerCount()); + } + + @Subscribe + public void onDisconnect(DisconnectEvent event) { + // Unlike Bukkit's quit event, the player is already gone from the count here. + triggerService.onPlayerCountChanged(proxy.getPlayerCount()); + } +} diff --git a/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewer.java b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewer.java new file mode 100644 index 0000000..224ac3f --- /dev/null +++ b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewer.java @@ -0,0 +1,77 @@ +package com.github.imdmk.automessage.velocity; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.velocitypowered.api.command.CommandSource; +import com.velocitypowered.api.proxy.Player; +import net.kyori.adventure.audience.Audience; + +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; + +public final class VelocityViewer implements Viewer { + + private static final String CONSOLE_NAME = "CONSOLE"; + + private final CommandSource source; + + public VelocityViewer(CommandSource source) { + this.source = source; + } + + @Override + public String name() { + return source instanceof Player player ? player.getUsername() : CONSOLE_NAME; + } + + @Override + public UUID uniqueId() { + return source instanceof Player player ? player.getUniqueId() : CONSOLE_ID; + } + + @Override + public String displayName() { + return name(); + } + + @Override + public String locale() { + if (!(source instanceof Player player)) { + return ""; + } + + // Lowercased with an underscore, matching what the Bukkit client string looks like, so + // one language file name works whichever platform reads it - see Viewer#locale. + // Null until the client has sent its settings, which it has not yet done for a player + // caught in the moment right after login. + return Optional.ofNullable(player.getEffectiveLocale()) + .map(locale -> locale.toString().toLowerCase(Locale.ROOT)) + .orElse(""); + } + + @Override + public Optional<String> world() { + return Optional.empty(); + } + + @Override + public boolean hasPermission(String permission) { + return source.hasPermission(permission); + } + + @Override + public boolean isPlayer() { + return source instanceof Player; + } + + @Override + public boolean isOnline() { + return !(source instanceof Player player) || player.isActive(); + } + + @Override + public Audience audience() { + // Velocity speaks Adventure natively, so there is nothing to adapt. + return source; + } +} diff --git a/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewerFactory.java b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewerFactory.java new file mode 100644 index 0000000..abece32 --- /dev/null +++ b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewerFactory.java @@ -0,0 +1,13 @@ +package com.github.imdmk.automessage.velocity; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerFactory; +import com.velocitypowered.api.command.CommandSource; + +public final class VelocityViewerFactory implements ViewerFactory<CommandSource> { + + @Override + public Viewer of(CommandSource source) { + return new VelocityViewer(source); + } +} diff --git a/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewerRegistry.java b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewerRegistry.java new file mode 100644 index 0000000..471778a --- /dev/null +++ b/automessage-velocity/src/main/java/com/github/imdmk/automessage/velocity/VelocityViewerRegistry.java @@ -0,0 +1,37 @@ +package com.github.imdmk.automessage.velocity; + +import com.github.imdmk.automessage.platform.viewer.Viewer; +import com.github.imdmk.automessage.platform.viewer.ViewerRegistry; +import com.velocitypowered.api.proxy.ProxyServer; +import org.jetbrains.annotations.Unmodifiable; + +import java.util.Collection; + +public final class VelocityViewerRegistry implements ViewerRegistry { + + private final ProxyServer proxy; + + public VelocityViewerRegistry(ProxyServer proxy) { + this.proxy = proxy; + } + + @Override + @Unmodifiable + public Collection<Viewer> online() { + // Copied rather than wrapped: the dispatcher walks this off the netty threads that add and + // remove players, and Velocity's own collection is a live view. + return proxy.getAllPlayers().stream() + .map(player -> (Viewer) new VelocityViewer(player)) + .toList(); + } + + @Override + public int onlineCount() { + return proxy.getPlayerCount(); + } + + @Override + public int maxPlayers() { + return proxy.getConfiguration().getShowMaxPlayers(); + } +} diff --git a/build.gradle.kts b/build.gradle.kts index d71a919..aa3dbaa 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,5 +1,5 @@ group = "com.github.imdmk.automessage" -version = "2.0.1" +version = "3.0.0" subprojects { version = rootProject.version @@ -12,6 +12,16 @@ subprojects { maven("https://storehouse.okaeri.eu/repository/maven-public/") // Okaeri maven("https://repo.panda-lang.org/releases") // Litecommands maven("https://repo.extendedclip.com/content/repositories/placeholderapi/") // PlaceholderAPI + maven("https://repo.spongepowered.org/repository/maven-public/") // Sponge + maven("https://repo.papermc.io/repository/maven-public/") { + // Folia publishes Gradle Module Metadata and no POM, which Gradle does not look for + // unless told to - without this the dependency simply fails to resolve. + metadataSources { + gradleMetadata() + mavenPom() + artifact() + } + } } extensions.configure<JavaPluginExtension> { @@ -37,3 +47,54 @@ subprojects { options.release.set(Versions.JAVA_RELEASE) } } + +// Every jar a server administrator would install, in one place. +// +// Collected through a configuration each platform module offers rather than by naming tasks from +// here: the shipped file is shadowJar's output on most platforms and remapJar's on Fabric, and a +// module knows which of those it is far better than the root does. +val platformJars: Configuration by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +subprojects { + // Fires when the module applies the convention, so adding a platform means adding it to + // settings.gradle.kts and nowhere else. + plugins.withId("automessage-platform") { + val modulePath = path + + rootProject.dependencies.add( + platformJars.name, + rootProject.dependencies.project(modulePath, "platformJar") + ) + } +} + +val dist by tasks.registering(Sync::class) { + group = "distribution" + description = "Runs every check and collects the jar for each platform into build/dist." + + // Sync rather than Copy: a jar left over from an older version would otherwise sit in the + // folder looking exactly as installable as the current one. + from(platformJars) + into(layout.buildDirectory.dir("dist")) + + // And always run, because up-to-date is the wrong answer for this one. Gradle skipped it for + // a folder that had a stale jar dropped into it, which is precisely the state the sync exists + // to correct. Everything expensive - compiling, testing - is upstream of here and still + // skips; this is a copy of six files. + outputs.upToDateWhen { false } + + dependsOn(subprojects.map { "${it.path}:check" }) + + doLast { + val target = destinationDir + logger.lifecycle("") + logger.lifecycle("Platform jars in ${target.relativeTo(rootDir)}:") + + target.listFiles() + ?.sortedBy { it.name } + ?.forEach { logger.lifecycle(" %-46s %,d KB".format(it.name, it.length() / 1024)) } + } +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 8fae809..e2e896d 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -8,6 +8,13 @@ repositories { dependencies { implementation("org.ow2.asm:asm:9.10.1") + + // Used by the automessage-platform convention, which every platform module applies. + implementation("com.gradleup.shadow:shadow-gradle-plugin:9.6.1") + + // Used by the automessage-bukkit-plugin convention, which writes plugin.yml for Bukkit + // and Folia. + implementation("net.minecrell.plugin-yml.bukkit:net.minecrell.plugin-yml.bukkit.gradle.plugin:0.6.0") } sourceSets { diff --git a/buildSrc/src/main/kotlin/CheckLinkageTask.kt b/buildSrc/src/main/kotlin/CheckLinkageTask.kt new file mode 100644 index 0000000..11af06a --- /dev/null +++ b/buildSrc/src/main/kotlin/CheckLinkageTask.kt @@ -0,0 +1,283 @@ +import org.gradle.api.DefaultTask +import org.gradle.api.GradleException +import org.gradle.api.file.ConfigurableFileCollection +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.provider.SetProperty +import org.gradle.api.tasks.Classpath +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFiles +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.TaskAction +import org.objectweb.asm.ClassReader +import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Opcodes +import org.objectweb.asm.Type +import java.io.File +import java.util.zip.ZipFile + +/** + * Verifies that compiled code links against a library it was not compiled against. + * + * This build has two places where that is deliberately the case, and it is not an oversight in + * either: LiteCommands publishes no Minestom binding for the framework version the rest of the + * plugin uses, and Adventure for Fabric trails the version this plugin pins because the release + * that caught up is for a Minecraft the Fabric command binding cannot follow. Both are the least + * bad option available, which is a different thing from being safe. + * + * So the build measures it. Every method and field reference the subject bytecode emits into the + * named package is resolved against the classes that will actually be on the classpath, walking + * supertypes as the JVM would. A member that has been dropped fails the build here rather than + * throwing NoSuchMethodError at the first message somebody sends. + */ +abstract class CheckLinkageTask : DefaultTask() { + + /** Jars or class directories whose bytecode is examined. */ + @get:InputFiles + @get:Classpath + abstract val subjects: ConfigurableFileCollection + + /** What those classes will find on the classpath at runtime. */ + @get:InputFiles + @get:Classpath + abstract val provided: ConfigurableFileCollection + + /** Internal-name prefix of the references to check, e.g. `net/kyori/adventure/`. */ + @get:Input + abstract val packagePrefix: Property<String> + + /** + * Prefixes inside that one to leave alone. + * + * For the parts of a library whose own version is not the question. Adventure's platform + * bridges are versioned with the platform rather than with Adventure, and are published + * against a different set of mappings again, so their signatures say nothing about whether + * the Adventure line underneath is the right one. + */ + @get:Input + abstract val ignoredPrefixes: SetProperty<String> + + /** Named in the report and in the failure, e.g. "Adventure 4.25.0". */ + @get:Input + abstract val providedDescription: Property<String> + + /** Printed when the check fails, to say what the reader should do about it. */ + @get:Input + abstract val advice: Property<String> + + @get:OutputFile + abstract val report: RegularFileProperty + + private data class MemberReference( + val owner: String, + val name: String, + val descriptor: String, + val field: Boolean + ) + + private data class ApiClass( + val superName: String?, + val interfaces: List<String>, + val methods: Set<String>, + val fields: Set<String> + ) + + @TaskAction + fun check() { + + val prefix = packagePrefix.get() + + val ownClasses = readClasses(subjects.files) + val api = readClasses(provided.files) + ownClasses + + val ignored = ignoredPrefixes.get() + val references = readReferences(prefix) + .filterNot { reference -> ignored.any { reference.owner.startsWith(it) } } + .toSet() + val borrowed = references.map { it.owner }.filterNot { it in ownClasses }.toSortedSet() + + val problems = mutableListOf<String>() + + for (reference in references.sortedBy { "${it.owner}#${it.name}${it.descriptor}" }) { + + // Classes that travel with the subject answer for themselves. + if (reference.owner in ownClasses) { + continue + } + + if (!api.containsKey(reference.owner)) { + problems += "unknown type ${reference.owner}" + continue + } + + if (!hasMember(api, reference.owner, reference)) { + problems += "unknown ${if (reference.field) "field" else "method"} " + + "${reference.owner}#${reference.name}${if (reference.field) "" else reference.descriptor}" + } + } + + val distinct = problems.distinct() + + val file = report.get().asFile + file.parentFile.mkdirs() + file.writeText( + buildString { + appendLine("resolved against ${providedDescription.get()}") + appendLine("types borrowed: ${borrowed.size}, members referenced: ${references.size}") + appendLine() + if (distinct.isEmpty()) appendLine("OK") else distinct.forEach { appendLine(it) } + } + ) + + if (distinct.isNotEmpty()) { + throw GradleException( + distinct.joinToString( + prefix = "Does not link against ${providedDescription.get()}:\n - ", + separator = "\n - ", + postfix = "\n\n${advice.get()}" + ) + ) + } + } + + private fun readClasses(files: Set<File>): Map<String, ApiClass> { + + val classes = mutableMapOf<String, ApiClass>() + + forEachClass(files) { bytes -> + val reader = ClassReader(bytes) + val methods = mutableSetOf<String>() + val fields = mutableSetOf<String>() + + reader.accept(object : ClassVisitor(Opcodes.ASM9) { + + override fun visitMethod( + access: Int, name: String, descriptor: String, + signature: String?, exceptions: Array<out String>? + ): MethodVisitor? { + methods += "$name$descriptor" + return null + } + + override fun visitField( + access: Int, name: String, descriptor: String, + signature: String?, value: Any? + ): FieldVisitor? { + fields += name + return null + } + }, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + + classes[reader.className] = ApiClass( + superName = reader.superName, + interfaces = reader.interfaces.toList(), + methods = methods, + fields = fields + ) + } + + return classes + } + + private fun readReferences(prefix: String): Set<MemberReference> { + + val references = mutableSetOf<MemberReference>() + + forEachClass(subjects.files) { bytes -> + ClassReader(bytes).accept(object : ClassVisitor(Opcodes.ASM9) { + + override fun visitMethod( + access: Int, name: String, descriptor: String, + signature: String?, exceptions: Array<out String>? + ): MethodVisitor = object : MethodVisitor(Opcodes.ASM9) { + + override fun visitFieldInsn( + opcode: Int, owner: String, name: String, descriptor: String + ) { + if (owner.startsWith(prefix)) { + references += MemberReference(owner, name, descriptor, field = true) + } + } + + override fun visitMethodInsn( + opcode: Int, owner: String, name: String, + descriptor: String, isInterface: Boolean + ) { + if (owner.startsWith(prefix)) { + references += MemberReference(owner, name, descriptor, field = false) + } + } + } + }, ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } + + return references + } + + private fun forEachClass(files: Set<File>, action: (ByteArray) -> Unit) { + + files.forEach { file -> + when { + file.isDirectory -> + file.walkTopDown() + .filter { it.isFile && it.name.endsWith(".class") } + .forEach { action(it.readBytes()) } + + file.isFile && file.name.endsWith(".jar") -> + ZipFile(file).use { zip -> + zip.entries().asSequence() + .filter { it.name.endsWith(".class") } + .forEach { entry -> action(zip.getInputStream(entry).use { it.readBytes() }) } + } + } + } + } + + private fun hasMember( + api: Map<String, ApiClass>, + owner: String, + reference: MemberReference + ): Boolean { + + val current = api[owner] ?: return hasJdkMember(owner, reference) + + if (reference.field && reference.name in current.fields) return true + if (!reference.field && "${reference.name}${reference.descriptor}" in current.methods) return true + + // Constructors are never inherited; anything else may come from a supertype. + if (reference.name == "<init>") return false + + val parents = buildList { + current.superName?.let { add(it) } + addAll(current.interfaces) + } + + return parents.any { hasMember(api, it, reference) } + } + + /** + * Library types inherit from the JDK - `java.lang.Object`, and `java.lang.Enum` for every enum + * constant a configuration names. Those supertypes are not in the jars being checked, so + * resolve them against the running JVM rather than reporting them missing. + */ + private fun hasJdkMember(owner: String, reference: MemberReference): Boolean { + + if (!owner.startsWith("java/")) return false + + val type = runCatching { Class.forName(owner.replace('/', '.')) }.getOrNull() ?: return false + + if (reference.field) { + return generateSequence(type) { it.superclass } + .any { candidate -> candidate.declaredFields.any { it.name == reference.name } } + } + + return generateSequence(type) { it.superclass } + .any { candidate -> + candidate.declaredMethods.any { + it.name == reference.name && Type.getMethodDescriptor(it) == reference.descriptor + } + } + } +} diff --git a/buildSrc/src/main/kotlin/Versions.kt b/buildSrc/src/main/kotlin/Versions.kt index 2c7a87d..13d553d 100644 --- a/buildSrc/src/main/kotlin/Versions.kt +++ b/buildSrc/src/main/kotlin/Versions.kt @@ -1,7 +1,7 @@ object Versions { /** JDK the project is built with. */ - const val JAVA_TOOLCHAIN = 21 + const val JAVA_TOOLCHAIN = 25 /** * Bytecode level of the shipped plugin, and therefore the oldest JVM a server may @@ -13,6 +13,96 @@ object Versions { /** Spigot API the plugin is compiled against (newest supported server). */ const val SPIGOT_API = "26.2-R0.1-SNAPSHOT" + /** + * Folia's fork of the Bukkit API, which adds the regionised schedulers. + * + * Deliberately not the newest. Folia 26.2 requires Java 25, and building against it would + * force this module's bytecode above the level Folia servers on 1.21 can load. The scheduler + * interfaces have not changed since, so the older API produces a jar that runs on both. + */ + /** Folia build the runFolia task downloads; Folia publishes releases per Minecraft line. */ + const val FOLIA_RUN_MINECRAFT = "26.2" + + const val FOLIA_API = "1.21.11-R0.1-SNAPSHOT" + + /** + * The Velocity proxy API. + * + * Deliberately the 3.x line rather than 4.x: it builds against Adventure 4.26.1, the exact + * version this plugin pins, so the proxy and the plugin agree on what a Component is. Velocity + * 4 moves to Adventure 5, which is the incompatibility the `checkAdventureAlignment` task + * exists to catch. + */ + const val VELOCITY_API = "3.5.1" + + /** + * Minestom. + * + * Deliberately not the newest, for the same reason as Velocity: this is the last release built + * against Adventure 4.26.1. Everything after it moved to Adventure 5, which the whole project + * cannot follow while adventure-platform-bukkit still links against types 5.x removed. + */ + const val MINESTOM = "2026.05.11-1.21.11" + + /** Minecraft this Minestom build speaks; it targets exactly one, not a range. */ + const val MINESTOM_MINECRAFT = "1.21.11" + + /** + * Bytecode level of the Minestom jar, and the one case where raising it is right. + * + * Minestom itself is compiled to Java 25 - every release in the Adventure 4.x window is, so + * there is no older build to retreat to and no Minestom server running on anything less. The + * mirror image of Folia, where raising the level would have locked the plugin out of servers + * that exist; here it locks out nobody, and leaving it at 21 would mean Gradle refusing to + * resolve Minestom at all. + */ + const val MINESTOM_JAVA_RELEASE = 25 + + /** + * Minecraft the Fabric mod is built for. + * + * The last version Fabric publishes intermediary and Yarn mappings for. Everything after it + * moved to a mapping scheme LiteCommands' Fabric binding does not speak - it is compiled + * against intermediary names (`net.minecraft.class_2168`), which 26.x no longer has. + */ + const val FABRIC_MINECRAFT = "1.21.11" + + const val FABRIC_YARN = "1.21.11+build.6" + const val FABRIC_LOADER = "0.19.5" + const val FABRIC_API = "0.141.6+1.21.11" + + /** + * Adventure for Fabric, which the mod loader provides rather than the plugin shading. + * + * Deliberately 6.8.0 rather than 6.9.0. The newer one is built against Adventure 4.26.1 - + * the version this plugin pins - but for Minecraft 26.1.2, which is past where the Fabric + * command binding can follow. So this one trails at Adventure 4.25.0, and + * `checkFabricAdventureLinkage` is what makes that a measured choice rather than a hope: + * it resolves every Adventure member the plugin calls against the 4.25.0 the mod will + * actually load. + */ + const val ADVENTURE_PLATFORM_FABRIC = "6.8.0" + + /** + * Adventure the Fabric mod will actually load, bundled inside adventure-platform-fabric. + * + * One minor line behind what the rest of the plugin compiles against, which is the price of + * the pin above. `checkAdventureLinkage` in the Fabric module resolves everything the shipped + * bytecode calls against exactly these jars. + */ + const val ADVENTURE_FABRIC = "4.25.0" + + /** + * The Sponge API. + * + * The newest stable line, and one that builds against Adventure 4.26.1 - the same version this + * plugin pins, so a Component built here is one Sponge can render. + */ + const val SPONGE_API = "17.0.0" + + /** Minecraft that SpongeAPI 17 is the API for; SpongeVanilla publishes the pairing. */ + const val SPONGE_MINECRAFT = "1.21.10" + /** Oldest supported Spigot API, verified by the `compileJavaSpigotMin` task. */ const val SPIGOT_API_MIN = "1.21-R0.1-SNAPSHOT" @@ -46,8 +136,19 @@ object Versions { const val PLACEHOLDER_API = "2.12.3" const val BSTATS_BUKKIT = "3.2.1" + + const val BSTATS_VELOCITY = "3.2.1" + + const val BSTATS_SPONGE = "3.2.1" const val LITECOMMANDS = "3.11.0" + /** Last version with a Minestom binding; the Minestom module pins the whole stack to it. */ + const val LITECOMMANDS_MINESTOM = "3.10.9" + + + /** Provided by Velocity, Minestom and Fabric alike; never shaded. */ + const val SLF4J = "2.0.17" + const val JUNIT = "6.1.3" const val ASSERTJ = "3.27.7" const val MOCKITO = "5.23.0" diff --git a/buildSrc/src/main/kotlin/automessage-adventure-compat.gradle.kts b/buildSrc/src/main/kotlin/automessage-adventure-compat.gradle.kts index 498577d..cf24ac4 100644 --- a/buildSrc/src/main/kotlin/automessage-adventure-compat.gradle.kts +++ b/buildSrc/src/main/kotlin/automessage-adventure-compat.gradle.kts @@ -2,15 +2,13 @@ plugins { `java-library` } -/** - * `adventure-platform-bukkit` is still published against the Adventure 4.x API and links - * against types 5.x removed — `net.kyori.adventure.audience.MessageType` among them. Nothing - * in the build fails when a 5.x module is pulled in: the jar shades cleanly and only dies on - * the first message sent, with a NoClassDefFoundError. - * - * A bump of any single Adventure module drags the rest of the line along with it, so this - * check asserts the whole shaded stack stays on the major line the platform expects. - */ +// `adventure-platform-bukkit` is still published against the Adventure 4.x API and links +// against types 5.x removed — `net.kyori.adventure.audience.MessageType` among them. Nothing +// in the build fails when a 5.x module is pulled in: the jar shades cleanly and only dies on +// the first message sent, with a NoClassDefFoundError. +// +// A bump of any single Adventure module drags the rest of the line along with it, so this +// check asserts the whole shaded stack stays on the major line the platform expects. abstract class CheckAdventureAlignmentTask : DefaultTask() { @get:Input diff --git a/buildSrc/src/main/kotlin/automessage-bukkit-plugin.gradle.kts b/buildSrc/src/main/kotlin/automessage-bukkit-plugin.gradle.kts new file mode 100644 index 0000000..e31019a --- /dev/null +++ b/buildSrc/src/main/kotlin/automessage-bukkit-plugin.gradle.kts @@ -0,0 +1,20 @@ +import net.minecrell.pluginyml.bukkit.BukkitPluginDescription + +plugins { + `java-library` + id("net.minecrell.plugin-yml.bukkit") +} + +// The plugin.yml fields Bukkit and Folia share. +// +// Only the entry point and the Folia flag actually differ between the two, and everything else was +// written out twice - which is how one jar ends up describing itself differently from the other. +extensions.configure<BukkitPluginDescription> { + name = "AutoMessage" + version = project.version.toString() + apiVersion = Versions.SPIGOT_API_VERSION + softDepend = listOf("PlaceholderAPI") + author = "imDMK (dominiks8318@gmail.com)" + description = "High-performance plugin for fully customizable automatic server-wide broadcasts." + website = "https://github.com/imDMK/AutoMessage" +} diff --git a/buildSrc/src/main/kotlin/automessage-platform.gradle.kts b/buildSrc/src/main/kotlin/automessage-platform.gradle.kts new file mode 100644 index 0000000..0c46d2c --- /dev/null +++ b/buildSrc/src/main/kotlin/automessage-platform.gradle.kts @@ -0,0 +1,156 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar + +plugins { + `java-library` + id("com.gradleup.shadow") +} + +// Shared by every platform module. +// +// Each platform ships its own jar - a Bukkit server has no use for the Velocity build and cannot +// load it - so the shadow setup lives here rather than being repeated per module with room to +// drift. +open class PlatformExtension(private val project: Project) { + + // Shown in the artifact name, e.g. "Bukkit" or "Velocity". + var platformName: String = "" + + // Server versions the jar is built for, shown in the artifact name. + var supportedVersions: String = Versions.SUPPORTED_MINECRAFT_RANGE + + // Whether the shadowed jar is the artifact, or a step towards it. + // + // Fabric has one more stage after shading - Loom remaps the mod back into the namespace the + // loader expects - so there the shipped file is remapJar's output, and shadowJar's is an + // intermediate that should not be mistaken for it in `build/libs`. + var shadowIsIntermediate: Boolean = false + + /** + * Port the module's runServer listens on, or zero where the platform is configured elsewhere. + * + * One per platform, so several can run at once and none of them lands on 25565 - which on a + * developer's own machine is usually already taken by the server they are actually playing on. + */ + var runPort: Int = 0 + + // Bundled third-party packages, moved out of the way so two plugins shading different + // versions of the same library cannot collide inside one server. + val relocated: MutableList<String> = mutableListOf( + "dev.rollczi.litecommands", + "eu.okaeri.configs", + "net.kyori", + "org.bstats", + "org.yaml.snakeyaml", + ) + + internal val provided: MutableList<String> = mutableListOf() + + // Libraries the platform brings itself: neither shaded nor relocated. + // + // Adventure is the one that matters. Where a platform speaks it natively, a shaded copy under + // another package is not the one it renders, so every Component the plugin builds becomes a + // silent no-op - which is why this is stated per module rather than left to a list somebody + // has to remember to prune. + fun providedByPlatform(vararg groups: String) { + provided += groups + relocated.removeIf { pkg -> groups.any { pkg == it || pkg.startsWith("$it.") } } + } + + // The name the shipped jar carries, wherever it is finally produced. + fun artifactFileName(): String = + "AutoMessage-$platformName v${project.version} ($supportedVersions).jar" + + internal var shadowAction: Action<ShadowJar>? = null + + fun shadowJar(action: Action<ShadowJar>) { + shadowAction = action + } +} + +val platform = extensions.create("automessagePlatform", PlatformExtension::class.java, project) + +// What this module ships, offered to whoever asks for it - which is the root project's `dist`. +// Named rather than guessed at, because the shipped file is not the same task everywhere: Fabric +// remaps its shaded jar afterwards, so shadowJar's output there is a step, not the product. +val platformJar: Configuration by configurations.creating { + isCanBeConsumed = true + isCanBeResolved = false +} + +tasks.named("build") { + dependsOn(tasks.named("shadowJar")) +} + +// Minecraft rewrites server.properties on every start, filling in every default it knows - which +// silently puts the port back to 25565. Re-applying the two settings that matter before each run +// is what keeps the task usable a second time, and it leaves every other line the developer or the +// server wrote alone. +afterEvaluate { + if (platform.runPort != 0 && tasks.names.contains("runServer")) { + tasks.named("runServer") { + doFirst { + val runDirectory = layout.projectDirectory.dir("run").asFile + runDirectory.mkdirs() + + val properties = runDirectory.resolve("server.properties") + val settings = mapOf( + "server-port" to platform.runPort.toString(), + "online-mode" to "false", + ) + + val kept = properties.takeIf { it.isFile } + ?.readLines() + ?.filterNot { line -> settings.keys.any { line.startsWith("$it=") } } + ?: emptyList() + + properties.writeText((kept + settings.map { (k, v) -> "$k=$v" }).joinToString("\n") + "\n") + } + } + } +} + +afterEvaluate { + val shipped = tasks.named(if (platform.shadowIsIntermediate) "remapJar" else "shadowJar") + + artifacts.add(platformJar.name, shipped.map { (it as AbstractArchiveTask).archiveFile.get() }) { + builtBy(shipped) + } + + tasks.withType<ShadowJar>().configureEach { + archiveFileName.set( + if (platform.shadowIsIntermediate) "${project.name}-shaded.jar" else platform.artifactFileName() + ) + + // An intermediate is kept out of build/libs, where everything is something to ship. + if (platform.shadowIsIntermediate) { + destinationDirectory.set(layout.buildDirectory.dir("shaded")) + } + + // Shadow's transformers only see entries that reach them; with the Jar default of EXCLUDE a + // second provider file of the same name would be dropped before mergeServiceFiles() runs. + duplicatesStrategy = DuplicatesStrategy.INCLUDE + + mergeServiceFiles() + + exclude( + "META-INF/*.SF", + "META-INF/*.DSA", + "META-INF/*.RSA", + "module-info.class", + "org/intellij/lang/annotations/**", + "org/jetbrains/annotations/**" + ) + + // No minimize(): Adventure reaches its serializers and its ServiceLoader providers + // reflectively, so the reachability analysis drops classes the plugin needs. + + val relocationPrefix = "com.github.imdmk.automessage.lib" + platform.relocated.forEach { pkg -> relocate(pkg, "$relocationPrefix.$pkg") } + + dependencies { + platform.provided.forEach { group -> exclude(dependency("$group:.*")) } + } + + platform.shadowAction?.execute(this) + } +} diff --git a/buildSrc/src/main/kotlin/automessage-spigot-compat.gradle.kts b/buildSrc/src/main/kotlin/automessage-spigot-compat.gradle.kts index 06e9145..78b689a 100644 --- a/buildSrc/src/main/kotlin/automessage-spigot-compat.gradle.kts +++ b/buildSrc/src/main/kotlin/automessage-spigot-compat.gradle.kts @@ -10,17 +10,15 @@ plugins { `java-library` } -/** - * Keeps the plugin usable on the whole advertised server range. - * - * The shipped artifact is built against the newest Spigot API, so two things can - * silently break on the oldest supported server: sources may reference API that did - * not exist yet, and call sites may be emitted against a type whose shape changed - * (`org.bukkit.Sound`, for instance, was an enum in 1.21 and is an interface today). - * - * `compileJavaSpigotMin` catches the first case, `checkSpigotBinaryCompatibility` the - * second. Both run as part of `check`. - */ +// Keeps the plugin usable on the whole advertised server range. +// +// The shipped artifact is built against the newest Spigot API, so two things can +// silently break on the oldest supported server: sources may reference API that did +// not exist yet, and call sites may be emitted against a type whose shape changed +// (`org.bukkit.Sound`, for instance, was an enum in 1.21 and is an interface today). +// +// `compileJavaSpigotMin` catches the first case, `checkSpigotBinaryCompatibility` the +// second. Both run as part of `check`. val spigotMinApi: Configuration = configurations.create("spigotMinApi") { isCanBeConsumed = false isCanBeResolved = true @@ -30,7 +28,7 @@ dependencies { spigotMinApi("org.spigotmc:spigot-api:${Versions.SPIGOT_API_MIN}") } -/** A single method/field reference emitted into one of our own classes. */ +// A single method/field reference emitted into one of our own classes. data class MemberReference( val owner: String, val name: String, @@ -39,7 +37,7 @@ data class MemberReference( val viaInterface: Boolean ) -/** Everything relevant we know about one class of the reference API. */ +// Everything relevant we know about one class of the reference API. data class ApiClass( val name: String, val isInterface: Boolean, @@ -269,11 +267,9 @@ abstract class CheckSpigotBinaryCompatibilityTask : DefaultTask() { return parents.any { hasMember(api, it, reference) } } - /** - * Bukkit types inherit from the JDK — `java.lang.Object` and, for the enums the - * older API still used, `java.lang.Enum`. Those supertypes are not in the Spigot - * jar, so resolve them against the running JVM instead of reporting them missing. - */ + // Bukkit types inherit from the JDK — `java.lang.Object` and, for the enums the + // older API still used, `java.lang.Enum`. Those supertypes are not in the Spigot + // jar, so resolve them against the running JVM instead of reporting them missing. private fun hasJdkMember(owner: String, reference: MemberReference): Boolean { if (!owner.startsWith("java/")) return false diff --git a/buildSrc/src/main/kotlin/automessage-testing.gradle.kts b/buildSrc/src/main/kotlin/automessage-testing.gradle.kts new file mode 100644 index 0000000..88f3c07 --- /dev/null +++ b/buildSrc/src/main/kotlin/automessage-testing.gradle.kts @@ -0,0 +1,15 @@ +plugins { + `java-library` +} + +// Applied by every module that has tests, so the stack is one decision instead of seven. +// +// Modules used to declare these themselves and had already drifted: one had the Mockito JUnit +// extension and another did not, and two declared the whole set while containing no tests at all. +dependencies { + "testImplementation"("org.junit.jupiter:junit-jupiter:${Versions.JUNIT}") + "testImplementation"("org.assertj:assertj-core:${Versions.ASSERTJ}") + "testImplementation"("org.mockito:mockito-core:${Versions.MOCKITO}") + "testImplementation"("org.mockito:mockito-junit-jupiter:${Versions.MOCKITO}") + "testRuntimeOnly"("org.junit.platform:junit-platform-launcher") +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3eb6330 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +# Fabric Loom decompiles and remaps the whole of Minecraft during configuration, and does it in +# the Gradle process. Gradle's default heap is not enough for that: the build dies with +# "Java heap space" while evaluating the Fabric module, and thrashes the garbage collector for a +# long while before it does. Nothing else in this build needs the room. +org.gradle.jvmargs=-Xmx4G diff --git a/settings.gradle.kts b/settings.gradle.kts index bc2c9cd..c4e236e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,37 @@ +pluginManagement { + repositories { + gradlePluginPortal() + + // SpongeGradle, which generates the Sponge plugin metadata, is published here only. + maven("https://repo.spongepowered.org/repository/maven-public/") + + // Fabric Loom, which is what turns a jar into a mod Minecraft can load. + maven("https://maven.fabricmc.net/") + } + + // Declared here so a module names a plugin without also naming a version - the same reason + // library versions live in Versions.kt rather than in whichever module happened to need one + // first. Shadow and plugin-yml are not here: they are applied by convention plugins, so their + // versions live on buildSrc's classpath instead. + plugins { + id("xyz.jpenilla.run-paper") version "3.1.0" + id("xyz.jpenilla.run-velocity") version "3.1.0" + id("org.spongepowered.gradle.plugin") version "2.3.0" + id("fabric-loom") version "1.17.20" + } +} + rootProject.name = "AutoMessage" + +include("automessage-api") +include("automessage-notice") include("automessage-core") -include("automessage-loader") \ No newline at end of file +include("automessage-slf4j") + +include("automessage-bukkit-common") +include("automessage-bukkit") +include("automessage-folia") +include("automessage-sponge") +include("automessage-velocity") +include("automessage-minestom") +include("automessage-fabric")