<feed xmlns='http://www.w3.org/2005/Atom'>
<title>LunaticChat.git/platform-velocity/src/main/kotlin/dev, branch main</title>
<subtitle>A next-generation chat plugin for Paper, Folia and Velocity.
</subtitle>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/'/>
<entry>
<title>docs: name the anthy engine in the project description</title>
<updated>2026-10-11T03:31:28+00:00</updated>
<author>
<name>Charlie Root</name>
<email>root@dangofactory.local</email>
</author>
<published>2026-10-11T03:31:28+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=d06ef045d846a751d92936c223ac8f2d2f71efb4'/>
<id>d06ef045d846a751d92936c223ac8f2d2f71efb4</id>
<content type='text'>
The tagline and both plugin descriptors now say that romaji conversion can run
inside the server through anthy, so the engine is visible wherever the project
describes itself: the README, the website hero and meta description, the API
documentation, and the Paper and Velocity plugin descriptors.
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
The tagline and both plugin descriptors now say that romaji conversion can run
inside the server through anthy, so the engine is visible wherever the project
describes itself: the README, the website hero and meta description, the API
documentation, and the Paper and Velocity plugin descriptors.
</pre>
</div>
</content>
</entry>
<entry>
<title>fix(paper): stop the command category logging on every tree build</title>
<updated>2026-08-30T12:11:46+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-30T12:11:46+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=1c79bee8f3c8fe20582afa96527cd298c043daa5'/>
<id>1c79bee8f3c8fe20582afa96527cd298c043daa5</id>
<content type='text'>
Three findings from review of #296.

Brigadier evaluates a `requires` predicate when it builds the command tree it
sends to a player - on every join and every updateCommands() - not when someone
is refused. The denial line sitting there put one entry per gated node per
player in the log, burying the command results the category exists to show. It
is dropped rather than moved: a hidden node is never reached by an execution, so
there is no refusal path to log from.

`all` was honoured by `debug: all` and `/lc debug all on` but not by
`categories: [all]`, which resolved to an unknown name - leaving `enabled: true`
with nothing switched on, reported as off by /lc status, explained only by a
warning in the log. The word now lives in one place that all three read.

Two dumps in the same second left one file, because the name is only accurate to
the second and the write replaces. Reproducing a bug means dumping either side of
the step that triggers it, so the second one now takes a suffix.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Three findings from review of #296.

Brigadier evaluates a `requires` predicate when it builds the command tree it
sends to a player - on every join and every updateCommands() - not when someone
is refused. The denial line sitting there put one entry per gated node per
player in the log, burying the command results the category exists to show. It
is dropped rather than moved: a hidden node is never reached by an execution, so
there is no refusal path to log from.

`all` was honoured by `debug: all` and `/lc debug all on` but not by
`categories: [all]`, which resolved to an unknown name - leaving `enabled: true`
with nothing switched on, reported as off by /lc status, explained only by a
warning in the log. The word now lives in one place that all three read.

Two dumps in the same second left one file, because the name is only accurate to
the second and the write replaces. Reproducing a bug means dumping either side of
the step that triggers it, so the second one now takes a suffix.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>feat(velocity): let the proxy be debugged without a config file</title>
<updated>2026-08-30T11:43:45+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-30T11:43:45+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=3aecb1374913d585c789c0c0f27295aa6be43fc4'/>
<id>3aecb1374913d585c789c0c0f27295aa6be43fc4</id>
<content type='text'>
The proxy had three slf4j debug calls and no way to turn them on, so the
handshake internals were either missing or mixed into INFO for every backend.

The switch is read from the `lunaticchat.debug` system property, falling back to
the `LUNATICCHAT_DEBUG` environment variable, rather than from a new config file:
a file means a parser and a parser means a dependency, and platform-velocity
deliberately ships without so much as Ktor. The property wins so a proxy started
from a compose file can still be overridden for one run without editing it.

Paper's `debug` is deliberately not carried over the handshake. The proxy is
shared by every backend, so one Paper server's setting must not decide how much
the proxy logs for everyone else.

The compatibility check now reports both halves of the rule with their measured
values. "Protocol version incompatible" left the operator comparing two version
strings against a rule they could not see, and which half failed is the whole
answer when a rolling upgrade stops relaying. For the same reason the relay names
the servers it skipped and why: from the receiving server's log, "the message
never arrived" and "the message was never sent" look identical.

The lines that were unconditionally at INFO drop to their category, leaving only
the failure an operator has to act on.

Refs #228

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
The proxy had three slf4j debug calls and no way to turn them on, so the
handshake internals were either missing or mixed into INFO for every backend.

