<feed xmlns='http://www.w3.org/2005/Atom'>
<title>LunaticChat.git/platform-paper, branch renovate/cloudnode-pro-modrinth-publish-digest</title>
<subtitle>A next-generation chat plugin for Paper, Folia and Velocity.
</subtitle>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/'/>
<entry>
<title>fix(deps): update dependency io.papermc.paper:paper-api to v26.2.build.96-stable (#270)</title>
<updated>2026-08-05T17:56:45+00:00</updated>
<author>
<name>renovate[bot]</name>
<email>29139614+renovate[bot]@users.noreply.github.com</email>
</author>
<published>2026-08-05T17:56:45+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=3fb478b008ab5d777e75a5553ef8260810d4c589'/>
<id>3fb478b008ab5d777e75a5553ef8260810d4c589</id>
<content type='text'>
Co-authored-by: renovate[bot] &lt;29139614+renovate[bot]@users.noreply.github.com&gt;</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Co-authored-by: renovate[bot] &lt;29139614+renovate[bot]@users.noreply.github.com&gt;</pre>
</div>
</content>
</entry>
<entry>
<title>fix(deps): update dependency io.papermc.paper:paper-api to v26.2.build.93-stable (#269)</title>
<updated>2026-08-05T14:05:44+00:00</updated>
<author>
<name>renovate[bot]</name>
<email>29139614+renovate[bot]@users.noreply.github.com</email>
</author>
<published>2026-08-05T14:05:44+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=b2015b10248079396942688802e222e142e9c248'/>
<id>b2015b10248079396942688802e222e142e9c248</id>
<content type='text'>
Co-authored-by: renovate[bot] &lt;29139614+renovate[bot]@users.noreply.github.com&gt;</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Co-authored-by: renovate[bot] &lt;29139614+renovate[bot]@users.noreply.github.com&gt;</pre>
</div>
</content>
</entry>
<entry>
<title>test: cover what the player settings store promises</title>
<updated>2026-08-04T19:26:57+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T19:26:57+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=adbcb0d59dfc96050cac9d578b632b5fab38acbb'/>
<id>adbcb0d59dfc96050cac9d578b632b5fab38acbb</id>
<content type='text'>
It was the one file store without a test, so the round trip it exists for - and
its two fallbacks, an absent file and an unparseable one - were only asserted
through PlayerSettingsManager. The write failure matters most: loading a torn
file discards every player's settings, so a failed save must leave the previous
file untouched.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
It was the one file store without a test, so the round trip it exists for - and
its two fallbacks, an absent file and an unparseable one - were only asserted
through PlayerSettingsManager. The write failure matters most: loading a torn
file discards every player's settings, so a failed save must leave the previous
file untouched.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>fix: stop a slow reply from pinning a word to hiragana</title>
<updated>2026-08-04T18:45:52+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T18:45:52+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=2223cf96de63f57cf076b30e8d758c8fc5899d4e'/>
<id>2223cf96de63f57cf076b30e8d758c8fc5899d4e</id>
<content type='text'>
Making a conversion timeout an ordinary exception put it in the same arm as a
hard API failure, where caching the hiragana fallback is deliberate - so one slow
reply recorded the unconverted form and that word rendered as hiragana for the
life of the cache. A timeout says the request was slow, not that the word has no
conversion, so it now returns the fallback without caching it and the next
message asks again.

The retry test builds its own remembering cache: the shared fixture's get()
always returns null, so against it the API is called every time and the test
would have passed even with the timeout cached.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Making a conversion timeout an ordinary exception put it in the same arm as a
hard API failure, where caching the hiragana fallback is deliberate - so one slow
reply recorded the unconverted form and that word rendered as hiragana for the
life of the cache. A timeout says the request was slow, not that the word has no
conversion, so it now returns the fallback without caching it and the next
message asks again.

The retry test builds its own remembering cache: the shared fixture's get()
always returns null, so against it the API is called every time and the test
would have passed even with the timeout cached.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: drop what no longer carries its weight</title>
<updated>2026-08-04T18:32:33+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T18:32:33+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=e25bfe0e39771ceac287f93a235aa5954a154236'/>
<id>e25bfe0e39771ceac287f93a235aa5954a154236</id>
<content type='text'>
- getDirectMessageSpyPlayers had no callers left; handing out a copy of the spy
  map is the shape notifySpies was introduced to replace.
- pluginScope was widened to public for "commands that must not block the tick
  thread", but commands take the delivery queue and only LunaticChat reads it.
- getPlayerChannels returned a Result that cannot fail, so three callers carried
  unreachable error paths and channel.status.error could never be shown.
- The cross-server managers were gated on velocityIntegration.enabled as well as
  on a manager that is non-null only when it is enabled, letting the two
  conditions disagree.
- LenientBoolean's non-YamlInput fallback was observationally identical to the
  cast failing, since both land in ConfigManager's catch-all.
- sendCrossServerMessage caught its own failures underneath the delivery queue,
  which already reports them without stopping the sender's later messages. The
  second boundary is what forced a CancellationException clause here.
- handleOutgoingCrossServerMessage still recorded the reply target after the
  commands took that over, so remote targets were recorded twice - re-inserting
  entries clearPlayer had swept, which is the bug the local path was fixed for.
- The reason delivery is queued was written out in both command constructors and
  twice more in KDoc; it now lives where the queueing happens.

Spy notification also defers its notice lookup and member set until a spy is
actually online, which is not the normal case.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
- getDirectMessageSpyPlayers had no callers left; handing out a copy of the spy
  map is the shape notifySpies was introduced to replace.
- pluginScope was widened to public for "commands that must not block the tick
  thread", but commands take the delivery queue and only LunaticChat reads it.
- getPlayerChannels returned a Result that cannot fail, so three callers carried
  unreachable error paths and channel.status.error could never be shown.
- The cross-server managers were gated on velocityIntegration.enabled as well as
  on a manager that is non-null only when it is enabled, letting the two
  conditions disagree.
- LenientBoolean's non-YamlInput fallback was observationally identical to the
  cast failing, since both land in ConfigManager's catch-all.
- sendCrossServerMessage caught its own failures underneath the delivery queue,
  which already reports them without stopping the sender's later messages. The
  second boundary is what forced a CancellationException clause here.
- handleOutgoingCrossServerMessage still recorded the reply target after the
  commands took that over, so remote targets were recorded twice - re-inserting
  entries clearPlayer had swept, which is the bug the local path was fixed for.
- The reason delivery is queued was written out in both command constructors and
  twice more in KDoc; it now lives where the queueing happens.

Spy notification also defers its notice lookup and member set until a spy is
actually online, which is not the normal case.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: make durability and teardown properties of the layer, not habits</title>
<updated>2026-08-04T18:32:22+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T18:32:11+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=7ed4bbbcf375c4f136a3b90bb6105c278901f654'/>
<id>7ed4bbbcf375c4f136a3b90bb6105c278901f654</id>
<content type='text'>
Atomicity was opt-in per write site, so a file added later was safe only if its
author noticed the convention. Worse, DebouncedSaver drops a request while one is
pending and so serves exactly one file - a rule held up only by the wiring
happening to construct a separate saver per file, and written down nowhere. A
FileStore now owns its file, its atomic write and its own saver, so neither can
be got wrong by wiring; writeTextAtomically is internal to the package.

Taking the Bukkit plugin out of DebouncedSaver in favour of an AsyncScheduler
makes the debounce testable at all: ChannelStorage's "the snapshot is taken when
the write runs" now runs against the real thing rather than a mocked saver.

Teardown gets the same treatment. The five services with shutdown work spelled it
saveToDisk() three times and shutdown() twice, and the list of them was
hand-maintained against a fourteen-field container - so a new service was not
stopped unless someone remembered a second place. They now implement
StoppableService and register as they are built, and shutdown iterates that list.
stop() delegates rather than renames, because the conversion cache's periodic
flush is a different caller from shutdown.

Also here, on files this commit already touches: player settings carry a dirty
flag, since updateSettings is the only writer and queues its own save, so every
quit was re-serializing every stored player to write identical bytes; and
setPlayerChannel returns early when nothing moved, because the quit path clears
the active channel for every player whether or not they had one, and a mass
disconnect paid a full snapshot per player in one tick.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Atomicity was opt-in per write site, so a file added later was safe only if its
author noticed the convention. Worse, DebouncedSaver drops a request while one is
pending and so serves exactly one file - a rule held up only by the wiring
happening to construct a separate saver per file, and written down nowhere. A
FileStore now owns its file, its atomic write and its own saver, so neither can
be got wrong by wiring; writeTextAtomically is internal to the package.

Taking the Bukkit plugin out of DebouncedSaver in favour of an AsyncScheduler
makes the debounce testable at all: ChannelStorage's "the snapshot is taken when
the write runs" now runs against the real thing rather than a mocked saver.

Teardown gets the same treatment. The five services with shutdown work spelled it
saveToDisk() three times and shutdown() twice, and the list of them was
hand-maintained against a fourteen-field container - so a new service was not
stopped unless someone remembered a second place. They now implement
StoppableService and register as they are built, and shutdown iterates that list.
stop() delegates rather than renames, because the conversion cache's periodic
flush is a different caller from shutdown.

Also here, on files this commit already touches: player settings carry a dirty
flag, since updateSettings is the only writer and queues its own save, so every
quit was re-serializing every stored player to write identical bytes; and
setPlayerChannel returns early when nothing moved, because the quit path clears
the active channel for every player whether or not they had one, and a mass
disconnect paid a full snapshot per player in one tick.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: bound the delivery queue and drop a departed player's backlog</title>
<updated>2026-08-04T18:32:00+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T18:32:00+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=5a20137f7822e7aa3a37716da2e3a450bc6cea96'/>
<id>5a20137f7822e7aa3a37716da2e3a450bc6cea96</id>
<content type='text'>
An item takes up to the conversion timeout to drain, far slower than a player
can send, so an unbounded queue grew for as long as a macro ran - each item
holding its sender and recipient alive and arriving minutes after it was typed.
Refusing the overflow is at least visible to the player.

Releasing a player now cancels their worker instead of letting the backlog run.
Finishing it would spend a round trip per item writing to somebody who has left,
and keep both players reachable until it drained.

Chosen over DROP_LATEST on the channel: that reports success to trySend and
drops silently, which would make the warning about discarded work unreachable in
the case it was written for.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
An item takes up to the conversion timeout to drain, far slower than a player
can send, so an unbounded queue grew for as long as a macro ran - each item
holding its sender and recipient alive and arriving minutes after it was typed.
Refusing the overflow is at least visible to the player.

Releasing a player now cancels their worker instead of letting the backlog run.
Finishing it would spend a round trip per item writing to somebody who has left,
and keep both players reachable until it drained.

Chosen over DROP_LATEST on the channel: that reports success to trySend and
drops silently, which would make the warning about discarded work unreachable in
the case it was written for.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>fix: keep a slow Google IME reply from killing a delivery queue</title>
<updated>2026-08-04T18:31:52+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T18:31:52+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=c939f879f388f07f00e017f59685276aa7a9faef'/>
<id>c939f879f388f07f00e017f59685276aa7a9faef</id>
<content type='text'>
withTimeout reports a timeout as a CancellationException, which callers must
rethrow rather than degrade. With api.timeout below convertWithRomaji's budget
the client's timeout therefore travelled through convertWord, through
withTimeoutOrNull - which rethrows a timeout belonging to another coroutine -
and into the queue worker, which read it as shutdown and ended its loop. The
channel stayed registered with nothing reading it, so every later message from
that player was buffered and never delivered: exactly the failure the worker's
own guard exists to prevent. A timeout is now an ordinary exception, so
cancellation once again means only cancellation.

The concurrency limiter also no longer covers the cache lookup. Its four
permits are shared by the whole server and held for a full round trip, so
cached words queued behind in-flight requests for a permit they did not need
and a message whose every word was cached could still exhaust the caller's
budget and go out unconverted.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
withTimeout reports a timeout as a CancellationException, which callers must
rethrow rather than degrade. With api.timeout below convertWithRomaji's budget
the client's timeout therefore travelled through convertWord, through
withTimeoutOrNull - which rethrows a timeout belonging to another coroutine -
and into the queue worker, which read it as shutdown and ended its loop. The
channel stayed registered with nothing reading it, so every later message from
that player was buffered and never delivered: exactly the failure the worker's
own guard exists to prevent. A timeout is now an ordinary exception, so
cancellation once again means only cancellation.

The concurrency limiter also no longer covers the cache lookup. Its four
permits are shared by the whole server and held for a full round trip, so
cached words queued behind in-flight requests for a permit they did not need
and a message whose every word was cached could still exhaust the caller's
budget and go out unconverted.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>fix: keep a config.yml the YAML reader rejects from disabling the plugin</title>
<updated>2026-08-04T17:23:04+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T17:23:04+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=c29d1621b26f9c1dfbf0a26f4433996060babf10'/>
<id>c29d1621b26f9c1dfbf0a26f4433996060babf10</id>
<content type='text'>
Catching YamlException at the parse step covered kaml but not the scanner
underneath it: a file saved as UTF-16, or one truncated with NUL padding after an
unclean shutdown, fails inside snakeyaml-engine's reader with an exception that
is not a YamlException. It escaped onEnable and Bukkit disabled the plugin over a
config file - the very failure reading per setting was meant to prevent, and one
the catch-all this replaced had handled. The same gap swallowed the serializer's
own non-YamlException fallback.

A UTF-8 BOM is also stripped before parsing. It otherwise stays on the first key,
which strictMode = false drops as an unknown setting without logging anything, so
the operator sees exactly one setting ignored and no reason why.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Catching YamlException at the parse step covered kaml but not the scanner
underneath it: a file saved as UTF-16, or one truncated with NUL padding after an
unclean shutdown, fails inside snakeyaml-engine's reader with an exception that
is not a YamlException. It escaped onEnable and Bukkit disabled the plugin over a
config file - the very failure reading per setting was meant to prevent, and one
the catch-all this replaced had handled. The same gap swallowed the serializer's
own non-YamlException fallback.

A UTF-8 BOM is also stripped before parsing. It otherwise stays on the first key,
which strictMode = false drops as an unknown setting without logging anything, so
the operator sees exactly one setting ignored and no reason why.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>fix: fall back per setting rather than discarding all of config.yml</title>
<updated>2026-08-04T17:13:34+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-04T17:13:34+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=e8c90068535e9b6a98e3e87ed3d2293cccf1ab38'/>
<id>e8c90068535e9b6a98e3e87ed3d2293cccf1ab38</id>
<content type='text'>
kaml rejects a document as a whole, so one unreadable value lost every other
setting the operator had written: velocityIntegration off, serverName "Unknown"
and channel chat disabled, behind one vague SEVERE line. The hand-written mapper
this replaced defaulted per key, so reading the file directly had quietly made
config.yml far more brittle than it was.

The offending setting is now dropped by the path its parse error carries and the
rest of the document is decoded, so the operator loses the one value they got
wrong and is told which. Only a document that is not YAML at all still costs
them everything.

Booleans also accept the YAML 1.1 spellings again. Bukkit read config.yml as
YAML 1.1, where `yes`, `no`, `on` and `off` are booleans; under kaml's YAML 1.2
they became strings, so `checkForUpdates: no` would have reset to its default -
which is the opposite of what the file says.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
kaml rejects a document as a whole, so one unreadable value lost every other
setting the operator had written: velocityIntegration off, serverName "Unknown"
and channel chat disabled, behind one vague SEVERE line. The hand-written mapper
this replaced defaulted per key, so reading the file directly had quietly made
config.yml far more brittle than it was.

The offending setting is now dropped by the path its parse error carries and the
rest of the document is decoded, so the operator loses the one value they got
wrong and is told which. Only a document that is not YAML at all still costs
them everything.

Booleans also accept the YAML 1.1 spellings again. Bukkit read config.yml as
YAML 1.1, where `yes`, `no`, `on` and `off` are booleans; under kaml's YAML 1.2
they became strings, so `checkForUpdates: no` would have reset to its default -
which is the opposite of what the file says.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
</feed>
