<feed xmlns='http://www.w3.org/2005/Atom'>
<title>LunaticChat.git/engine, branch main</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>feat: carry the sender's world in cross-server direct messages</title>
<updated>2026-09-28T14:43:14+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-09-28T14:43:14+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=388ae4ef14ec620e6f1fe2ce8a8ea8e3a816d10d'/>
<id>388ae4ef14ec620e6f1fe2ce8a8ea8e3a816d10d</id>
<content type='text'>
{world} rendered empty for a direct message from another server, which
left formats like "({world})" showing a bare pair of brackets. The relay
now carries the sender's world as an optional field, so this is a PATCH
bump (protocol 1.0.2) and either side can be updated first; an older
sender or proxy still yields an empty value.

The world is read once per message, so the sender, the spies and the
relayed copy can no longer disagree if the sender changes worlds mid-way.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
{world} rendered empty for a direct message from another server, which
left formats like "({world})" showing a bare pair of brackets. The relay
now carries the sender's world as an optional field, so this is a PATCH
bump (protocol 1.0.2) and either side can be updated first; an older
sender or proxy still yields an empty value.

The world is read once per message, so the sender, the spies and the
relayed copy can no longer disagree if the sender changes worlds mid-way.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</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://www.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(paper): make the debug switch categorised, live and dumpable</title>
<updated>2026-08-30T11:43:36+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-30T11:43:36+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=85fad9cf81c45b16dc9d0c3310bdbf0eea694723'/>
<id>85fad9cf81c45b16dc9d0c3310bdbf0eea694723</id>
<content type='text'>
config.yml now accepts `debug: velocity,protocol` and a block with `enabled` and
`categories` beside the boolean it always took. Categories exist so that
instrumenting the chat path does not bury the handshake an operator is chasing
under one line per message.

The setting is read by inspecting the YAML node by hand rather than through a
data class, because one key has to accept both a scalar and a map and kaml picks
the input to build from the descriptor kind alone - a class descriptor rejects
`debug: true` before the serializer is ever called. An unknown category name is
collected rather than thrown: throwing would make ConfigManager prune the whole
`debug` key and switch off the categories the operator did spell correctly.

`debug` moves from RESTART_REQUIRED to applied, since a reload only has to
replace a DebugState. `/lc debug` switches a category for the running server and
deliberately does not write config.yml back - re-emitting a commented YAML
document from a parsed tree loses every comment and every bit of formatting the
operator put there, and a restart or `/lc reload` putting the file back in charge
is the safer default anyway.

`/lc dump` writes the report a bug report needs: versions, enabled features,
connection state and how full each store is. It carries no message text, player
name or UUID, because it exists to be pasted into a public issue by someone who
is not reading it first. Its reply names the file relative to the server
directory - the command can be granted to someone with no filesystem access, to
whom an absolute path discloses where the server is installed and nothing they
can act on.

The KtorSimpleLogger calls in ChannelMessageHandler and ChannelMessageLogger are
folded into the same logger, so there is one place that decides whether a
diagnostic line is printed.

Refs #228

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
config.yml now accepts `debug: velocity,protocol` and a block with `enabled` and
`categories` beside the boolean it always took. Categories exist so that
instrumenting the chat path does not bury the handshake an operator is chasing
under one line per message.

The setting is read by inspecting the YAML node by hand rather than through a
data class, because one key has to accept both a scalar and a map and kaml picks
the input to build from the descriptor kind alone - a class descriptor rejects
`debug: true` before the serializer is ever called. An unknown category name is
collected rather than thrown: throwing would make ConfigManager prune the whole
`debug` key and switch off the categories the operator did spell correctly.

`debug` moves from RESTART_REQUIRED to applied, since a reload only has to
replace a DebugState. `/lc debug` switches a category for the running server and
deliberately does not write config.yml back - re-emitting a commented YAML
document from a parsed tree loses every comment and every bit of formatting the
operator put there, and a restart or `/lc reload` putting the file back in charge
is the safer default anyway.

`/lc dump` writes the report a bug report needs: versions, enabled features,
connection state and how full each store is. It carries no message text, player
name or UUID, because it exists to be pasted into a public issue by someone who
is not reading it first. Its reply names the file relative to the server
directory - the command can be granted to someone with no filesystem access, to
whom an absolute path discloses where the server is installed and nothing they
can act on.

The KtorSimpleLogger calls in ChannelMessageHandler and ChannelMessageLogger are
folded into the same logger, so there is one place that decides whether a
diagnostic line is printed.

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://www.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>feat(paper): add /lc reload to apply message formats without a restart</title>
<updated>2026-08-09T23:31:20+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-09T23:31:20+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=f08d4d8f0557581d93063cd9f6e750ab3ac9cc17'/>
<id>f08d4d8f0557581d93063cd9f6e750ab3ac9cc17</id>
<content type='text'>
Changing a message format meant taking the server down, because config.yml
was read once in onEnable and every consumer held the value it was built
with. Operators tune these formats far more often than anything else in the
file, and a restart is a disproportionate price for a colour code.