The switch is read from the `lunaticchat.debug` system property, falling back to
the `LUNATICCHAT_DEBUG` environment variable, rather than from a new config file:
a file means a parser and a parser means a dependency, and platform-velocity
deliberately ships without so much as Ktor. The property wins so a proxy started
from a compose file can still be overridden for one run without editing it.

Paper's `debug` is deliberately not carried over the handshake. The proxy is
shared by every backend, so one Paper server's setting must not decide how much
the proxy logs for everyone else.

The compatibility check now reports both halves of the rule with their measured
values. "Protocol version incompatible" left the operator comparing two version
strings against a rule they could not see, and which half failed is the whole
answer when a rolling upgrade stops relaying. For the same reason the relay names
the servers it skipped and why: from the receiving server's log, "the message
never arrived" and "the message was never sent" look identical.

The lines that were unconditionally at INFO drop to their category, leaving only
the failure an operator has to act on.

Refs #228

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>feat(engine): give both platforms one debug switch to read</title>
<updated>2026-08-30T11:43:18+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-30T11:43:18+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=64197733346bbcabc3daa65f6648c1a21e26b328'/>
<id>64197733346bbcabc3daa65f6648c1a21e26b328</id>
<content type='text'>
"Debug" had grown into three unconnected mechanisms: a boolean injected into
RomanjiConverter, a one-off configuration dump at startup, and KtorSimpleLogger
calls that Paper's stock log4j configuration never printed. Turning `debug: true`
on told an operator nothing about the Velocity handshake, which is what #228 is
actually about.

The vocabulary goes in engine because it is a contract before it is an
implementation: config.yml and the proxy's system property have to agree on what
`velocity,protocol` means, and a category added on one side must exist on the
other. Only the writing is left to the platforms.

The message is a lambda so that the chat path - instrumented once per message -
costs nothing while debugging is off. The `if (debugMode) logger.info("... $word")`
guards this replaces built their strings either way.

Both platform loggers write at INFO rather than at the level the content
deserves, because neither Paper's nor Velocity's stock logging configuration
prints anything below it, and asking operators to edit log4j before they can
answer a bug report is the friction this exists to remove.

Refs #228

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
"Debug" had grown into three unconnected mechanisms: a boolean injected into
RomanjiConverter, a one-off configuration dump at startup, and KtorSimpleLogger
calls that Paper's stock log4j configuration never printed. Turning `debug: true`
on told an operator nothing about the Velocity handshake, which is what #228 is
actually about.

The vocabulary goes in engine because it is a contract before it is an
implementation: config.yml and the proxy's system property have to agree on what
`velocity,protocol` means, and a category added on one side must exist on the
other. Only the writing is left to the platforms.

The message is a lambda so that the chat path - instrumented once per message -
costs nothing while debugging is off. The `if (debugMode) logger.info("... $word")`
guards this replaces built their strings either way.

Both platform loggers write at INFO rather than at the level the content
deserves, because neither Paper's nor Velocity's stock logging configuration
prints anything below it, and asking operators to edit log4j before they can
answer a bug report is the friction this exists to remove.

Refs #228

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: make the direct message failure reason an enum</title>
<updated>2026-08-04T16:15:29+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-03T06:12:42+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=daf9060561656f468a0ae366e5695f5eb74f48f7'/>
<id>daf9060561656f468a0ae366e5695f5eb74f48f7</id>
<content type='text'>
DirectMessageError.reason was a String backed by two constants, so the
receiving side matched one case and let everything else fall through to
"the target is offline". Adding a third reason on the proxy would have
shipped it to Paper servers that silently reported the wrong thing - the
one string-keyed dispatch sitting next to a protocol layer whose messages
are otherwise a sealed hierarchy with exhaustiveness checking.

As an enum, the reader must decide what to show for each case, and
CrossServerDirectMessageManager's when no longer needs an else.

The wire format is unchanged: kotlinx serializes an enum as its name, so
the existing snapshots still decode. What did need care is the reverse
direction - a reason from a newer proxy would now fail to parse, where the
String version degraded. The property has a default and the codec enables
coerceInputValues, so an unknown reason lands on TARGET_OFFLINE, exactly
the old else branch. There is a compatibility test for that case.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
DirectMessageError.reason was a String backed by two constants, so the
receiving side matched one case and let everything else fall through to
"the target is offline". Adding a third reason on the proxy would have
shipped it to Paper servers that silently reported the wrong thing - the
one string-keyed dispatch sitting next to a protocol layer whose messages
are otherwise a sealed hierarchy with exhaustiveness checking.

As an enum, the reader must decide what to show for each case, and
CrossServerDirectMessageManager's when no longer needs an else.

