Skip to content

feat: run AutoMessage on six platforms, one core behind all of them - #119

Merged
imDMK merged 25 commits into
mainfrom
feat/multiplatform
Sep 2, 2026
Merged

feat: run AutoMessage on six platforms, one core behind all of them#119
imDMK merged 25 commits into
mainfrom
feat/multiplatform

Conversation

@imDMK

@imDMK imDMK commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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.

Dominik Suliga 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
imDMK marked this pull request as draft September 2, 2026 08:30
Dominik Suliga 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.
@imDMK
imDMK marked this pull request as ready for review September 2, 2026 11:22
@imDMK
imDMK merged commit 836ba46 into main Sep 2, 2026
5 checks passed
@imDMK
imDMK deleted the feat/multiplatform branch September 2, 2026 11:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant