summaryrefslogtreecommitdiff
path: root/website/src/docs
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-07-19 22:06:37 +0900
committerGitHub <noreply@github.com>2026-07-19 22:06:37 +0900
commitbee1a61b7e38b160d967a91dd06bc5f44e14d057 (patch)
treea8a0d281f1ddb4f314eb70ec3d01f4e4f7134cc8 /website/src/docs
parent31e024179517b261dedd45ff10dc80bb928af5df (diff)
downloadLunaticChat-bee1a61b7e38b160d967a91dd06bc5f44e14d057.tar.gz
LunaticChat-bee1a61b7e38b160d967a91dd06bc5f44e14d057.tar.bz2
LunaticChat-bee1a61b7e38b160d967a91dd06bc5f44e14d057.zip
docs: add developer design/architecture guide (EN/JA) (#251)
* docs: add developer design/architecture guide (EN/JA) The docs site covered features and reference but had no entry point for the codebase's design. Add a Developer Guide section describing the module structure (engine shared kernel, platform-paper, platform-velocity), the protocol-version compatibility model, and the Service Container / Feature Gating pattern, so contributors can understand the architecture without reading the source first. Co-Authored-By: Claude <noreply@anthropic.com> * docs: mirror cross-server DM docs into English (#231) PR #231 added the cross-server direct messaging feature but updated only the Japanese docs. Port the same additions to the English pages (configuration, direct-message, velocity, commands) so both locales stay in sync. Co-Authored-By: Claude <noreply@anthropic.com> * style: apply biome formatting to ja.ts sidebar config The developer-guide sidebar entries were not biome-formatted, failing the build_docs CI check (format:check). Apply the formatter. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
Diffstat (limited to 'website/src/docs')
-rw-r--r--website/src/docs/configuration.md1
-rw-r--r--website/src/docs/developers/architecture.md87
-rw-r--r--website/src/docs/developers/engine.md95
-rw-r--r--website/src/docs/developers/introduction.md35
-rw-r--r--website/src/docs/developers/platform-paper.md189
-rw-r--r--website/src/docs/developers/platform-velocity.md92
-rw-r--r--website/src/docs/developers/resource.md60
-rw-r--r--website/src/docs/features/direct-message.md12
-rw-r--r--website/src/docs/features/velocity.md4
-rw-r--r--website/src/docs/reference/commands.md4
10 files changed, 578 insertions, 1 deletions
diff --git a/website/src/docs/configuration.md b/website/src/docs/configuration.md
index e7891b3..859b617 100644
--- a/website/src/docs/configuration.md
+++ b/website/src/docs/configuration.md
@@ -57,6 +57,7 @@ LunaticChat's configuration is managed in `plugins/LunaticChat/config.yml`. A de
|-----|------|---------|-------------|
| `enabled` | Boolean | `false` | Enable integration with the Velocity proxy |
| `crossServerGlobalChat` | Boolean | `false` | Enable cross-server global chat |
+| `crossServerDirectMessage` | Boolean | `false` | Enable cross-server direct messaging |
| `serverName` | String | `"Unknown"` | Server name displayed in cross-server chat |
| `messageDeduplicationCacheSize` | Int | `100` | Size of the message deduplication cache |
diff --git a/website/src/docs/developers/architecture.md b/website/src/docs/developers/architecture.md
new file mode 100644
index 0000000..4ba88d1
--- /dev/null
+++ b/website/src/docs/developers/architecture.md
@@ -0,0 +1,87 @@
+---
+layout: doc
+---
+
+# Design Overview
+
+Alongside direct messaging, channel chat, and romaji conversion on Paper/Folia, LunaticChat provides **cross-server global chat relay** behind a Velocity proxy.
+
+This page covers the big picture and the cross-cutting design decisions that span modules. See the per-module pages for details.
+
+## Module Structure
+
+A Gradle multi-module setup separates the shared kernel from the platform implementations.
+
+The dependency direction is one-way: both `platform-paper` and `platform-velocity` depend on `engine`, and `engine` depends on nothing downstream. Neither platform "owns" the protocol — both depend on the neutral `engine` as equal peers.
+
+| Module | Role |
+|--------|------|
+| `engine` | Platform-independent core (domain models, protocol, conversion, exceptions, permissions) |
+| `platform-paper` | Paper / Folia plugin |
+| `platform-velocity` | Velocity proxy plugin (cross-server chat relay) |
+| `dokka` | API documentation aggregator (no Kotlin source) |
+
+### Why extract the engine module
+
+`engine` is a **Shared Kernel**. Its contents fall into two categories.
+
+#### (a) Contracts both sides must agree on
+
+Things that break unless Paper and Velocity share the exact same definition.
+
+- `protocol` — the wire contract between the two processes (communication breaks without identical definitions)
+- `chat` / `channel`, `settings` — persistence schemas (`@Serializable`)
+- `exception` — the shared vocabulary of domain errors
+- `permission`, `command` — neutral abstractions for permission node strings and command results
+
+#### (b) Platform-independent pure logic
+
+Logic that could live anywhere, but is pulled into the neutral core because it is pure and reusable.
+
+- `converter` — the pure romaji-conversion algorithm (Trie) plus an external API client
+
+The primary goal of centralizing (a) in `engine` is to create a **single source of truth for the wire contract**. Paper and Velocity are two artifacts built, deployed, and versioned separately; duplicating the protocol in both modules would inevitably drift. With a single definition in `engine`, a contract mismatch surfaces early as a compile error or a snapshot-test failure rather than a runtime mismatch in production.
+
+`engine` depends on no Bukkit / Velocity API, and borrows only the "meaning of types and values" from Adventure / Brigadier to avoid depending on their runtimes (`compileOnly` Adventure, and `toBrigadierResult()` returning an `Int` without depending on Brigadier itself). This lets `engine` be tested on a pure JVM without spinning up a Minecraft server, while platform concerns (the Folia scheduler, etc.) stay isolated in the platform modules.
+
+## Compatibility via the protocol version
+
+Paper–Velocity compatibility is determined solely by the **`ProtocolVersion`** held in `engine`, not by the plugin version. This is the linchpin of LunaticChat's multi-platform design.
+
+- Compatibility check: MAJOR must match exactly, the remote MINOR must be within `[MIN_SUPPORTED_MINOR, MINOR]`, and PATCH is ignored
+- Backward compatibility: JSON `ignoreUnknownKeys` plus fields with default values
+- `ProtocolBackwardCompatibilityTest` verifies backward compatibility mechanically via JSON snapshots
+
+Decoupling compatibility from the plugin version means **Paper and Velocity can be versioned independently, each released at its own pace** even though the two platforms change at different rates. Update ordering is also defined per bump level (PATCH = any order / MINOR = Velocity first / MAJOR = simultaneous), which is what makes rolling updates possible.
+
+For details, see [engine - Shared Kernel](/docs/developers/engine).
+
+## Service Container pattern + Feature Gating
+
+`platform-paper` assembles its features via manual DI, without an external DI framework.
+
+- `ServiceInitializer` handles construction, initialization order, and shutdown
+- `ServiceContainer` (an immutable data class) holds the services
+- **A disabled feature's service is `null`**, so the presence of a feature is expressed in the type
+- Command, listener, and SettingHandler registration branches on `null` checks
+
+In short: "config flag → `ServiceInitializer` creates a nullable service → nullable field on `ServiceContainer` → registration branches on a `null` check." When a feature is disabled, its service simply does not exist at the type level, and the corresponding code path is never built. Feature toggling and lifecycle management are expressed purely through Kotlin's type system and null-safety, with no external framework.
+
+For details, see [platform-paper - Paper / Folia Plugin](/docs/developers/platform-paper).
+
+## Cross-cutting design traits
+
+1. **engine / platform separation of concerns** — the platform layer is a bridge to the platform API, while domain models, algorithms, and the protocol live in `engine`. The platform side is an adapter layer that absorbs "the reality of Bukkit / Velocity".
+2. **Compatibility via the protocol version** — compatibility is decided by `ProtocolVersion` alone.
+3. **Service Container + Feature Gating** — the presence of a feature is expressed in the type.
+4. **Annotation-driven commands** — `@Command` / `@Permission` / `@PlayerOnly` are read via Kotlin reflection and mapped onto the Brigadier tree. A command's definition and its metadata (permission, aliases) are declared together in one place.
+5. **Folia compatibility** — asynchronous work runs on `asyncScheduler` and `PluginCoroutineScope` (SupervisorJob), and Bukkit API calls are moved back to the main thread via `scheduler.runTask`. Thread boundaries are handled explicitly so it also works on region-threaded Folia.
+6. **Persistence chosen per purpose** — languages / player settings = KAML (YAML), channels / conversion cache = kotlinx.serialization JSON, channel logs = NDJSON. All follow the same pattern: in-memory cache + asynchronous save (debounce/queue) + synchronous save on shutdown.
+7. **DM/channel = local, global = via the proxy** — routing differs by chat type; only global chat goes through Velocity. The relay prevents loops in two stages: "exclude the source server" + "deduplicate by messageId".
+
+## Module details
+
+- [engine - Shared Kernel](/docs/developers/engine)
+- [platform-paper - Paper / Folia Plugin](/docs/developers/platform-paper)
+- [platform-velocity - Velocity Plugin (Proxy Relay)](/docs/developers/platform-velocity)
+- [Build, Release & Versioning](/docs/developers/resource)
diff --git a/website/src/docs/developers/engine.md b/website/src/docs/developers/engine.md
new file mode 100644
index 0000000..ee3a2c1
--- /dev/null
+++ b/website/src/docs/developers/engine.md
@@ -0,0 +1,95 @@
+---
+layout: doc
+---
+
+# engine - Shared Kernel
+
+`engine` is the platform-independent core module.
+
+It is positioned as a **Shared Kernel** that gathers the contracts Paper and Velocity share (protocol, schemas, vocabulary) together with platform-independent pure logic (the conversion algorithm).
+
+It depends on no Bukkit / Velocity API, and borrows only the "meaning of types and values" from Adventure / Brigadier without depending on their runtimes. That is why it can be tested on a pure JVM without spinning up a Minecraft server.
+
+For the full rationale behind extracting `engine`, see the [Design Overview](/docs/developers/architecture#why-extract-the-engine-module).
+
+## protocol — Paper ↔ Velocity communication
+
+Paper and Velocity are separate-process artifacts that communicate via plugin messaging. `protocol` lives in `engine` **so that both sides share the exact same wire contract**. Since changing the definition on only one side breaks communication, a single definition is kept in `engine` so mismatches can be caught at compile time and in tests.
+
+There are five message types, headed by `sealed interface PluginMessage`.
+
+| Type | Direction | Key fields |
+|------|-----------|-----------|
+| `Handshake` | Paper→Velocity | `pluginVersion`, `protocol` components |
+| `HandshakeResponse` | Velocity→Paper | `compatible`, `velocityVersion`, `error?`, `protocol` components |
+| `StatusRequest` | Paper→Velocity | (no fields) |
+| `StatusResponse` | Velocity→Paper | `velocityVersion`, `protocolVersion`, `online` |
+| `GlobalChatMessage` | Paper↔Velocity↔Paper | `messageId`, `serverName`, `playerId`, `playerName`, `message`, `timestamp` |
+
+`GlobalChatMessage.messageId` is a unique ID that prevents duplicate display during relay loops. Note also that the protocol layer carries UUIDs as plain `String`s (in contrast to the `UUID` type plus custom serializer used in the settings/channel layers — this keeps transport simple).
+
+### Wire format
+
+- `[subChannel: UTF][messageJson: UTF]` — `DataOutputStream.writeUTF` writes the "sub-channel name" and the "JSON body", a `ByteArray` form convenient for Minecraft plugin messaging
+- JSON via kotlinx-serialization. `Json { ignoreUnknownKeys = true }` means an older version won't break when it receives unknown fields added by a newer version (the basis for forward compatibility)
+- Sub-channels: `handshake` / `handshake_response` / `status_request` / `status_response` / `global_chat`
+
+### Versioning strategy (`ProtocolVersion`)
+
+Paper–Velocity compatibility is judged by `ProtocolVersion` alone, not the plugin version. Following SemVer, the bump level and deployment order are determined by the nature of the change.
+
+| Level | When to bump | Deployment order |
+|-------|--------------|------------------|
+| PATCH | Add an optional field with a default / an ignorable new sub-channel | Any order |
+| MINOR | Add a required field / a sub-channel whose absence degrades functionality | Velocity → Paper |
+| MAJOR | Remove/rename fields or sub-channels, or change the wire format | All simultaneously |
+
+The compatibility check is "**MAJOR matches exactly, the remote MINOR is within `[MIN_SUPPORTED_MINOR, MINOR]`, and PATCH is ignored**". Raising `MIN_SUPPORTED_MINOR` lets you phase out acceptance of older MINOR versions. When adding a new message or field, add a JSON snapshot to `ProtocolBackwardCompatibilityTest` to mechanically guarantee that the old format keeps parsing.
+
+As a consequence of this design, Paper and Velocity can be released independently. See [Build, Release & Versioning](/docs/developers/resource#independent-versioning).
+
+## converter — Romaji-to-Japanese conversion
+
+`converter` is not a Paper↔Velocity contract (Velocity does no romaji conversion); it lives in `engine` **because it is platform-independent pure logic**. It has three layers.
+
+- `KanaConverter` (`object`) — converts romaji to hiragana with a **Trie**. An immutable structure of `sealed class TrieNode { Leaf, Branch }` covers mappings from 4 characters (`xtsu`→っ) down to 1 (`a`→あ). `isValidRomaji()` validates before conversion; `toHiragana()` is a pure algorithm using longest-match plus sokuon handling
+- `GoogleIMEClient` — receives a Ktor `HttpClient` via DI and converts hiragana to kanji-kana via Google IME (`langpair=ja-Hira|ja`), concatenating the top candidate of each segment of the response
+- `CacheData` (`@Serializable`) — the persistence schema for conversion results (`version` plus `entries: Map`). It is a container for caching the expensive IME conversions; the caching logic itself lives on the paper side
+
+## chat/channel — Channel domain model
+
+The channel persistence schemas are placed on the `engine` side as `@Serializable` models — used by the paper side that persists them, and kept sharable for the future.
+
+- `Channel` — validated in `init` (`id` matches `^[a-zA-Z0-9_-]{3,30}$`, `name` must not be blank)
+- `ChannelData` — the persistence root; a `version` field accommodates schema evolution
+- `ChannelMember` / `ChannelRole` — members and roles; roles are the three tiers `OWNER` / `MODERATOR` / `MEMBER`
+- `ChannelContext` — a non-Serializable runtime aggregate DTO (a view passing `channel` + `members` to operations)
+- `ChannelMessageLogEntry` — a log entry designed for NDJSON, daily rotation, and Grafana Loki compatibility
+
+Limits such as the number of channels, members, and memberships keep only the **vocabulary of exceptions** in `engine`, while the concrete thresholds are injected by config (paper side). This separates "that a limit exists" from "what the limit is".
+
+## settings — Player settings and UUID serialization
+
+The persistence model and the runtime model are separated.
+
+- `PlayerSettingsData` — the YAML persistence root; holds three settings as UUID→Boolean maps
+- `PlayerChatSettings` — a flat per-player model (all settings default to true); a runtime view projected from the whole map
+
+There are two UUID serializers because they serve different purposes. `UUIDSerializer` (descriptor name `"UUID"`) is the general one, used by channel and `PlayerChatSettings.uuid`; `UUIDASStringSerializer` (descriptor name `"UUIDAsString"`) is used for the **map keys** of `PlayerSettingsData` for YAML compatibility. They are hand-written because `kotlinx.serialization` does not support UUID out of the box.
+
+## exception — Shared error vocabulary
+
+So that Paper and Velocity can handle domain errors as the same types, exceptions are centralized in `engine`. There is no common sealed base — it is a flat structure (23 types) that directly extends `Exception`. They fall into existence/reference, state, limit, and permission/BAN/KICK categories, and many take `playerId` / `channelId` / `limit` in the constructor and build their own messages. Because there is no base type, callers are expected to catch each individually.
+
+## permission / command — Neutral abstractions
+
+Permissions and command results are placed in `engine` as neutral representations that can be passed to either the Bukkit or Velocity API.
+
+- `LunaticChatPermissionNode` — permissions enumerated type-safely as `sealed class` + `object` subclasses. The string node can be passed to either platform's permission API, and `when` also gives exhaustiveness checking
+- `CommandResult` — a `sealed class` (`Success` / `SuccessWithMessage` / `Failure` / `InvalidUsage`). The message is an Adventure `Component`, and `toBrigadierResult()` expresses only "the meaning of the return value" (success=1/failure=0) without depending on Brigadier itself
+
+## Related
+
+- [Design Overview](/docs/developers/architecture)
+- [platform-paper - Paper / Folia Plugin](/docs/developers/platform-paper)
+- [platform-velocity - Velocity Plugin](/docs/developers/platform-velocity)
diff --git a/website/src/docs/developers/introduction.md b/website/src/docs/developers/introduction.md
new file mode 100644
index 0000000..3e3f3a9
--- /dev/null
+++ b/website/src/docs/developers/introduction.md
@@ -0,0 +1,35 @@
+---
+layout: doc
+---
+
+# Introduction
+
+This is a developer guide covering the design and architecture of LunaticChat.
+
+Players and server administrators should refer to the [documentation / reference](/docs/getting-started).
+
+::: tip Target versions
+The design and architecture described in this guide reflect [Paper/Folia: v1.2.2](https://github.com/m1sk9/LunaticChat/releases/tag/paper%2Fv1.2.2) and [Velocity: v1.1.0](https://github.com/m1sk9/LunaticChat/releases/tag/velocity%2Fv1.1.0).
+:::
+
+## Module Structure
+
+LunaticChat is organized into the following modules.
+
+For the overall design, see [Design / Architecture](/docs/developers/architecture).
+
+| Module | Role |
+|--------|------|
+| `engine` | Platform-independent core |
+| `platform-paper` | Paper / Folia plugin |
+| `platform-velocity` | Velocity proxy plugin |
+
+## Guide Index
+
+- [Design / Architecture](/docs/developers/architecture) — the big picture
+ - [engine - Shared Kernel](/docs/developers/engine)
+ - [platform-paper - Paper / Folia Plugin](/docs/developers/platform-paper)
+ - [platform-velocity - Velocity Plugin (Proxy Relay)](/docs/developers/platform-velocity)
+- [Build, Release & Versioning](/docs/developers/resource) — release flow and versioning
+
+- [The story of building "LunaticChat", a successor to LunaChat - m1sk9 (Zenn)](https://zenn.dev/m1sk9/articles/adb6c0a7fa7bd2) — null-safety, coroutine usage, cache system, and more (external site, Japanese)
diff --git a/website/src/docs/developers/platform-paper.md b/website/src/docs/developers/platform-paper.md
new file mode 100644
index 0000000..19437c1
--- /dev/null
+++ b/website/src/docs/developers/platform-paper.md
@@ -0,0 +1,189 @@
+---
+layout: doc
+---
+
+# platform-paper - Paper / Folia Plugin
+
+`platform-paper` is the plugin itself.
+
+It is the layer that bridges to the platform APIs — Bukkit / Paper / Folia, Adventure, Brigadier, Plugin Messaging — and delegates domain models, algorithms, and the protocol to [engine](/docs/developers/engine).
+
+The paper side acts as an adapter absorbing "the reality of Bukkit / Folia", connecting `engine`'s pure models to platform concerns (scheduler, threads, events).
+
+## Entry point and DI (Service Container)
+
+Features are assembled via manual DI, without an external DI framework. The key idea is **separating "the responsibility of construction" from "the responsibility of holding"**.
+
+- `LunaticChat` (`JavaPlugin` + `Listener`) — the plugin entry point
+- `ServiceInitializer` — handles service construction, initialization order, and shutdown
+- `ServiceContainer` — an immutable `data class` holding the constructed services
+- `PluginCoroutineScope` — `SupervisorJob` + `Dispatchers.Default`; used for non-blocking work such as `UpdateChecker`
+
+### Lifecycle
+
+The `onEnable` flow:
+
+1. `saveDefaultConfig()` → build `LunaticChatConfiguration` via `ConfigManager`
+2. Initialize `HttpClient(CIO)` and `PluginCoroutineScope`
+3. `ServiceInitializer.initialize()` → receive a `ServiceContainer`
+4. Move services into the public properties used by commands
+5. `schedulePeriodicTasks()` → `registerCommands()` → `registerEventListeners()`
+6. Start `UpdateChecker` if `checkForUpdates` is enabled
+
+`onDisable` runs `pluginScope.cancel()` → `serviceInitializer.shutdown()`, closing settings, caches, channels, logs, and the Velocity connection in order.
+
+### ServiceContainer and ServiceInitializer
+
+`ServiceContainer` holds always-available services (`languageManager` / `playerSettingsManager` / `directMessageHandler`) as non-null, and feature-gated ones (`channelManager` / `velocityConnectionManager`, etc.) as nullable fields (default null). The aim is to eliminate null-assertions (`!!`) from the codebase.
+
+`ServiceInitializer.initialize()` creates services in dependency order.
+
+1. `LanguageManager` (before commands; a prerequisite for all features)
+2. `PlayerSettingsManager` (always needed, e.g. for DM notifications)
+3. Japanese conversion (optional)
+4. Channel group — `ChannelManager` / `ChannelMembershipManager` / `ChannelMessageHandler` / `ChannelNotificationHandler`, plus `ChannelMessageLogger` when logging is enabled (optional)
+5. `DirectMessageHandler` (depends on settings, romaji, language)
+6. Velocity integration (optional)
+7. Cross-server chat (only when velocity is enabled, `crossServerGlobalChat` is on, and the velocity manager is non-null)
+
+### Feature Gating
+
+This `initialize()` is where feature toggling actually happens. Japanese conversion / Channel group / Velocity integration / Cross-server chat are **created only when their config flag is true, and are `null` otherwise**.
+
+```
+config flag
+ → ServiceInitializer creates a nullable service
+ → stored in a nullable field on ServiceContainer
+ → command / listener / SettingHandler registration branches on a null check
+```
+
+A disabled feature's service simply does not exist at the type level, and its code path is never built. The presence of a feature is expressed through Kotlin's null-safety.
+
+For the design rationale, see the [Design Overview](/docs/developers/architecture#service-container-pattern-feature-gating).
+
+## Command framework (annotation-driven + Brigadier)
+
+A command's definition and its metadata (permission, aliases, player-only) are declared together in one place, then **read via Kotlin reflection and mapped onto the Brigadier tree**.
+
+### Annotations
+
+- `@Command(name, aliases, description)` — command name, aliases, description
+- `@Permission(KClass<out LunaticChatPermissionNode>)` — required permission (specified by type via the engine's permission node)
+- `@PlayerOnly` — a player-only marker
+
+### LunaticCommand
+
+The abstract base for all commands. It lazily reads the annotations on the class, and `buildWithChecks()` wraps the subclass's `buildCommand()` to inject shared behavior.
+
+- If `@Deprecated` is present, it swaps in a handler that returns an error message at runtime
+- If `@Permission` is present, it attaches Brigadier's `.requires { source.sender.hasPermission(perm) }`
+- `handleResult()` converts the engine's `CommandResult` into an Adventure message plus the `Int` from `toBrigadierResult()`
+- `withAliases()` clones a Brigadier node to create alias nodes, and `applyMethodPermission()` reflects a **method-level** `@Permission`
+
+### CommandRegistry
+
+`register` / `registerAll` accumulate commands, and `initialize()` registers a handler on Paper's `LifecycleEvents.COMMANDS`. The actual Brigadier tree construction (`buildWithChecks().build()`) happens inside that lifecycle event.
+
+### Convention: root and nested subcommands
+
+- **Root command** — annotate the class with `@Command`
+- **Nested subcommand** — no `@Command`; apply permission via a `build()` method plus a method-level `@Permission` and `applyMethodPermission("build", …)`
+
+### Command hierarchy
+
+| Command | Aliases | Registration condition |
+|---------|---------|------------------------|
+| `lc` (→ settings / status / channel) | `lunaticchat` | Always |
+| `channel` (14 subcommands) | `ch` | When channelChat is enabled |
+| `tell` | `t` / `msg` / `m` / `w` / `whisper` | Always |
+| `reply` | `r` | When quickReplies is enabled |
+| `lcv` (→ status) | `lunaticvelocity` | When velocity is enabled |
+
+`settings` iterates `SettingKey.values()` to dynamically generate on/off/status nodes for each key and delegates to `SettingHandlerRegistry`. Adding a setting is a three-step process: "add a SettingKey → implement a Handler → register it in the Registry".
+
+## Chat processing
+
+### Routing (PlayerChatListener)
+
+This is where routing happens, deciding **"local (channel) vs. global (possibly via the proxy)"**. It hooks `AsyncChatEvent` at `EventPriority.HIGHEST, ignoreCancelled = true`.
+
+Flow:
+
+1. Serialize the message to plain text and check for a leading `!` (the force-global prefix)
+2. If it is `!` with an empty body, cancel the event and return (don't emit an empty message)
+3. If the sender has romaji conversion enabled, run it through `convertWithRomaji`
+4. Determine whether the player has an active channel via `channelManager.getPlayerChannel()`
+
+Branches:
+
+- **Active channel and no `!`** → `event.isCancelled = true` + `viewers().clear()` + `message(empty)` to stop normal chat, then route to `ChannelMessageHandler.sendChannelMessage()` (local to the server)
+- **Otherwise** (no active channel, or a `!` prefix) → `handleGlobalChat()`. If velocity cross-server is enabled, send to `CrossServerChatManager.sendGlobalMessage()` while also displaying normal chat; otherwise, normal chat only
+
+### Direct messages (DirectMessageHandler)
+
+Manages `/tell`・`/reply` state. Two `ConcurrentHashMap`s, `lastMessager` / `lastRecipient`, track reply targets, and `getReplyTarget()` returns an online player in the order "whoever messaged me → whoever I messaged".
+
+`sendDirectMessage()` applies romaji conversion per the sender's settings → delivers a hover-annotated copy to spy players (excluding sender and recipient) → sends the formatted message to sender and recipient plus a notification sound (settings-dependent). The message carries a `ClickEvent.suggestCommand` that fills in `/tell <sender>`.
+
+### Channel chat (ChannelMessageHandler)
+
+`sendChannelMessage()` resolves the active channel via `channelManager.getPlayerChannelContext()` (doing nothing if absent), then delivers to spies (excluding the sender and members) → delivers to all channel members plus a receiver notification sound → writes an NDJSON log via the engine's `ChannelMessageLogEntry.create()` when logging is enabled.
+
+Channel state itself is managed by the `chat/channel` package.
+
+- `ChannelManager` — the single source of truth for channels. It holds state in `channelsCache` / `membersCache` / `activeChannels` (`ConcurrentHashMap`), and its CRUD returns `kotlin.Result`, wrapping engine exceptions on failure. It checks config limits (0 = unlimited)
+- `ChannelMembershipManager` — the business logic for join/leave/switch/role. `joinChannel()` checks existence / already-active / BAN / private-invite / already-a-member / membership limit in order
+- `ChannelStorage` — persists `ChannelData` as JSON (`channels.json`)
+- `ChannelMessageLogger` — an asynchronous NDJSON logger with daily rotation, a size cap, and periodic deletion of files past the retention period
+
+## Listener registration
+
+- `EventListenerRegistry` (`object`) — `SpyPermissionManager` and `PlayerPresenceListener` are always registered; `PlayerChatListener` is registered only when channel / velocity cross-server / romaji is enabled (Feature Gating again)
+- `PlayerPresenceListener` — on Join: update notification, nightly warning, active-channel restoration notice; on Quit: clear DM references, deactivate the active channel, and save settings
+- `SpyPermissionManager` (`object : Listener`) — caches holders of the `Spy` permission on join/quit; referenced by the DM and channel handlers
+
+## config
+
+- `ConfigManager` — reads the main `config.yml` from **Bukkit's `FileConfiguration`** by dotted keys and hand-assembles `LunaticChatConfiguration` (note: this path is not KAML)
+- Feature defaults: `quickReplies=true`, `japaneseConversion=false`, `channelChat=false`, `velocityIntegration=false`
+- Under `config/key`: `FeaturesConfig` / `ChannelChatFeatureConfig` / `JapaneseConversionFeatureConfig` / `VelocityIntegrationConfig` / `QuickRepliesFeatureConfig` / `MessageFormatConfig` / `ChannelMessageLoggingConfig`
+
+::: warning Implementation note
+`ChannelChatFeatureConfig.messageLogging` is not loaded by `ConfigManager` and stays at its default values (enabled=true, retention=30, 100MB). Whether this is intentional needs confirmation — decide whether to fix it or document it as intended behavior.
+:::
+
+## i18n
+
+- `Language` (enum) — `EN` / `JA`; unknown codes fall back to EN
+- `LanguageManager` — loads `resources/languages/` with KAML at startup and flattens the nested YAML into dotted keys (`toggle.on`, etc.). `getMessage(key, placeholders)` resolves with selected-language → EN fallback and substitutes `{placeholder}`, returning the key itself if not found. A missing EN is a fatal error
+- `MessageFormatter` (`object`) — produces an Adventure `Component` with a `[LC]` prefix and highlights `{braces}` placeholders detected by regex
+
+## converter (paper side) — engine integration
+
+The paper side handles the platform concerns of "cache management, timeouts, Bukkit scheduling", and delegates the conversion algorithm and API calls to `engine`.
+
+- `RomanjiConverter` — the two-stage conversion orchestrator. Per word: cache lookup → engine `KanaConverter` for romaji→hiragana → engine `GoogleIMEClient` for hiragana→kanji. Falls back to hiragana on API failure
+- `ConversionCache` — persists engine `CacheData` as JSON. In-memory cache plus debounced save (a FIXME notes that eviction on `maxEntries` overflow is effectively random due to `ConcurrentHashMap` ordering)
+- `RomajiConversionHelper` — `convertWithRomaji()`. Calls synchronously via `runBlocking` + `withTimeoutOrNull` (default 1000ms), returning `"original §e(converted)"` on success and the original text on failure/timeout
+
+## Velocity integration (Paper side)
+
+Using the engine's protocol, it communicates with the proxy over Bukkit's Plugin Messaging Channel (`lunaticchat:main`). The actual cross-server routing is handled by the Velocity side; paper is responsible for "sending, receiving, deduplication, and formatted display".
+
+- `VelocityConnectionManager` (`PluginMessageListener`) — manages `ConnectionState` (DISCONNECTED / HANDSHAKING / CONNECTED / FAILED). It encodes and sends the engine's `PluginMessage.Handshake`, timing out after 5 seconds. To avoid a circular dependency, `CrossServerChatManager` is injected afterward (setter injection)
+- The handshake runs **only once, triggered by the first player join** (`AtomicBoolean`). It is scheduled 1 second after the join via `asyncScheduler`, and the result is received as `HandshakeResult.Success` / `Error`
+- `CrossServerChatManager` — the send/receive and **deduplication** of global chat. On send, it registers the generated `messageId` in the cache immediately to prevent an echo on its own server (stage one); on receive, it prevents duplicate display with a dedup cache keyed by `messageId` (TTL 60s, oldest-first cleanup when over `cacheSize`). Bukkit API calls are moved to the main thread via `scheduler.runTask`
+
+## settings / common
+
+- `PlayerSettingsManager` — manages three boolean settings in `ConcurrentHashMap`s. Uses the engine DTOs; unset values default to true
+- `YamlPlayerSettingsStorage` — reads/writes `player-settings.yaml` with KAML. Recovers from a backup on load failure; debounced save (5s)
+- `UpdateChecker` — hits the GitHub Releases API via Ktor and compares semver. The result is a sealed `UpdateCheckResult`
+- `SoundCollector` — Adventure `Sound` constants for notifications plus Player extension functions
+- `PermissionCollector` — a DSL that collects permissions via `@PermissionDsl` + the `+LunaticChatPermissionNode` operator. `requirePermission` throws the engine's `RequirePermissionException`
+
+## Related
+
+- [Design Overview](/docs/developers/architecture)
+- [engine - Shared Kernel](/docs/developers/engine)
+- [platform-velocity - Velocity Plugin](/docs/developers/platform-velocity)
diff --git a/website/src/docs/developers/platform-velocity.md b/website/src/docs/developers/platform-velocity.md
new file mode 100644
index 0000000..643fa6b
--- /dev/null
+++ b/website/src/docs/developers/platform-velocity.md
@@ -0,0 +1,92 @@
+---
+layout: doc
+---
+
+# platform-velocity - Velocity Plugin (Proxy Relay)
+
+`platform-velocity` is a thin layer whose only job is to **relay** cross-server global chat.
+
+Its substance is just `LunaticChat` / `BuildInfo` / two files under `messaging/`; it has no command classes. Note that while `/lcv` is a Velocity-related feature, the command implementation lives on the [platform-paper](/docs/developers/platform-paper) side.
+
+::: tip Why the Velocity side is thin
+The protocol definition is held by `engine`, and all chat state (channels, DMs, settings) lives on the Paper side. The only responsibility left to Velocity is "distribute a received global chat message to the other servers", so this layer is intentionally kept thin. Neither platform owns the protocol; both depend on `engine` as equal peers (see the [Design Overview](/docs/developers/architecture#why-extract-the-engine-module)).
+:::
+
+## Lifecycle
+
+- `LunaticChat` (`@Plugin`) — receives `ProxyServer` / `Logger` / `PluginContainer` via a Guice `@Inject` constructor
+- The `version` in the `@Plugin` annotation is fixed at `"0.0.0"` and **is not used at runtime**. The real version is obtained from `velocity-plugin.json` via `PluginContainer.description.version` (startup fails if it is missing)
+- `@Subscribe onProxyInitialization` creates `CrossServerChatRelay`, then creates and `initialize()`s `PluginMessageHandler` with it injected
+- `@Subscribe onProxyShutdown` calls `messageHandler.shutdown()`
+
+## Message reception and dispatch
+
+`PluginMessageHandler` handles reception on the `lunaticchat:main` channel. In `initialize()` it calls `channelRegistrar.register(CHANNEL)` and subscribes to events.
+
+`@Subscribe onPluginMessage` processing:
+
+1. Ignore if `event.identifier != CHANNEL`
+2. Warn and discard if the source is not a `ServerConnection`
+3. Branch on the result of `PluginMessageCodec.decode()` with `when`
+4. `Handshake` → check compatibility and reply with `HandshakeResponse` / `StatusRequest` → reply with `StatusResponse` / `GlobalChatMessage` → delegate to the relay / otherwise (a Velocity-originated response type) → warn only
+
+### Trust boundary: rejecting client-originated messages
+
+The check for whether the source is a `ServerConnection` is not just a type guard — it is a **trust boundary**.
+
+Velocity plugin messages can arrive not only from backend servers but also from clients. By rejecting anything other than a backend connection here, **it prevents clients from directly injecting global chat or forged handshakes**. Only messages from trusted server connections are relayed.
+
+## Handshake handling
+
+On receiving a `Handshake`, it judges compatibility via the engine's `ProtocolVersion.isCompatible(major, minor)`.
+
+- **Compatible** — reply with `HandshakeResponse` where `compatible=true`
+- **Incompatible** — reply with `compatible=false` and an error string carrying both the Paper-side and Velocity-side versions
+
+`HandshakeResponse` / `StatusResponse` always carry Velocity's own `ProtocolVersion` (`MAJOR` / `MINOR` / `PATCH`), so the Paper side can learn the peer's protocol from the response.
+
+## Cross-server relay
+
+`CrossServerChatRelay.relayGlobalMessage(message, sourceServer)` is the heart of the relay.
+
+```
+server.allServers
+ .filter { it != sourceServer } // exclude the source
+ .forEach { it.sendPluginMessage(CHANNEL, encoded) }
+```
+
+It **excludes the source server** and broadcasts to all remaining backends (stage one of echo prevention). The relay count is logged.
+
+### What gets relayed / what stays local
+
+Keeping the relay scope minimal is a key design point.
+
+- The only thing Velocity relays to other servers is the **`GlobalChatMessage`**
+- `Handshake` / `HandshakeResponse` / `StatusRequest` / `StatusResponse` complete between Velocity and a single Paper, and are not forwarded
+- **DM and channel chat are never sent to Velocity at all** (they complete locally within Paper)
+
+### Two-stage echo/loop prevention
+
+To keep global chat from being displayed multiple times through relay loops, it is prevented in two places.
+
+1. **Velocity side** — broadcast excluding the source server
+2. **Paper side** — a dedup LRU cache keyed by `messageId` (TTL 60s). The sender also registers its own `messageId` right after generation to prevent an echo on its own server
+
+## Message flow
+
+1. Paper sends a `Handshake` (its own protocol version), triggered by a player connecting
+2. Velocity judges with `ProtocolVersion.isCompatible` and replies with `HandshakeResponse` → if compatible, the Paper side becomes `CONNECTED`
+3. A player sends global chat (no active channel, or a `!` prefix) → Paper sends a `GlobalChatMessage` (a new `messageId`) to Velocity and displays normal chat on the source
+4. Velocity relays to all backends except the source
+5. Each Paper receives it → dedups by `messageId` → formats with `crossServerGlobalChatFormat` and delivers to all players
+
+## Implementation notes
+
+- The `plugin` parameter of `PluginMessageHandler` is typed `Any` because Velocity's `EventManager.register()` takes an `Object` (the API itself isn't type-safe, so making it generic offers little benefit).
+- To run cross-server chat, Velocity's `velocity.toml` needs `bungee-plugin-message-channel=true` (plugin messaging enabled).
+
+## Related
+
+- [Design Overview](/docs/developers/architecture)
+- [engine - Shared Kernel](/docs/developers/engine) — protocol details
+- [platform-paper - Paper / Folia Plugin](/docs/developers/platform-paper) — the Paper-side counterpart
diff --git a/website/src/docs/developers/resource.md b/website/src/docs/developers/resource.md
new file mode 100644
index 0000000..2859480
--- /dev/null
+++ b/website/src/docs/developers/resource.md
@@ -0,0 +1,60 @@
+---
+layout: doc
+---
+
+# Build, Release & Versioning
+
+A Gradle multi-module setup shares `engine` while building and releasing Paper / Velocity as independent artifacts.
+
+## Build configuration
+
+- Root `build.gradle.kts` — manages Kotlin 2.4.0 + serialization / Shadow / ktlint / dokka. The JVM target is **JVM_25**. Tests use JUnit Platform + jacoco, with common test dependencies injected into all modules
+- `engine` — exposes core libraries (serialization / coroutines / ktor) via `api()` to propagate them to the platforms. Adventure is `compileOnly`. A pure library with no Shadow
+- `platform-paper` — `version = paperVersion`. `api(project(":engine"))`. paper-api as `compileOnly`, KAML + kotlin-reflect as `implementation`. Output is **`LunaticChat-<ver>.jar`** (no classifier; `jar` disabled)
+- `platform-velocity` — `version = velocityVersion`. `api(project(":engine"))`. velocity-api as `compileOnly` + `annotationProcessor` (for `@Plugin` processing). Output is **`LunaticChat-<ver>-velocity.jar`** (distinguished by classifier)
+- `dokka` — aggregates engine/paper/velocity and includes the README in the HTML
+
+Both platforms' `processResources` compute `version` / `gitCommitHash` / `channel` from the git short hash and `isNightly`, and token-expand them into `paper-plugin.yml` / `velocity-plugin.json` and `build-info.properties`.
+
+## Independent versioning
+
+```properties
+# gradle.properties
+paperVersion=1.2.2
+velocityVersion=1.1.0
+```
+
+Paper and Velocity carry separate version numbers and can be released independently. That's because **compatibility is guaranteed by the engine-shared [`ProtocolVersion`](/docs/developers/engine#versioning-strategy-protocolversion) rather than the numeric version**, so the two platforms — which change at different rates — can be bumped and published at their own pace. The wire format is forward-compatible via JSON + `ignoreUnknownKeys`, and backward compatibility is controlled by matching MAJOR + a MINOR-range check on the protocol.
+
+## Release workflows
+
+The release target switches based on the tag pattern.
+
+| Workflow | Trigger tag | Build target | Version validation |
+|----------|-------------|--------------|--------------------|
+| `release.yaml` | `v*` | Both Paper + Velocity | extract both versions from gradle.properties |
+| `release-paper.yaml` | `paper/v*` | Paper only | requires the tag to match `paperVersion` |
+| `release-velocity.yaml` | `velocity/v*` | Velocity only | requires the tag to match `velocityVersion` |
+
+- Common flow: `validate` (check for a duplicate existing release) → `build` (mise + Gradle setup, `shadowJar`) → `release` (`gh release create --draft` + publish to Modrinth)
+- The per-platform workflows (paper / velocity) differ from `release.yaml` in requiring a strict match between the tag and `gradle.properties`
+- Modrinth game-versions are Paper=`26.1.x` (loaders: paper, folia) and Velocity=`1.21.x` + `26.1.x` (loader: velocity)
+
+## CI
+
+`ci.yaml` runs on push to main / PR / manual dispatch.
+
+- `build_plugin` — ktlintCheck → test + jacocoTestReport → upload to Codecov → nightly shadowJar (`-PisNightly=true`) → retain artifacts
+- `build_dokka` / `deploy_dokka` — generate Dokka → deploy to GitHub Pages (main push only)
+- `build_docs` — format/lint/build `website/` with bun → deploy to Cloudflare Workers (wrangler) (main push only)
+
+## Development environment
+
+- `mise.toml` — bun / java zulu-25 (consistent with `JVM_25`)
+- `x` — a bash debug-server script. `./x <action> <platform> [--stable]` for start/stop/log/clean/rcon/help. Without `--stable` it builds nightly. With `velocity` it brings up **1 Velocity + 2 Paper** so you can test cross-server chat relay for real
+- `docker/` — `compose.yaml` for three environments: paper / velocity / folia (`itzg/minecraft-server:java25`, etc.). velocity.toml enables plugin messaging with `bungee-plugin-message-channel=true`
+
+## Related
+
+- [Design Overview](/docs/developers/architecture)
+- [Introduction](/docs/developers/introduction)
diff --git a/website/src/docs/features/direct-message.md b/website/src/docs/features/direct-message.md
index 6aa9f42..07f6cdb 100644
--- a/website/src/docs/features/direct-message.md
+++ b/website/src/docs/features/direct-message.md
@@ -30,6 +30,18 @@ Replies to the last player who sent you a message. If there is no such player, t
To use quick reply, `features.quickReplies.enabled` must be `true` (default) in `config.yml`.
+## Cross-Server Direct Messages <Badge type="tip" text="v1.3.0~" />
+
+> [!NOTE]
+>
+> To use this feature, set `features.velocityIntegration.crossServerDirectMessage` to `true` in `config.yml`.
+
+To message a player on another server, specify the player argument as `playerName@serverName`.
+
+```
+/tell <player>@<server> <message>
+```
+
## Notification Settings
Players can individually control the sound notification when receiving direct messages.
diff --git a/website/src/docs/features/velocity.md b/website/src/docs/features/velocity.md
index f43fd65..8bc1b70 100644
--- a/website/src/docs/features/velocity.md
+++ b/website/src/docs/features/velocity.md
@@ -51,6 +51,10 @@ When `crossServerGlobalChat` is set to `true`, player chat messages are relayed
Each message is assigned a unique ID, and a cache prevents the same message from being displayed more than once. The cache size can be configured with `messageDeduplicationCacheSize` (default: `100`).
+## Cross-Server Direct Messages <Badge type="tip" text="v1.3.0~" />
+
+Setting `crossServerDirectMessage` to `true` lets players exchange direct messages with players on other servers connected to the same proxy.
+
## Connection States
The states reported by `/lcv status` and their meanings:
diff --git a/website/src/docs/reference/commands.md b/website/src/docs/reference/commands.md
index 0f76838..a039f7a 100644
--- a/website/src/docs/reference/commands.md
+++ b/website/src/docs/reference/commands.md
@@ -8,10 +8,12 @@ A reference for all commands available in LunaticChat.
## Direct Messages
-### `/tell <player> <message>`
+### `/tell <player> <message>` / `/tell <player>@<server> <message>`
Sends a direct message to a player.
+When a server name is specified, the message is sent to the player on that server.
+
- **Aliases**: `t`, `msg`, `m`, `w`, `whisper`
- **Permission**: `lunaticchat.command.tell`