The wire format is unchanged: kotlinx serializes an enum as its name, so
the existing snapshots still decode. What did need care is the reverse
direction - a reason from a newer proxy would now fail to parse, where the
String version degraded. The property has a default and the codec enables
coerceInputValues, so an unknown reason lands on TARGET_OFFLINE, exactly
the old else branch. There is a compatibility test for that case.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>perf: take per-message logging off the chat path</title>
<updated>2026-08-04T15:42:23+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-02T15:40:30+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=2d8b613a3ad48acafd683f5286e29c3f1c167275'/>
<id>2d8b613a3ad48acafd683f5286e29c3f1c167275</id>
<content type='text'>
Every channel message wrote a fully interpolated INFO line, duplicating
what the dedicated async ChannelMessageLogger already persists. Every
cross-server message produced one INFO on the sending Paper server, two on
Velocity, and one on each receiving Paper server - so a single global chat
message cost up to four synchronous console and latest.log writes across
the network, on the message path, with JUL and Logback appenders being
synchronous.

These are now debug, and phrased so the string is not built unless debug
is on: the JUL sites take a supplier, the slf4j sites take a format plus
arguments.

Handshake, registration and delivery-failure lines stay at info - they
fire once per server or once per failed message, and they explain
something an operator needs to see.

Two relay tests asserted on the text of a log line. One of them was
verifying "0 servers" where the same test already verifies zero sends;
the other was really checking that the payload reaches the target, so it
now decodes the relayed bytes and compares them to the original message.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Every channel message wrote a fully interpolated INFO line, duplicating
what the dedicated async ChannelMessageLogger already persists. Every
cross-server message produced one INFO on the sending Paper server, two on
Velocity, and one on each receiving Paper server - so a single global chat
message cost up to four synchronous console and latest.log writes across
the network, on the message path, with JUL and Logback appenders being
synchronous.

These are now debug, and phrased so the string is not built unless debug
is on: the JUL sites take a supplier, the slf4j sites take a format plus
arguments.

Handshake, registration and delivery-failure lines stay at info - they
fire once per server or once per failed message, and they explain
something an operator needs to see.

Two relay tests asserted on the text of a log line. One of them was
verifying "0 servers" where the same test already verifies zero sends;
the other was really checking that the payload reaches the target, so it
now decodes the relayed bytes and compares them to the original message.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: single-source the plugin messaging channel and dedup cache</title>
<updated>2026-08-02T10:27:51+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-02T10:27:51+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=bf62ebe35653492c123c729ae03cb32d8e20e2e6'/>
<id>bf62ebe35653492c123c729ae03cb32d8e20e2e6</id>
<content type='text'>
The channel Paper and Velocity talk over was declared in seven places, in
two spellings ("lunaticchat:main" and the namespace/name pair), one of
them an inline literal in CrossServerChatManager that bypassed even its
own file's constant. Renaming it meant finding all seven; missing one
leaves both sides compiling and starting, just not talking. It now lives
next to the codec that defines the wire format.

The echo-suppression cache was likewise written twice, and the copies had
already drifted in style - one hand-rolled the expiry sweep, the other used
filter/map - while staying semantically identical. Any future change to
eviction would have had to land in both, and CrossServerChatManager's copy
carried a comment claiming ConcurrentHashMap iterators cannot remove(),
which they can.

MessageDeduplicationCache documents the one property that surprised the
tests written against it: eviction orders by millisecond timestamp, so a
burst inside a single millisecond evicts arbitrarily among its members.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
The channel Paper and Velocity talk over was declared in seven places, in
two spellings ("lunaticchat:main" and the namespace/name pair), one of
them an inline literal in CrossServerChatManager that bypassed even its
own file's constant. Renaming it meant finding all seven; missing one
leaves both sides compiling and starting, just not talking. It now lives
next to the codec that defines the wire format.

The echo-suppression cache was likewise written twice, and the copies had
already drifted in style - one hand-rolled the expiry sweep, the other used
filter/map - while staying semantically identical. Any future change to
eviction would have had to land in both, and CrossServerChatManager's copy
carried a comment claiming ConcurrentHashMap iterators cannot remove(),
which they can.

MessageDeduplicationCache documents the one property that surprised the
tests written against it: eviction orders by millisecond timestamp, so a
burst inside a single millisecond evicts arbitrarily among its members.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: remove code that no production path reaches</title>
<updated>2026-08-02T10:10:56+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-02T10:10:46+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=bc0d7d522e169f3eeb8d5b632ee673392060694b'/>
<id>bc0d7d522e169f3eeb8d5b632ee673392060694b</id>
<content type='text'>
These were all scaffolding that drifted out of use, and each one costs
a reader time before they discover it does nothing:

- UUIDASStringSerializer duplicated UUIDSerializer byte for byte; the
  differing descriptor name never reaches the JSON/YAML wire format, so
  the choice between them was a coin flip for contributors.
- Velocity's BuildInfo was never referenced (the plugin reads its version
  from PluginContainer) and read a "commit" property the build never
  wrote, so it would have reported "unknown" had anyone called it.
