feat: run AutoMessage on six platforms, one core behind all of them - #119
Merged
Conversation
added 16 commits
September 1, 2026 10:45
AutoMessage was a Bukkit plugin whose core spoke Bukkit types. It now runs on Bukkit, Folia, Sponge, Velocity, Fabric and Minestom from one platform-neutral core, and each platform ships its own jar because a Bukkit server cannot load the Velocity build. The core no longer names a server. A Viewer stands in for a player, a ViewerRegistry for who is online, a TaskScheduler for the platform's threading and a PluginLogger for its log. Commands take a Viewer rather than a sender, so they are written once and every platform contributes only its own binding. A platform declares what it can do, and the configuration it writes carries only that. A proxy has no worlds, no playtime and no first join; a server embedded from a library has no permission system beyond an operator level. Options, shipped examples and the documentation blocks around them are all filtered by capability, so a file never offers an instruction the runtime cannot follow - 119 lines on Bukkit, 99 on a proxy, 83 on Minestom. Version choices were forced rather than picked, and each is written down where somebody would otherwise undo it. Velocity stays on 3.x and Minestom on the May build because those are the last releases on the Adventure line adventure-platform-bukkit still needs. Fabric targets 1.21.11, the last version Fabric publishes intermediary mappings for and therefore the last the LiteCommands binding can follow, and runs on Adventure 4.25 - which checkAdventureLinkage resolves the shipped bytecode against, member by member. Minestom is the one module built for Java 25, because every Minestom release in that window is, and no Minestom server runs below it. Running the thing found what tests could not. Fabric registered no commands at all: LiteCommands hooks Fabric's registration callback when it is built, and that callback had already fired by SERVER_STARTED. Sponge threw on every startup and then could not register commands either, because the only moment it accepts them has no engine while the upstream binding needs one - so the binding is assembled here from public API with a scheduler that looks up the engine when a command runs. Folia was scheduling command work on a scheduler it does not have. An audit before that found a reload that ignored new language files while the file itself told administrators otherwise, a shutdown that leaked threads when a config could not be written, and a broken placeholder expansion that could take down a whole broadcast. 227 tests, twelve of which drive the whole plugin end to end against a server made of test doubles - the composition root had never been constructed in a test at all. Every platform has been started for real except Minestom, whose runtime starts inside a test on every build. ./gradlew dist runs every check and collects the six installable jars into build/dist. Not verified: no real client has ever connected, so no player has received a message. The chain is proven in two halves - the logic in process, the platform bindings on live servers - and never once end to end with a real client.
The README read flat, and the cause was measurable rather than a matter of taste. Of ten images in it, eight were badges; there were two real visuals in 517 lines, the last of them at line 143, leaving 375 consecutive lines - 72% of the page - with nothing but headings, prose and 74 rows of table. Every code block showed input. None showed output. A document about how announcements look never showed one. Two drawings fix that. The first renders the example announcement as a player receives it: the boss bar counting down, the gradient title and subtitle, two chat lines with their click target, and the action bar above the hotbar - the same four surfaces the YAML above it configures. The second replaces a wall of prose about the architecture with the architecture: one core, six artifacts, each labelled with the Minecraft line it targets. Both are SVG in the palette the hero already uses, and both were drawn against a rendered preview rather than by guessing - the platform strip overflowed its canvas on the first attempt and the sixth runtime was cut off. Emoji went on the section headings, which is what was asked for and does help scanning. It also breaks every anchor GitHub generates, because a stripped emoji leaves a leading dash in the slug, so the five headings the navigation links to now carry explicit anchors and the links survive. Real visuals went from two to five, and the longest stretch without one from 375 lines to 274 - and that remainder is the reference section, which is 209 lines of collapsed detail showing as eight rows.
Three more drawings, and they cover the three things this plugin does that a rotator cannot. Targeting is the hardest to believe from prose, so it is shown instead: twelve players online, a rule asking for the rank.vip permission and ten hours of playtime, five of them lit and seven left dark, with each player's playtime printed so the filtering can be checked by eye rather than taken on faith. Triggers are shown as three lanes on one timeline: a channel ticking on its period, a join firing three seconds after somebody connects, and a player-count milestone crossing one hundred, firing, dipping, re-arming and firing again. That last behaviour took two attempts - the first chart was too flat for the crossing to read at all. Platform-aware configuration was one sentence describing the feature nobody else has. It is now the same generated file twice, side by side, with six entries struck through on the proxy and the real line counts this branch produces on live servers: 119 against 99. Both counts in the new at-a-glance strip were checked against the code rather than estimated - eight notice surfaces because seven carry a key and chat needs none, and eight audience rules including the three combinators. Real visuals are now eight, from two when this started. The one remaining stretch without a picture is the reference section, which is collapsed detail rendering as eight rows.
A boss bar with a duration but no progress of its own sat full until it vanished, which reads as a broken timer rather than a countdown. It now steps down to empty as its time runs out. A bar that names a progress was asked for that exact fill, so it keeps it and simply disappears. Redraws are capped at 40 steps, so a ten-minute bar does not repaint once per tick.
Starting a platform meant knowing which name that platform happened to use: runServer here, runFolia or runMinestom there. Every module now answers to runServer and nothing else this project adds. Folia's second task is gone - its runServer starts Folia - and Minestom, which ships no server jar, gets a dev source set with a launcher of its own. Minecraft rewrites server.properties on every start and quietly puts the port back to 25565, so a second run would collide with whatever the developer is already running. Each module now reapplies its own port before starting and leaves every other line alone, so several platforms can run at once.
LiteCommands warned on every Folia start that the Folia extension was not enabled, while the extension was in fact registered a moment later. The warning comes from BukkitScheduler's constructor, and LiteBukkitFactory builds that scheduler before it hands back the builder .extension() is called on - so it is already false when it is printed, and none of the factory's six builder(..) overloads accept a scheduler to be early enough. The record is dropped instead, from the Folia module alone: the Bukkit jar run on Folia installs no extension, so there the warning is true and stays. The filter is scoped to the call that builds the commands and restored afterwards, so it cannot outlive the one message it exists for.
RenderConfigsTest had no assertions at all - it wrote config files into the build directory and could only fail by throwing. ConfigManagerTest injected mocks into private fields by reflection and then verified that each method called its collaborators, which restates the implementation line by line and would pass with the whole thing broken end to end; six remaining classes exercise ConfigManager for real against a temporary folder. The rest were duplicates of a neighbouring test, or the same assertion made twice in one class. One of them ran a billion iterations to reach an overflow threshold and cost 3.5 of the suite's 5.6 seconds on its own. MessageSelectorFactoryTest covered two of four branches and asserted that a null enum throws, which is the JVM's behaviour rather than ours; it is now one parameterized case per selector, so a branch pointing at the neighbouring class fails. 225 tests, down from 234, and the suite runs in 2.2s rather than 5.6s.
…r player
Every recipient got their own MiniMessage parse of the same text, so a full
server parsed one gradient line five hundred times to say one thing. Players
who share a language and are reading identical text are now served from a
single render. Measured on the real send path with 500 players online:
5.16ms per announcement before, 0.34ms after. What remains is handing the
finished component to each player, which cannot be avoided.
A message that reads differently per player - one naming {PLAYER}, or any
PlaceholderAPI token, since only the expansion behind it knows - still takes
the per-player path.
Adventure's own group audience takes showTitle apart into sendTitlePart calls
that an audience implementing only showTitle never sees, which silently
dropped every title as soon as two players were online. AudienceGroup
forwards the title whole, so a group behaves exactly like sending to each in
turn. The end-to-end test has one player and could not have caught this;
GroupedBroadcastTest has two.
Which placeholders a message contains is also no longer rescanned on every
announcement. It is a property of the text, so it is remembered until a
reload, which clears it - a placeholder added by an edit would otherwise
reach players as literal text.
Two questions an administrator could not previously answer without reading the log: what is coming, and what has actually been going out. next asks each channel what it would announce, using the preview the selectors already supported and nobody called - so asking does not use up the message it names, and a SHUFFLE channel keeps its place in the deck. A RANDOM channel says it cannot know rather than naming a message that will not be the one that arrives; a channel that is switched off or has nothing assigned says so. stats counts announcements as they happen, through the observer the Discord mirror already runs on. The counter goes in front of it, because an observer that throws stops the ones behind it and the local count should not depend on the network. Nothing is stored on disk: the counts describe this run. Both are translated into Polish and German alongside the rest.
The biggest thing the plugin does for a busy server was a half sentence in a feature card. It now has a section of its own, with the measurement behind it: 5.16 ms per announcement at 500 players before, 0.34 ms after, roughly a tenth of a tick down to under one percent. The section says what the mechanism does not do as plainly as what it does - text naming a player, or carrying a PlaceholderAPI token, is still built per player - so a reader can trust the number rather than discount it. A diagram in the same visual language as the rest carries the comparison. Every em dash in the file is now a plain hyphen. The en dashes in numeric ranges are left alone; they are ranges, not punctuation.
The first image in the README was a 9 MB, 7.5 second recording whose opening second was a dark screen and whose last 3.4 seconds were one boss bar over an empty sky. Whoever stopped scrolling there saw the weakest frame of it. It is now a still of the three surfaces together, cut from the good frames of that same recording: the boss bar across the top, the title in the middle and the chat line below, laid out the way they actually sit on a player's screen. Same footprint on the page, sharp text, and 60 kB rather than 9 MB - 150 times lighter, and nothing to wait for. The opening prose was three paragraphs, two of them lists of keywords in sentence form. The comparison table directly below already carries every one of those specifics, so the section now makes its point once and hands over. "A complete announcement, start to finish" showed two configuration files and then claimed a finish it never showed. A diagram now says what the server does with them: two players join, the one with 240 hours passes the playtime rule and hears the greeting, the newcomer hears nothing.
A discarded encoding attempt, unreferenced by the README and with a shared palette that had turned the jungle brown. Removed from the tree; the blob stays in history until somebody rewrites it.
…thing The five shortcuts at the top covered five of nineteen places worth landing on, so anything else meant scrolling a six hundred line file. They are replaced by a three column contents: the pitch, the install path, and the reference. Every heading in the file carries an explicit anchor now. GitHub derives heading ids from the text, and an emoji at the front leaves a stray leading dash in the id, so an emoji heading cannot be linked reliably without one. The seven reference blocks are collapsed <details> with no heading at all and had no way to be addressed; each has an anchor of its own, so a link lands on the block rather than at the top of the section. Checked mechanically rather than by eye: 22 anchors, 23 links, no duplicates, no link without a target, and no second level heading left unreachable. The performance section takes the feather the lean-dispatch card already uses, so it no longer shares an emoji with Quick start.
…ares The file declared com.github.imdmk.automessage.platform.placeholder while sitting in the root package directory, the only source file there - every other platform SPI type lives under platform/. Gradle never noticed, because javac compiles the file list it is handed and reads the package from the declaration rather than from the path. IntelliJ did notice, and offered the fix that matches the declaration to the directory, which renamed the package out from under the fifteen files importing it and broke the build. Moved into platform/placeholder/ instead, so the declaration stands and the imports keep resolving. That also puts it beside logger, scheduler, viewer and capability, where a reader would look for it. Swept the other 213 sources: every one declares the package its directory implies, so this was the only file that could have sprung this.
The MULTIPLATFORM ANNOUNCEMENTS pill was 304 wide around a label that measures 312 and starts 42 in, so the last five characters sat outside the rounded border. Measured in a browser rather than guessed: the text ran 50px past the right edge. The pill is 368 now, which leaves the same 13px either side of the mark and the text. The four capability chips had been sized by hand and drifted: a uniform 15px from the left edge to the dot, but 10.6, 20, 17 and 21.3 on the right, so no two were padded alike. Each is now its text plus the same 15px on both sides, with the twelve pixel gaps between them kept. Every label also carries textLength with lengthAdjust="spacing". The file asks for Inter, which GitHub will not load, so the width depended on whichever font the reader happened to have - which is how a badge fits on the machine it was drawn on and overflows everywhere else. Pinned, the boxes hold. Spacing rather than spacingAndGlyphs, so a wider fallback tightens the letter spacing instead of squeezing the letters.
Every heading in the file leads with an emoji and the contents table led with nothing, which is why it read flat next to the rest of the page. The entries take the emoji of the section they point at, so the two agree rather than merely coexist: the megaphone in the list is the megaphone on the heading it scrolls to. Reference entries inherit from the section that explains them, so audience rules keep the target and event triggers the bolt in both columns. The three column titles get their own, the reference one matching the section it names. Checked at the width GitHub actually renders a README at: every entry still sits on one line, and all 23 links still resolve to their anchor. Left the counts line above it alone. Seven figures separated by dots is already dense, and an icon on each would compete with the numbers that are the whole point of it.
imDMK
marked this pull request as draft
September 2, 2026 08:30
added 9 commits
September 2, 2026 10:31
An IDE offered to drop it as redundant. It is not: setPlaceholders has both a Player and an OfflinePlayer form taking a String, and a bare null resolves to the Player one only because Player is the more specific of the two. One more overload upstream and the call is either ambiguous or quietly aimed somewhere else. Restored, with a note so the suggestion is not taken again.
Two notations were doing one job. format() is what gets written into config files and read back by the parser, so it keeps its milliseconds and stays unspaced - and /automessage stats was using it, which is why a line meant for a person said "34s572ms". formatReadable() is the one for reading: whole seconds, and spaced, so "1h 34s" is taken in at a glance where "1h34s" has to be picked apart. Every line a person sees now uses it, including the startup log; every value written to disk still uses the other. Anything left under a second still reads as "1s". A countdown that prints "0s" while there is time left looks broken, and truncating is what turns 34s572ms into 34s rather than 35s. DispatchTiming keeps the configuration notation on purpose: its minimum period is 50ms, and "period must be at least 1s" would be a false statement. The parser now ignores whitespace anywhere rather than only at the ends. A duration is shown spaced, so a value read off the screen has to be typeable back into a file - it was not, which was a papercut this change would otherwise have introduced. /automessage stats answered how often and how long ago and left the obvious third question alone. Dispatches are now counted per channel as well as per message, so each channel reports what it has carried, when its last one went out and when the next is due, with the per-message breakdown beneath it. A channel that has sent nothing says so and still shows its countdown. Confirmed on a running server with hand-written spaced intervals in config.yml: the file parsed, and the countdown read "in 59m 44s".
… list
/automessage stats printed a channel row and a message row that looked
alike, so "default" and "vote-reminder" sat one under the other with nothing
saying which kind of name either was. They are now two labelled lists, and a
message row names the channel it goes out on:
• AutoMessage has sent 12 announcements since startup.
• Channels
• default - 8 sent, last 2m 10s ago, next in 2m 50s
• ads - nothing sent yet, next in 1m
• Messages
• vote-reminder (default) - 4 sent, last 2m 10s ago
Both halves are needed rather than one or the other: when the next one is
due is a fact about a channel, and how often something goes out is a fact
about a message. Either list alone answers half the question. Separating and
labelling them is what removes the ambiguity, and the channel in parentheses
is what makes a message row stand on its own.
/automessage next takes the same shape, so a name is read the same way in
both: "default - vote-reminder, in 1m 36s".
The shipped translations are handed to a record constructor by position,
thirty-one strings long, and this change inserts two fields in the middle of
it. A list that drifts by one still compiles and every message still says
something, just not the right thing - so ShippedLanguagesTest anchors a few
fields spread across the record in both languages, and checks that no
translation dropped a placeholder the command needs. Verified by swapping two
Polish strings, which fails it.
"ads - 0 sent, not counting down" described a missing timer, which is this command's business and not the reader's. It also covered two unrelated situations with one phrase: a channel switched off in config.yml, and a channel nobody assigned a message to. Each now says which: • ads - 0 sent, no messages assigned • events - 0 sent, disabled in config.yml The wording is the same one /automessage next already uses for those states, so the two commands describe a channel the same way instead of each having its own vocabulary. The count stays on the line: what a channel carried before it was switched off is still worth knowing. ChannelPreview already told these apart; the command was throwing that away by keying off a null countdown. It switches on the kind now, exhaustively and without a default, so a new one cannot be added without deciding what it should say.
Channels and Messages were grey, the same colour as the text on the rows beneath them, so the two headings read as list items rather than as headings. Gold and bold in all three shipped languages.
…reement
The rows were longer than Minecraft's chat is wide, so they wrapped and the
wrapped remainder lost its indentation - "next in 4m" then "53s" alone
against the left edge, which reads as broken output rather than one line.
Measured against a 53 character chat: every row in all three languages now
fits, the longest at 50.
Two things had to give. A channel row no longer says when it last fired,
only how much it has carried and when the next one is due; the message rows
underneath still carry the age, and a channel's last announcement is the most
recent of theirs anyway. And a count is written "8x" rather than "8 sent".
That second one was also a grammar bug I had put in. Polish inflects a noun
after a numeral - one announcement is "ogloszenie", two is "ogloszenia",
five is "ogloszen" - so "{COUNT} wyslanych" was wrong for every count, and
the header read "wyslal 1 ogloszen". No placeholder can carry that, so the
numeral no longer sits in front of a noun at all: the header asks in the
genitive ("Ogloszen od uruchomienia: 1"), which holds for any number, and the
rows count with a bare multiplier. German got the same treatment.
ShippedLanguagesTest caught the dropped {AGO} on the first run, which is what
it was written for - the removal is deliberate, so its expectation moved.
The two lists were the same colour. A channel name was gold and a message name yellow, which side by side is one colour, so the sections read as one list again however they were labelled. They now have an identity each and carry it consistently: channels aqua names under a blue gradient heading messages yellow names under an amber gradient heading The plugin's own name wears the gradient from its logo, purple into cyan, so chat and the rest of the project look related. Counts are bold white so the number is what the eye lands on first, times stay green, states that need attention stay red, and the chrome around them - bullets, separators, brackets - is dark grey and stays out of the way. Headings open with a guillemet and rows with a bullet, so the two are told apart by shape as well as by colour and indent. Separators are one character throughout: a middle dot, where a mix of dashes and commas had crept in. Punctuation inside sentences was left alone. All three languages, and every row still measures under a chat line: the longest is 51 characters of the 53 available. Underline was left out on purpose - in Minecraft chat it fights the text rather than framing it.
/automessage toggle, remembered across restarts, on all six platforms.
Storage was the whole question, and the answer is a plain file the plugin
writes itself - data/muted.txt, one id per line. The alternatives lose on
the same point:
in memory only forgotten on restart, so the command promises quiet it
does not keep
Bukkit's container two platforms of six. Sponge holds data its own way,
Fabric would need mixins, Velocity has no world data at
all, and Minestom never writes player data - it would
hold the flag until restart, which is memory again. All
four would need this file anyway, so it is work on top
rather than instead, for two behaviours to keep tested
a permission nobody can grant themselves one
It stays out of the okaeri files deliberately: this is state, not
configuration. A reload must not touch it, nobody edits a list of ids by
hand, and it grows with the server rather than with the settings. Written
beside the real file and moved over it, so a server that dies mid-write keeps
the previous list instead of half a new one, and only written when something
actually changed - a server where nobody asked for quiet grows no file. A
write that fails is marked to be tried again, at the latest on shutdown.
The check sits in front of the rules rather than behind them: it is one set
lookup that turns the whole player away, so a muted veteran costs no playtime
query. One filter serves the rotation and the triggers both, so somebody who
asked for quiet is not greeted on the way in either - which is its own test,
because that is the half a person would forget.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
AutoMessage was a Bukkit plugin whose core spoke Bukkit types. It now runs on Bukkit, Folia, Sponge, Velocity, Fabric and Minestom from one platform-neutral core, and each platform ships its own jar.