Only messageFormat is applied. The rest of config.yml settles things once,
at startup - which services exist, which commands and listeners are
registered, which files are opened - and none of that can be redone while
the server runs: Paper registers commands only through its COMMANDS
lifecycle event, Bukkit cannot unregister a listener, and the conversion
cache saver keeps no task handle to cancel. Rebuilding the services would
leave the old ones still wired up, so a changed setting the server cannot
pick up is reported back as needing a restart rather than silently ignored.

MessageFormatHolder is deliberately narrower than LunaticChatConfiguration:
taking it says the value is live, taking the configuration says it was
frozen at startup. A holder over the whole tree would hold a configuration
matching no version of the file, since only messageFormat is ever swapped.

Reload reads config.yml more strictly than startup does, because it has an
option startup does not - leaving the running configuration alone. Startup
falls a single unreadable setting back to its default so the server can come
up; reload refuses the file, names every unreadable setting at once, and
keeps what the server already had. A document holding no settings is refused
for the same reason: it cannot be told apart from a config.yml caught
halfway through being written, and resetting every format over that is worse
than doing nothing.

Closes #266

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
Changing a message format meant taking the server down, because config.yml
was read once in onEnable and every consumer held the value it was built
with. Operators tune these formats far more often than anything else in the
file, and a restart is a disproportionate price for a colour code.

Only messageFormat is applied. The rest of config.yml settles things once,
at startup - which services exist, which commands and listeners are
registered, which files are opened - and none of that can be redone while
the server runs: Paper registers commands only through its COMMANDS
lifecycle event, Bukkit cannot unregister a listener, and the conversion
cache saver keeps no task handle to cancel. Rebuilding the services would
leave the old ones still wired up, so a changed setting the server cannot
pick up is reported back as needing a restart rather than silently ignored.

MessageFormatHolder is deliberately narrower than LunaticChatConfiguration:
taking it says the value is live, taking the configuration says it was
frozen at startup. A holder over the whole tree would hold a configuration
matching no version of the file, since only messageFormat is ever swapped.

Reload reads config.yml more strictly than startup does, because it has an
option startup does not - leaving the running configuration alone. Startup
falls a single unreadable setting back to its default so the server can come
up; reload refuses the file, names every unreadable setting at once, and
keeps what the server already had. A document holding no settings is refused
for the same reason: it cannot be told apart from a config.yml caught
halfway through being written, and resetting every format over that is worse
than doing nothing.

Closes #266

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: keep rendering out of CommandResult</title>
<updated>2026-08-04T16:16:03+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-03T06:19:08+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=d45db164428bb4b350c879bf3f43ceb1cc581955'/>
<id>d45db164428bb4b350c879bf3f43ceb1cc581955</id>
<content type='text'>
CommandResult carried Adventure Components, which was engine's last
Minecraft dependency and the reason CLAUDE.md's "engine has no Minecraft
platform dependencies" was not quite true. It also meant a command could
not report a result without having already decided how it looks: every
site had to pick formatError versus format before it could return.

Results now carry text, and LunaticCommandBase.handleResult is the single
place that styles it - error red for Failure, normal for
SuccessWithMessage. The fail()/ok() helpers from #260 already funnelled
every call site through two functions, so this is a change to those two
plus the one command that composes its own success text.

engine's dependency list is down to kotlinx-serialization, and nothing
under engine/src references net.kyori, org.bukkit, com.velocitypowered or
io.papermc.

Not done: the review also proposed collapsing the per-command
`when (error)` blocks into one exception-to-key table. Those blocks pick
wording, not just a key - "only owners can delete this channel" reads
differently in the ban command than the delete command - so a shared table
would hand every caller the same sentence and need per-command overrides
on top. Left alone deliberately.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
CommandResult carried Adventure Components, which was engine's last
Minecraft dependency and the reason CLAUDE.md's "engine has no Minecraft
platform dependencies" was not quite true. It also meant a command could
not report a result without having already decided how it looks: every
site had to pick formatError versus format before it could return.

Results now carry text, and LunaticCommandBase.handleResult is the single
place that styles it - error red for Failure, normal for
SuccessWithMessage. The fail()/ok() helpers from #260 already funnelled
every call site through two functions, so this is a change to those two
plus the one command that composes its own success text.

engine's dependency list is down to kotlinx-serialization, and nothing
under engine/src references net.kyori, org.bukkit, com.velocitypowered or
io.papermc.