- KanaConverter.TrieNode.Leaf is never constructed: buildTrie starts from
  a Branch and insert only ever returns Branch. Six branches guarded
  against a state the type system allowed but the code could not produce.
  With those gone, isValidRomaji and toHiragana were visibly the same
  trie walk, so they now share one longestMatch.
- @Deprecated command handling had no annotated command to act on.
- The settings backup restore looked for *.backup.* files that nothing in
  the repository writes, so it always fell through to empty settings.

Also drops CommandContext.replyWithEvent/replyPlain, PluginCoroutineScope's
unused plugin parameter, GitHubRelease fields no caller reads, and four
language keys with no lookup site.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
These were all scaffolding that drifted out of use, and each one costs
a reader time before they discover it does nothing:

- UUIDASStringSerializer duplicated UUIDSerializer byte for byte; the
  differing descriptor name never reaches the JSON/YAML wire format, so
  the choice between them was a coin flip for contributors.
- Velocity's BuildInfo was never referenced (the plugin reads its version
  from PluginContainer) and read a "commit" property the build never
  wrote, so it would have reported "unknown" had anyone called it.
- KanaConverter.TrieNode.Leaf is never constructed: buildTrie starts from
  a Branch and insert only ever returns Branch. Six branches guarded
  against a state the type system allowed but the code could not produce.
  With those gone, isValidRomaji and toHiragana were visibly the same
  trie walk, so they now share one longestMatch.
- @Deprecated command handling had no annotated command to act on.
- The settings backup restore looked for *.backup.* files that nothing in
  the repository writes, so it always fell through to empty settings.

Also drops CommandContext.replyWithEvent/replyPlain, PluginCoroutineScope's
unused plugin parameter, GitHubRelease fields no caller reads, and four
language keys with no lookup site.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>feat: add cross-server direct messaging via Velocity</title>
<updated>2026-06-17T07:15:40+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-06-17T07:15:40+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=c678bd15324dad0519cb1d9a4c58202a981ee8e2'/>
<id>c678bd15324dad0519cb1d9a4c58202a981ee8e2</id>
<content type='text'>
Allow /tell and /reply to reach players on other Paper servers behind a
Velocity proxy using the "&lt;player&gt;@&lt;server&gt;" target syntax.

Engine (protocol bumped 1.0.0 -&gt; 1.0.1, optional sub-channels):
- Add DirectMessageRelay, DirectMessageError, PresenceSnapshot/PresenceEntry
  and PresenceRequest messages plus codec branches.

Velocity:
- CrossServerDirectMessageRelay routes a DM to the target server (or returns
  a delivery error to the source).
- PresenceTracker broadcasts proxy-wide presence snapshots on join/quit/switch
  and on request.

Paper:
- RemotePlayerRegistry caches proxy presence for completion and remote target
  resolution.
- CrossServerDirectMessageManager handles send/receive/error and dedup.
- DirectMessageHandler reply state generalized to ReplyTarget (Local/Remote)
  so /reply works across servers.
- TellCommand parses "name@server", completes local names and remote
  name@server targets, and uses exact local name matching.
- New crossServerDirectMessage config flag and i18n keys (en/ja).

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Allow /tell and /reply to reach players on other Paper servers behind a
Velocity proxy using the "&lt;player&gt;@&lt;server&gt;" target syntax.

Engine (protocol bumped 1.0.0 -&gt; 1.0.1, optional sub-channels):
- Add DirectMessageRelay, DirectMessageError, PresenceSnapshot/PresenceEntry
  and PresenceRequest messages plus codec branches.

Velocity:
- CrossServerDirectMessageRelay routes a DM to the target server (or returns
  a delivery error to the source).
- PresenceTracker broadcasts proxy-wide presence snapshots on join/quit/switch
  and on request.

Paper:
- RemotePlayerRegistry caches proxy presence for completion and remote target
  resolution.
- CrossServerDirectMessageManager handles send/receive/error and dedup.
- DirectMessageHandler reply state generalized to ReplyTarget (Local/Remote)
  so /reply works across servers.
- TellCommand parses "name@server", completes local names and remote
  name@server targets, and uses exact local name matching.
- New crossServerDirectMessage config flag and i18n keys (en/ja).

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>feat: Improving Velocity's Cycling Compatibility</title>
<updated>2026-04-04T11:26:37+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-04-04T11:26:37+00:00</published>
<link rel='alternate' type='text/html' href='https://openpgpkey.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=2b0829856c9a24b90a9248d28c7de84101b83232'/>
<id>2b0829856c9a24b90a9248d28c7de84101b83232</id>
<content type='text'>
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
</pre>
</div>
</content>
</entry>
</feed>