Not done: the review also proposed collapsing the per-command
`when (error)` blocks into one exception-to-key table. Those blocks pick
wording, not just a key - "only owners can delete this channel" reads
differently in the ban command than the delete command - so a shared table
would hand every caller the same sentence and need per-command overrides
on top. Left alone deliberately.

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://www.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>refactor: move romaji conversion out of engine</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:09:58+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=fd56ebaa1dc2cfa8f834edb06395e8fc02375d1d'/>
<id>fd56ebaa1dc2cfa8f834edb06395e8fc02375d1d</id>
<content type='text'>
Closes #259.

engine exposed ktor through api(), so both platforms inherited the client
and its CIO engine. The only thing in engine that used ktor was
GoogleIMEClient, and the only module that used GoogleIMEClient was
platform-paper - Velocity was shipping roughly six megabytes of HTTP
client to support a Paper-only feature. Same story for
kotlinx-coroutines-core, which Velocity does not use at all.

Romaji conversion is a Paper feature, so the converter package now lives
in platform-paper alongside the ConversionCache and RomanjiConverter that
were already there. engine keeps kotlinx-serialization on api(), which is
genuine shared surface: the plugin messaging protocol is built on it.

The velocity shadow jar goes from 7,618,405 to 2,769,395 bytes, and no
longer contains io/ktor at all.

CacheData's tests were sitting inside engine's SettingsDataClassesTest,
which is unrelated to settings; they move with the class.

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

engine exposed ktor through api(), so both platforms inherited the client
and its CIO engine. The only thing in engine that used ktor was
GoogleIMEClient, and the only module that used GoogleIMEClient was
platform-paper - Velocity was shipping roughly six megabytes of HTTP
client to support a Paper-only feature. Same story for
kotlinx-coroutines-core, which Velocity does not use at all.

Romaji conversion is a Paper feature, so the converter package now lives
in platform-paper alongside the ConversionCache and RomanjiConverter that
were already there. engine keeps kotlinx-serialization on api(), which is
genuine shared surface: the plugin messaging protocol is built on it.

The velocity shadow jar goes from 7,618,405 to 2,769,395 bytes, and no
longer contains io/ktor at all.

CacheData's tests were sitting inside engine's SettingsDataClassesTest,
which is unrelated to settings; they move with the class.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</pre>
</div>
</content>
</entry>
<entry>
<title>refactor: fold the remaining small duplications</title>
<updated>2026-08-02T10:45:33+00:00</updated>
<author>
<name>Sho Sakuma</name>
<email>me@m1sk9.dev</email>
</author>
<published>2026-08-02T10:45:33+00:00</published>
<link rel='alternate' type='text/html' href='https://www.dangofactory.net/cgit/cgit.cgi/LunaticChat.git/commit/?id=7bcaaf9a305c2a8608a420c8f05521bd2de089dd'/>
<id>7bcaaf9a305c2a8608a420c8f05521bd2de089dd</id>
<content type='text'>
- MessageFormatter built the same prefix component in three functions.
- LanguageManager copied kaml's YamlNode into a private YamlValue tree
  before flattening it, so the map case was written twice and the
  list-of-maps case rendered a Kotlin data class toString into a player
  facing string. It now folds YamlNode directly.
- StatusCommand inlined `if (enabled) "toggle.on" else "toggle.off"`,
  which is the body of LanguageManager.getToggleText.
- The three chat formats each spelled out their own chain of
  String.replace, with the valid placeholder names documented only in a
  config.yml comment.
- ChannelContext carried a channelId that both construction sites filled
  with channel.id; it is now derived, so the two cannot disagree.
- ChannelInfo and ChannelStatus each declared MAX_MEMBERS_DISPLAY = 10 and
  built the same truncated member line, differing only in indent. A
  divergence between the two constants would have been invisible.

Co-Authored-By: Claude &lt;noreply@anthropic.com&gt;
</content>
<content type='xhtml'>
<div xmlns='http://www.w3.org/1999/xhtml'>
<pre>
- MessageFormatter built the same prefix component in three functions.
- LanguageManager copied kaml's YamlNode into a private YamlValue tree
  before flattening it, so the map case was written twice and the
  list-of-maps case rendered a Kotlin data class toString into a player
  facing string. It now folds YamlNode directly.
- StatusCommand inlined `if (enabled) "toggle.on" else "toggle.off"`,
  which is the body of LanguageManager.getToggleText.
- The three chat formats each spelled out their own chain of
  String.replace, with the valid placeholder names documented only in a
  config.yml comment.
- ChannelContext carried a channelId that both construction sites filled
  with channel.id; it is now derived, so the two cannot disagree.
- ChannelInfo and ChannelStatus each declared MAX_MEMBERS_DISPLAY = 10 and
  built the same truncated member line, differing only in indent. A
  divergence between the two constants would have been invisible.

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://www.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>
</feed>
