summaryrefslogtreecommitdiff
path: root/website
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
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')
-rw-r--r--website/.vitepress/config/en.ts31
-rw-r--r--website/.vitepress/config/ja.ts31
-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
-rw-r--r--website/src/ja/docs/developers/architecture.md87
-rw-r--r--website/src/ja/docs/developers/engine.md97
-rw-r--r--website/src/ja/docs/developers/introduction.md37
-rw-r--r--website/src/ja/docs/developers/platform-paper.md189
-rw-r--r--website/src/ja/docs/developers/platform-velocity.md92
-rw-r--r--website/src/ja/docs/developers/resource.md60
18 files changed, 1202 insertions, 1 deletions
diff --git a/website/.vitepress/config/en.ts b/website/.vitepress/config/en.ts
index f9929fd..26076c9 100644
--- a/website/.vitepress/config/en.ts
+++ b/website/.vitepress/config/en.ts
@@ -73,6 +73,37 @@ export const en: DefaultTheme.Config = {
},
],
},
+ {
+ text: 'Developer Guide',
+ items: [
+ {
+ link: '/docs/developers/introduction',
+ text: 'Introduction',
+ },
+ {
+ text: 'Design / Architecture',
+ link: '/docs/developers/architecture',
+ items: [
+ {
+ link: '/docs/developers/engine',
+ text: 'engine - Shared Kernel',
+ },
+ {
+ link: '/docs/developers/platform-paper',
+ text: 'platform-paper - Paper / Folia Plugin',
+ },
+ {
+ link: '/docs/developers/platform-velocity',
+ text: 'platform-velocity - Velocity Plugin (Proxy Relay)',
+ },
+ ],
+ },
+ {
+ link: '/docs/developers/resource',
+ text: 'Build, Release & Versioning',
+ },
+ ],
+ },
],
},
};
diff --git a/website/.vitepress/config/ja.ts b/website/.vitepress/config/ja.ts
index 12fd9a1..1f8f4c0 100644
--- a/website/.vitepress/config/ja.ts
+++ b/website/.vitepress/config/ja.ts
@@ -73,6 +73,37 @@ export const ja: DefaultTheme.Config = {
},
],
},
+ {
+ text: '開発者向けガイド',
+ items: [
+ {
+ link: '/ja/docs/developers/introduction',
+ text: 'はじめに',
+ },
+ {
+ text: '設計/アーキテクチャ',
+ link: '/ja/docs/developers/architecture',
+ items: [
+ {
+ link: '/ja/docs/developers/engine',
+ text: 'engine - 共通カーネル',
+ },
+ {
+ link: '/ja/docs/developers/platform-paper',
+ text: 'platform-paper - Paper / Folia プラグイン本体',
+ },
+ {
+ link: '/ja/docs/developers/platform-velocity',
+ text: 'platform-velocity - Velocity プラグイン本体 (プロキシ中継)',
+ },
+ ],
+ },
+ {
+ link: '/ja/docs/developers/resource',
+ text: 'ビルド・リリース・バージョニング',
+ },
+ ],
+ },
],
},
};
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`
diff --git a/website/src/ja/docs/developers/architecture.md b/website/src/ja/docs/developers/architecture.md
new file mode 100644
index 0000000..cd3965a
--- /dev/null
+++ b/website/src/ja/docs/developers/architecture.md
@@ -0,0 +1,87 @@
+---
+layout: doc
+---
+
+# 設計概要
+
+LunaticChat は Paper/Folia におけるDM・チャンネルチャット・ローマ字変換に加え,Velocity プロキシ配下での**サーバー間グローバルチャット中継**を提供します.
+
+このページはアーキテクチャの全体像と,モジュールをまたぐ横断的な設計判断をまとめます.各モジュールの詳細は配下のページを参照してください.
+
+## モジュール構成
+
+Gradle マルチモジュール構成で,共有カーネルとプラットフォーム実装を分離しています.
+
+依存の向きは一方向で,`platform-paper` と `platform-velocity` の両方が `engine` に依存し,`engine` は下流に何も依存しません.どちらのプラットフォームも protocol を「所有」せず,中立な `engine` に対して対等な peer として依存します.
+
+| モジュール | 役割 |
+|-----------|------|
+| `engine` | プラットフォーム非依存のコア (ドメインモデル・プロトコル・変換・例外・権限) |
+| `platform-paper` | Paper / Folia プラグイン本体 |
+| `platform-velocity` | Velocity プロキシプラグイン (サーバー間チャット中継) |
+| `dokka` | API ドキュメント集約専用 (Kotlin ソースなし) |
+
+### なぜ engine を切り出すのか
+
+engine は**共有カーネル (Shared Kernel)** です.中身は 2 系統に分かれます.
+
+#### (a) 両者が合意しなければならない契約
+
+Paper と Velocity が同一定義でないと壊れるもの.
+
+- `protocol` — 両プロセス間の通信契約 (同一定義でないと通信不能)
+- `chat` / `channel`, `settings` — 永続化スキーマ (`@Serializable`)
+- `exception` — ドメインエラーの共通語彙
+- `permission`, `command` — 権限ノード文字列とコマンド結果の中立抽象
+
+#### (b) プラットフォーム非依存の純ロジック
+
+どこに置いてもよいが,純粋な再利用可能のロジックを中立コアに寄せたもの.
+
+- `converter` — ローマ字変換の純アルゴリズム (Trie) +外部 API クライアント
+
+(a) を engine に一元化する最大の狙いは,**ワイヤ契約の「単一の真実源」を作ること**です.Paper と Velocity は別々にビルド・デプロイ・バージョニングされる 2 つの成果物であり,protocol を両モジュールに複製すれば必ず drift します.engine に 1 つだけ置けば,契約の不一致が「本番での実行時ミスマッチ」ではなく「コンパイルエラー / スナップショットテスト失敗」として早期に顕在化します.
+
+engine は Bukkit / Velocity API に依存せず,Adventure / Brigadier も「型・値の意味」だけを借りて本体依存を避けています (`compileOnly` の Adventure,Brigadier に依存せず `Int` を返す `toBrigadierResult()`).これにより engine は Minecraft サーバーを立てずに pure-JVM でテストでき,プラットフォーム都合 (Folia のスケジューラ等) は platform 側に隔離されます.
+
+## プロトコルバージョンによる互換管理
+
+Paper–Velocity 間の互換性は,プラグインのバージョンではなく engine が持つ **`ProtocolVersion`** だけで決まります.これが LunaticChat のマルチプラットフォーム設計の要です.
+
+- 互換判定は MAJOR 完全一致 & リモート MINOR ∈ `[MIN_SUPPORTED_MINOR, MINOR]` となり, PATCH は無視
+- 後方互換は JSON の `ignoreUnknownKeys` + デフォルト値付きフィールド
+- `ProtocolBackwardCompatibilityTest` が JSON スナップショットで後方互換を機械的に検証
+
+互換をプラグインバージョンから切り離した帰結として,**Paper と Velocity を独立にバージョニングでき,更新頻度の異なる 2 プラットフォームをそれぞれのペースでリリースできます**.さらにプロトコルのバンプレベルごとに更新順序が定義され (PATCH=任意 / MINOR=Velocity 先 / MAJOR=同時),これがローリングアップデートを可能にします.
+
+詳細は [engine - 共通カーネル](/ja/docs/developers/engine) を参照してください.
+
+## Service Container パターン + Feature Gating
+
+`platform-paper` は外部 DI フレームワークを使わず,手動 DI で機能を組み立てます.
+
+- `ServiceInitializer` が構築・初期化順序・shutdown を担当
+- `ServiceContainer` (イミュータブルな data class) がサービスを保持
+- **無効な機能はサービスが `null`** になり,型で機能の有無が表現される
+- コマンド・リスナー・SettingHandler の登録が `null` 判定で条件分岐する
+
+要は「config フラグ → `ServiceInitializer` が nullable なサービスを生成 → `ServiceContainer` の nullable フィールド → 登録処理が `null` 判定で分岐」という一本の流れです.機能が無効なら対応するサービスが型のうえで「存在しない」ことになり,そのコードパスは最初から構築されません.外部フレームワークを持ち込まず,機能トグルとライフサイクル管理を Kotlin の型と null 許容性だけで表現しているのが特徴です.
+
+詳細は [platform-paper - Paper / Folia プラグイン本体](/ja/docs/developers/platform-paper) を参照してください.
+
+## 横断的な設計特徴
+
+1. **engine / platform の関心分離** — platform はプラットフォーム API との橋渡しに徹し,ドメインモデル・アルゴリズム・プロトコルは engine が持ちます.platform 側は「Bukkit / Velocity という現実」を吸収するアダプタ層です.
+2. **プロトコルバージョンによる互換管理** — 互換は `ProtocolVersion` だけで決まります.
+3. **Service Container + Feature Gating** — 機能の有無を型で表現します.
+4. **アノテーション駆動コマンド** — `@Command` / `@Permission` / `@PlayerOnly` を Kotlin リフレクションで読み Brigadier ツリーへマッピングします.コマンドの定義とメタデータ (権限・エイリアス) が同じ場所に宣言的に並びます.
+5. **Folia 互換性** — 非同期処理は `asyncScheduler` と `PluginCoroutineScope` (SupervisorJob) で行い,Bukkit API 呼び出しは `scheduler.runTask` でメインスレッドへ戻します.リージョンスレッド化された Folia でも壊れないよう,スレッド境界を明示的に扱います.
+6. **永続化の使い分け** — 言語/プレイヤー設定=KAML(YAML),チャンネル/変換キャッシュ=kotlinx.serialization JSON,チャンネルログ=NDJSON.いずれも「メモリキャッシュ+非同期保存 (デバウンス/キュー)+shutdown 同期保存」の共通パターンに従います.
+7. **DM/チャンネル=ローカル,グローバル=プロキシ経由** — チャットの種類でルーティングが分かれ,グローバルチャットだけが Velocity を経由します.中継は「送信元サーバー除外」+「messageId による重複排除」の二段でループを防ぎます.
+
+## 各モジュールの詳細についてはこちら
+
+- [engine - 共通カーネル](/ja/docs/developers/engine)
+- [platform-paper - Paper / Folia プラグイン本体](/ja/docs/developers/platform-paper)
+- [platform-velocity - Velocity プラグイン本体 (プロキシ中継)](/ja/docs/developers/platform-velocity)
+- [ビルド・リリース・バージョニング](/ja/docs/developers/resource)
diff --git a/website/src/ja/docs/developers/engine.md b/website/src/ja/docs/developers/engine.md
new file mode 100644
index 0000000..fb9ee9e
--- /dev/null
+++ b/website/src/ja/docs/developers/engine.md
@@ -0,0 +1,97 @@
+---
+layout: doc
+---
+
+# engine - 共通カーネル
+
+`engine` はプラットフォーム非依存のコアモジュールです.
+
+Paper と Velocity が共有する契約 (protocol・スキーマ・語彙) と,プラットフォームに依存しない純ロジック (変換アルゴリズム) を集約した**共有カーネル**として位置づけられます.
+
+Bukkit / Velocity API に依存せず,Adventure / Brigadier も「型・値の意味」だけを借りるにとどめ,本体依存を持ちません.そのため Minecraft サーバーを立てずに pure-JVM でテストできます.
+
+engine を切り出す意図の全体像は [設計概要](/ja/docs/developers/architecture#なぜ-engine-を切り出すのか) を参照してください.
+
+## protocol — Paper ↔ Velocity 通信
+
+Paper と Velocity は別プロセスの成果物であり,プラグインメッセージで通信します.その**ワイヤ契約を両者が同一定義で共有するため**に protocol は engine に置かれています.片方だけが定義を変えれば通信は壊れるので,唯一の定義を engine に持たせ,不一致をコンパイル時・テスト時に検出できるようにしています.
+
+`sealed interface PluginMessage` を頂点とする 5 種類のメッセージがあります.
+
+| 種別 | 方向 | 主なフィールド |
+|------|------|---------------|
+| `Handshake` | Paper→Velocity | `pluginVersion`, `protocol` 各要素 |
+| `HandshakeResponse` | Velocity→Paper | `compatible`, `velocityVersion`, `error?`, `protocol` 各要素 |
+| `StatusRequest` | Paper→Velocity | (フィールドなし) |
+| `StatusResponse` | Velocity→Paper | `velocityVersion`, `protocolVersion`, `online` |
+| `GlobalChatMessage` | Paper↔Velocity↔Paper | `messageId`, `serverName`, `playerId`, `playerName`, `message`, `timestamp` |
+
+`GlobalChatMessage` の `messageId` は中継ループでの重複表示を防ぐための一意 ID です.また protocol 層では UUID を素の `String` として運びます (settings/channel 層の `UUID` 型+カスタムシリアライザとは対照的に,移送を単純化する狙い) .
+
+### ワイヤフォーマット
+
+- `[subChannel: UTF][messageJson: UTF]` — `DataOutputStream.writeUTF` で「サブチャネル名」「JSON 本文」の 2 つを書き出す,Minecraft のプラグインメッセージで扱いやすい `ByteArray` 形式
+- JSON は kotlinx-serialization.`Json { ignoreUnknownKeys = true }` で,新バージョンが増やした未知フィールドを旧バージョンが受け取っても壊れない (前方互換の土台)
+- サブチャネル: `handshake` / `handshake_response` / `status_request` / `status_response` / `global_chat`
+
+### バージョニング戦略 (`ProtocolVersion`)
+
+Paper–Velocity の互換性は,プラグインバージョンではなく `ProtocolVersion` だけで判定します.SemVer に沿って,変更の性質ごとにバンプするレベルとデプロイ順が決まります.
+
+| レベル | いつ上げる | デプロイ順 |
+|--------|-----------|-----------|
+| PATCH | デフォルト付き任意フィールド追加 / 無視可能な新サブチャネル | 任意 |
+| MINOR | 必須フィールド追加 / 欠けると機能低下するサブチャネル | Velocity → Paper |
+| MAJOR | フィールド/サブチャネルの削除・改名,ワイヤ形式変更 | 全同時 |
+
+互換判定は「**MAJOR 完全一致 & リモート MINOR ∈ `[MIN_SUPPORTED_MINOR, MINOR]`,PATCH は無視**」で行っています.これにより `MIN_SUPPORTED_MINOR` を引き上げることで,古い MINOR の受け入れを段階的に打ち切れます.新しいメッセージやフィールドを追加したときは `ProtocolBackwardCompatibilityTest` に JSON スナップショットを足し,旧フォーマットが読み続けられることを機械的に保証します.
+
+この設計の帰結として Paper と Velocity を独立にリリースできます.詳しくは [ビルド・リリース・バージョニング](/ja/docs/developers/resource#独立バージョニング) を参照してください.
+
+## converter — ローマ字→日本語変換
+
+converter は Paper↔Velocity の契約ではなく (Velocity はローマ字変換をしない),**プラットフォームに依存しない純ロジックだから** engine に置かれています.3 段構成です.
+
+- `KanaConverter` (`object`) — **Trie** でローマ字→ひらがなに変換.`sealed class TrieNode { Leaf, Branch }` の不変構造で,4 文字 (`xtsu`→っ) 〜1 文字 (`a`→あ) を網羅.`isValidRomaji()` で変換前検証,`toHiragana()` は最長一致+促音処理を行う純アルゴリズム
+- `GoogleIMEClient` — Ktor `HttpClient` を DI で受け取り,Google IME (`langpair=ja-Hira|ja`) でひらがな→漢字仮名交じりに変換.レスポンスの各セグメント第 1 候補を連結する
+- `CacheData` (`@Serializable`) — 変換結果 (`version` + `entries: Map`) の永続化スキーマ.コストの高い IME 変換をキャッシュするための器で,キャッシュ本体のロジックは paper 側にある
+
+## chat/channel — チャンネルのドメインモデル
+
+チャンネルの永続化スキーマは,保存を行う paper 側と,将来的な共有可能性を見据えて engine 側に `@Serializable` なモデルとして置かれています.
+
+- `Channel` — `init` でバリデーション (`id` は `^[a-zA-Z0-9_-]{3,30}$`,`name` は空白不可)
+- `ChannelData` — 永続化ルート.`version` フィールドでスキーマ進化に対応
+- `ChannelMember` / `ChannelRole` — メンバーとロール.ロールは `OWNER` / `MODERATOR` / `MEMBER` の 3 階層
+- `ChannelContext` — 非 Serializable な実行時集約 DTO (`channel` + `members` を操作に渡すビュー)
+- `ChannelMessageLogEntry` — NDJSON・日次ローテーション・Grafana Loki 互換を想定したログエントリ
+
+チャンネル数・メンバー数・所属数などの上限は,engine には**例外の「語彙」だけ**を置き,具体的な閾値は config (paper 側) が注入します.「上限があること」と「上限がいくつか」を分離する設計です.
+
+## settings — プレイヤー設定と UUID シリアライズ
+
+永続用と実行用でモデルを分けています.
+
+- `PlayerSettingsData` — YAML 永続化のルート.3 種の設定を UUID→Boolean のマップで保持
+- `PlayerChatSettings` — 1 プレイヤー単位のフラットモデル (全設定デフォルト true).全体マップから射影した実行時ビュー
+
+UUID シリアライザが 2 つあるのは用途が違うためです.
+
+`UUIDSerializer` (descriptor 名 `"UUID"`) は汎用で channel や `PlayerChatSettings.uuid` に,`UUIDASStringSerializer` (descriptor 名 `"UUIDAsString"`) は YAML 互換のため `PlayerSettingsData` の**マップキー**に使います.`kotlinx.serialization` が UUID を標準サポートしないため自前実装しています.
+
+## exception — 共通の例外語彙
+
+ドメインエラーを Paper / Velocity 双方で同じ型として扱えるよう,例外を engine に集約しています.共通の封印基底は持たず,`Exception` を直接継承するフラット構造 (23 種) です.存在/参照系・状態系・制限系・権限/BAN・KICK 系に分類でき,多くが `playerId` / `channelId` / `limit` をコンストラクタで受けてメッセージを自前生成します.基底を持たないため,呼び出し側は個別に catch する前提です.
+
+## permission / command — 中立抽象
+
+Bukkit / Velocity どちらの API にも渡せる中立表現として,権限とコマンド結果を engine に置いています.
+
+- `LunaticChatPermissionNode` — `sealed class` + `object` サブクラスで権限を型安全に列挙.文字列ノードは両プラットフォームの permission API に渡せ,`when` で網羅性チェックも効く
+- `CommandResult` — `sealed class` (`Success` / `SuccessWithMessage` / `Failure` / `InvalidUsage`).メッセージは Adventure `Component`,`toBrigadierResult()` は Brigadier 本体に依存せず成功=1/失敗=0 という「戻り値の意味」だけを表現する
+
+## 関連
+
+- [設計概要](/ja/docs/developers/architecture)
+- [platform-paper - Paper / Folia プラグイン本体](/ja/docs/developers/platform-paper)
+- [platform-velocity - Velocity プラグイン本体](/ja/docs/developers/platform-velocity)
diff --git a/website/src/ja/docs/developers/introduction.md b/website/src/ja/docs/developers/introduction.md
new file mode 100644
index 0000000..79782d1
--- /dev/null
+++ b/website/src/ja/docs/developers/introduction.md
@@ -0,0 +1,37 @@
+---
+layout: doc
+---
+
+# はじめに
+
+このガイドは LunaticChat の設計・アーキテクチャを解説する開発者ガイドになります.
+
+プレイヤー・サーバ管理者は [ドキュメント/リファレンス](/ja/docs/getting-started) を参照してください.
+
+::: tip 対象バージョン
+
+このガイドの設計・アーキテクチャは [Paper/Folia: v1.2.2](https://github.com/m1sk9/LunaticChat/releases/tag/paper%2Fv1.2.2), [Velocity: v1.1.0](https://github.com/m1sk9/LunaticChat/releases/tag/velocity%2Fv1.1.0) 時点で記述しています.
+
+:::
+
+## モジュール構成
+
+LunaticChat のモジュール構成は次の通りです.
+
+設計の全体像は [設計/アーキテクチャ](/ja/docs/developers/architecture) を参照してください.
+
+| モジュール | 役割 |
+|-----------|------|
+| `engine` | プラットフォーム非依存のコア |
+| `platform-paper` | Paper / Folia プラグイン本体 |
+| `platform-velocity` | Velocity プロキシプラグイン |
+
+## ガイド一覧
+
+- [設計/アーキテクチャ](/ja/docs/developers/architecture) — アーキテクチャの全体像
+ - [engine - 共通カーネル](/ja/docs/developers/engine)
+ - [platform-paper - Paper / Folia プラグイン本体](/ja/docs/developers/platform-paper)
+ - [platform-velocity - Velocity プラグイン本体 (プロキシ中継)](/ja/docs/developers/platform-velocity)
+- [ビルド・リリース・バージョニング](/ja/docs/developers/resource) — リリースフローとバージョニング
+
+- [LunaChat の後継 "LunaticChat" を開発した話 - m1sk9 (Zenn)](https://zenn.dev/m1sk9/articles/adb6c0a7fa7bd2) — Null安全やコルーチン活用、キャッシュシステム導入など (外部サイト)
diff --git a/website/src/ja/docs/developers/platform-paper.md b/website/src/ja/docs/developers/platform-paper.md
new file mode 100644
index 0000000..bbbeb0c
--- /dev/null
+++ b/website/src/ja/docs/developers/platform-paper.md
@@ -0,0 +1,189 @@
+---
+layout: doc
+---
+
+# platform-paper - Paper / Folia プラグイン本体
+
+`platform-paper` はプラグインの本体です.
+
+Bukkit / Paper / Folia API・Adventure・Brigadier・Plugin Messaging といったプラットフォーム API との橋渡しに徹する層で,ドメインモデルやアルゴリズム・プロトコルは [engine](/ja/docs/developers/engine) に委譲します.
+
+paper 側は「Bukkit / Folia という現実」を吸収するアダプタとして働き,engine の純粋なモデルをプラットフォームの都合 (スケジューラ・スレッド・イベント) に接続するのが役割です.
+
+## エントリポイントと DI (Service Container)
+
+外部 DI フレームワークを使わず,手動 DI でサービスを組み立てます.**「構築の責務」と「保持の責務」を分離**しているのが要点です.
+
+- `LunaticChat` (`JavaPlugin` + `Listener`) — プラグインのエントリポイント
+- `ServiceInitializer` — サービスの構築・初期化順序・shutdown を担当
+- `ServiceContainer` — 構築済みサービスを保持するイミュータブルな `data class`
+- `PluginCoroutineScope` — `SupervisorJob` + `Dispatchers.Default`.`UpdateChecker` などの非ブロッキング実行に使う
+
+### ライフサイクル
+
+`onEnable` の流れは次の通りです.
+
+1. `saveDefaultConfig()` → `ConfigManager` で `LunaticChatConfiguration` を生成
+2. `HttpClient(CIO)` と `PluginCoroutineScope` を初期化
+3. `ServiceInitializer.initialize()` → `ServiceContainer` を受け取る
+4. コマンドから使う公開プロパティへサービスを移し替え
+5. `schedulePeriodicTasks()` → `registerCommands()` → `registerEventListeners()`
+6. `checkForUpdates` が有効なら `UpdateChecker` を起動
+
+`onDisable` は `pluginScope.cancel()` → `serviceInitializer.shutdown()` の順で,設定・キャッシュ・チャンネル・ログ・Velocity 接続を順に閉じます.
+
+### ServiceContainer と ServiceInitializer
+
+`ServiceContainer` は,常時利用可能なサービス (`languageManager` / `playerSettingsManager` / `directMessageHandler`) を非 null,機能ゲート対象 (`channelManager` / `velocityConnectionManager` など) を nullable フィールド (デフォルト null) として保持します.null-assertion (`!!`) をコードから排除する狙いです.
+
+`ServiceInitializer.initialize()` は依存順にサービスを生成します.
+
+1. `LanguageManager` (コマンドより前,全機能の前提)
+2. `PlayerSettingsManager` (DM 通知などに常時必要)
+3. Japanese conversion (optional)
+4. Channel 群 — `ChannelManager` / `ChannelMembershipManager` / `ChannelMessageHandler` / `ChannelNotificationHandler`,ログ有効時は `ChannelMessageLogger` (optional)
+5. `DirectMessageHandler` (settings・romaji・language に依存)
+6. Velocity integration (optional)
+7. Cross-server chat (velocity 有効 かつ `crossServerGlobalChat` かつ velocity manager 非 null のときのみ)
+
+### Feature Gating
+
+機能トグルの実装本体はこの `initialize()` です.Japanese conversion / Channel 群 / Velocity integration / Cross-server chat は,**config フラグが true のときだけサービスを生成し,それ以外は `null`** にします.
+
+```
+config フラグ
+ → ServiceInitializer が nullable なサービスを生成
+ → ServiceContainer の nullable フィールドに格納
+ → コマンド・リスナー・SettingHandler の登録が null 判定で条件分岐
+```
+
+無効な機能はサービスが型のうえで「存在しない」ことになり,そのコードパスは最初から構築されません.機能の有無を Kotlin の null 許容性で表現しています.
+
+設計思想の全体像は [設計概要](/ja/docs/developers/architecture#service-container-パターン-feature-gating) を参照してください.
+
+## コマンドフレームワーク (アノテーション駆動 + Brigadier)
+
+コマンドの定義とメタデータ (権限・エイリアス・プレイヤー限定) を同じ場所に宣言的に並べ,**Kotlin リフレクションで読み取って Brigadier ツリーへマッピング**します.
+
+### アノテーション
+
+- `@Command(name, aliases, description)` — コマンド名・エイリアス・説明
+- `@Permission(KClass<out LunaticChatPermissionNode>)` — 必要権限 (engine の権限ノードを型で指定)
+- `@PlayerOnly` — プレイヤー専用マーカー
+
+### LunaticCommand
+
+全コマンドの抽象基底です.クラスに付いたアノテーションを lazy に読み取り,`buildWithChecks()` がサブクラスの `buildCommand()` を包んで共通処理を差し込みます.
+
+- `@Deprecated` が付いていれば,実行時にエラーメッセージを返すハンドラへ差し替える
+- `@Permission` があれば Brigadier の `.requires { source.sender.hasPermission(perm) }` を付与する
+- `handleResult()` が engine の `CommandResult` を Adventure メッセージ送信+`toBrigadierResult()` の `Int` へ変換する
+- `withAliases()` は Brigadier ノードを複製してエイリアスノードを生成,`applyMethodPermission()` は**メソッドレベル**の `@Permission` を反映する
+
+### CommandRegistry
+
+`register` / `registerAll` でコマンドを蓄積し,`initialize()` で Paper の `LifecycleEvents.COMMANDS` にハンドラを登録します.実際の Brigadier ツリー構築 (`buildWithChecks().build()`) はこのライフサイクルイベント内で行われます.
+
+### 規約: ルートとネストサブコマンド
+
+- **ルートコマンド** — クラスに `@Command` を付ける
+- **ネストサブコマンド** — `@Command` を付けず,`build()` メソッド+メソッドレベル `@Permission` + `applyMethodPermission("build", …)` で権限を適用する
+
+### コマンド階層
+
+| コマンド | エイリアス | 登録条件 |
+|---------|-----------|---------|
+| `lc` (→ settings / status / channel) | `lunaticchat` | 常時 |
+| `channel` (14 サブコマンド) | `ch` | channelChat 有効時 |
+| `tell` | `t` / `msg` / `m` / `w` / `whisper` | 常時 |
+| `reply` | `r` | quickReplies 有効時 |
+| `lcv` (→ status) | `lunaticvelocity` | velocity 有効時 |
+
+`settings` は `SettingKey.values()` を回して各キーに on/off/status ノードを動的生成し,`SettingHandlerRegistry` に委譲します.設定を増やすのは「SettingKey 追加 → Handler 実装 → Registry 登録」の 3 ステップです.
+
+## チャット処理
+
+### ルーティング (PlayerChatListener)
+
+チャットの振り分けはここが担い,**「ローカル (チャンネル) か,グローバル (プロキシ経由の可能性) か」** を決めます.`AsyncChatEvent` を `EventPriority.HIGHEST, ignoreCancelled = true` でフックします.
+
+処理の流れ:
+
+1. メッセージを plain text 化し,先頭の `!` (グローバル強制プレフィックス) を判定する
+2. `!` のみで本文が空なら,イベントをキャンセルして終了する (空メッセージを流さない)
+3. 送信者の設定でローマ字変換が有効なら `convertWithRomaji` を通す
+4. `channelManager.getPlayerChannel()` でアクティブチャンネルの有無を判定する
+
+分岐:
+
+- **アクティブチャンネルあり かつ `!` なし** → `event.isCancelled = true` + `viewers().clear()` + `message(empty)` で通常チャットを止め,`ChannelMessageHandler.sendChannelMessage()` に流す (サーバーローカル完結)
+- **それ以外** (チャンネル未所属 or `!` プレフィックス) → `handleGlobalChat()`.velocity cross-server が有効なら `CrossServerChatManager.sendGlobalMessage()` へ送りつつ通常チャットも表示,無効なら通常チャットのみ
+
+### ダイレクトメッセージ (DirectMessageHandler)
+
+`/tell`・`/reply` の状態を管理します.`lastMessager` / `lastRecipient` の 2 つの `ConcurrentHashMap` で返信先を追跡し,`getReplyTarget()` は「自分に送ってきた人 → 自分が送った人」の優先順でオンラインのプレイヤーを返します.
+
+`sendDirectMessage()` は,送信者設定に応じたローマ字変換 → spy プレイヤーへの hover 付き配信 (送受信者は除外) → 送受信者への整形メッセージ送信+通知音 (設定依存) を行います.メッセージには `/tell <sender>` を補完する `ClickEvent.suggestCommand` が付きます.
+
+### チャンネルチャット (ChannelMessageHandler)
+
+`sendChannelMessage()` は `channelManager.getPlayerChannelContext()` でアクティブチャンネルを解決し (無ければ何もしない),spy 配信 (送信者とメンバーを除外) → チャンネルメンバー全員への配信+受信者通知音 → ログ有効時は engine の `ChannelMessageLogEntry.create()` で NDJSON ログ,という順で処理します.
+
+チャンネルの状態管理そのものは `chat/channel` パッケージが担います.
+
+- `ChannelManager` — チャンネルの単一の真実源.`channelsCache` / `membersCache` / `activeChannels` の `ConcurrentHashMap` で状態を持ち,CRUD は `kotlin.Result` を返して失敗時に engine 例外を包む.config の上限 (0 = 無制限) を検査する
+- `ChannelMembershipManager` — 入退室・切替・ロールのビジネスロジック.`joinChannel()` は 存在 / 既アクティブ / BAN / private-invite / 既メンバー / 所属上限 を順に検査する
+- `ChannelStorage` — `ChannelData` を JSON (`channels.json`) で永続化
+- `ChannelMessageLogger` — NDJSON の非同期ロガー.日次ローテーション+サイズ上限+保持日数超過ファイルの定期削除
+
+## リスナー登録
+
+- `EventListenerRegistry` (`object`) — `SpyPermissionManager` と `PlayerPresenceListener` は常時,`PlayerChatListener` は channel / velocity cross-server / romaji のいずれかが有効なときだけ登録する (ここも Feature Gating)
+- `PlayerPresenceListener` — Join でアップデート通知・nightly 警告・アクティブチャンネル復元通知,Quit で DM 参照クリア+アクティブチャンネル解除+設定保存
+- `SpyPermissionManager` (`object : Listener`) — `Spy` 権限保持者を join/quit でキャッシュし,DM・チャンネルハンドラが参照する
+
+## config
+
+- `ConfigManager` — メイン `config.yml` を **Bukkit の `FileConfiguration`** からドット記法で読み,`LunaticChatConfiguration` を手組みする (この経路は KAML ではない点に注意)
+- 機能デフォルト: `quickReplies=true`, `japaneseConversion=false`, `channelChat=false`, `velocityIntegration=false`
+- `config/key` 以下に `FeaturesConfig` / `ChannelChatFeatureConfig` / `JapaneseConversionFeatureConfig` / `VelocityIntegrationConfig` / `QuickRepliesFeatureConfig` / `MessageFormatConfig` / `ChannelMessageLoggingConfig`
+
+::: warning 実装ノート
+`ChannelChatFeatureConfig.messageLogging` は `ConfigManager` でロードされず,デフォルト値 (enabled=true, retention=30, 100MB) 固定になっています.意図的な仕様か要確認 — 修正するか,仕様として明記するかを決める必要があります.
+:::
+
+## i18n
+
+- `Language` (enum) — `EN` / `JA`.未知コードは EN にフォールバック
+- `LanguageManager` — 起動時に `resources/languages/` を KAML でロードし,ネストした YAML をドット記法 (`toggle.on` 等) にフラット化する.`getMessage(key, placeholders)` は 選択言語 → EN フォールバック で解決し `{placeholder}` を置換,未発見はキー自身を返す.EN が無ければ致命エラー
+- `MessageFormatter` (`object`) — `[LC]` プレフィックス付きの Adventure `Component` を生成し,`{braces}` プレースホルダを正規表現で検出して色分けする
+
+## converter (paper 側) — engine 連携
+
+paper 側は「キャッシュ管理・タイムアウト・Bukkit スケジューリング」というプラットフォーム都合を担い,変換アルゴリズムと API 通信は engine に委譲します.
+
+- `RomanjiConverter` — 2 段変換のオーケストレータ.単語ごとに キャッシュ確認 → engine `KanaConverter` でローマ字→ひらがな → engine `GoogleIMEClient` でひらがな→漢字.API 失敗時はひらがなにフォールバック
+- `ConversionCache` — engine `CacheData` を JSON 永続化.メモリキャッシュ+デバウンス保存 (`maxEntries` 超過時の退避は ConcurrentHashMap の順不同により実質ランダム,との FIXME あり)
+- `RomajiConversionHelper` — `convertWithRomaji()`.`runBlocking` + `withTimeoutOrNull` (既定 1000ms) で同期呼び出しし,成功時 `"元文 §e(変換)"`,失敗/タイムアウト時は原文を返す
+
+## velocity 連携 (Paper 側視点)
+
+engine の protocol を使い,Bukkit の Plugin Messaging Channel (`lunaticchat:main`) でプロキシと通信します.実際のクロスサーバールーティングは Velocity 側が担い,paper は「送出・受信・重複排除・整形表示」を担当します.
+
+- `VelocityConnectionManager` (`PluginMessageListener`) — `ConnectionState` (DISCONNECTED / HANDSHAKING / CONNECTED / FAILED) を管理.ハンドシェイクは engine の `PluginMessage.Handshake` を encode して送信し,5 秒でタイムアウトする.循環依存回避のため `CrossServerChatManager` は後入れ (setter injection)
+- ハンドシェイクは**最初のプレイヤー参加を契機に一度だけ** (`AtomicBoolean`) 実行される.参加の 1 秒後に `asyncScheduler` でスケジュールし,結果は `HandshakeResult.Success` / `Error` で受ける
+- `CrossServerChatManager` — グローバルチャットの送出・受信・**重複排除**.送信時に生成した `messageId` を即キャッシュ登録して自サーバーでのエコーを防ぎ (一段目),受信時は `messageId` の重複排除キャッシュ (TTL 60s,`cacheSize` 超過で古い順に掃除) で二重表示を防ぐ.Bukkit API 呼び出しは `scheduler.runTask` でメインスレッドに戻す
+
+## settings / common
+
+- `PlayerSettingsManager` — 3 種のブール設定を `ConcurrentHashMap` で管理.engine の DTO を使い,未設定はデフォルト true
+- `YamlPlayerSettingsStorage` — KAML で `player-settings.yaml` を read/write.読み込み失敗時はバックアップから復旧,5 秒デバウンス保存
+- `UpdateChecker` — GitHub Releases API を Ktor で叩き semver 比較.結果は sealed `UpdateCheckResult`
+- `SoundCollector` — 通知音の Adventure `Sound` 定数と Player 拡張関数
+- `PermissionCollector` — `@PermissionDsl` + `+LunaticChatPermissionNode` 演算子で権限を集める DSL.`requirePermission` は engine の `RequirePermissionException` を投げる
+
+## 関連
+
+- [設計概要](/ja/docs/developers/architecture)
+- [engine - 共通カーネル](/ja/docs/developers/engine)
+- [platform-velocity - Velocity プラグイン本体](/ja/docs/developers/platform-velocity)
diff --git a/website/src/ja/docs/developers/platform-velocity.md b/website/src/ja/docs/developers/platform-velocity.md
new file mode 100644
index 0000000..46f5ac0
--- /dev/null
+++ b/website/src/ja/docs/developers/platform-velocity.md
@@ -0,0 +1,92 @@
+---
+layout: doc
+---
+
+# platform-velocity - Velocity プラグイン本体 (プロキシ中継)
+
+`platform-velocity` はサーバー間グローバルチャットの**中継**だけを担う薄い層です.
+
+実体は `LunaticChat` / `BuildInfo` / `messaging/` の 2 ファイルのみで,コマンドクラスは持ちません.`/lcv` は Velocity に関する機能ですが,コマンドの実装は [platform-paper](/ja/docs/developers/platform-paper) 側にある点に注意してください.
+
+::: tip なぜ Velocity 側は薄いのか
+protocol の定義は engine が持ち,チャットの状態 (チャンネル・DM・設定) はすべて Paper 側にあります.Velocity に残る責務は「受け取ったグローバルチャットを他サーバーへ配る」ことだけなので,この層は意図的に薄く保たれています.どちらのプラットフォームも protocol を所有せず,engine に対して対等に依存する構図です (詳細は [設計概要](/ja/docs/developers/architecture#なぜ-engine-を切り出すのか)).
+:::
+
+## ライフサイクル
+
+- `LunaticChat` (`@Plugin`) — Guice の `@Inject` コンストラクタで `ProxyServer` / `Logger` / `PluginContainer` を受け取る
+- `@Plugin` アノテーションの `version` は `"0.0.0"` 固定で**実行時には使われない**.実バージョンは `velocity-plugin.json` から `PluginContainer.description.version` 経由で取得する (見つからなければ起動を止める)
+- `@Subscribe onProxyInitialization` で `CrossServerChatRelay` を生成 → それを注入して `PluginMessageHandler` を生成し `initialize()`
+- `@Subscribe onProxyShutdown` で `messageHandler.shutdown()`
+
+## メッセージ受信とディスパッチ
+
+`PluginMessageHandler` がチャンネル `lunaticchat:main` の受信を捌きます.`initialize()` で `channelRegistrar.register(CHANNEL)` とイベント購読を行います.
+
+`@Subscribe onPluginMessage` の処理:
+
+1. `event.identifier != CHANNEL` なら無視する
+2. 送信元が `ServerConnection` でなければ警告して破棄する
+3. `PluginMessageCodec.decode()` の結果を `when` で分岐する
+4. `Handshake` → 互換判定して `HandshakeResponse` 返送 / `StatusRequest` → `StatusResponse` 返送 / `GlobalChatMessage` → 中継へ委譲 / それ以外 (Velocity 発の応答型) → 警告のみ
+
+### 信頼境界: クライアント由来メッセージの拒否
+
+送信元が `ServerConnection` かどうかのチェックは,単なる型ガードではなく**信頼境界**です.
+
+Velocity のプラグインメッセージはバックエンドサーバーだけでなくクライアントからも届き得ます.ここでバックエンド接続以外を弾くことで,**クライアントがグローバルチャットや偽ハンドシェイクを直接注入することを防いでいます**.中継されるのは信頼できるサーバー接続から来たメッセージだけです.
+
+## ハンドシェイク処理
+
+`Handshake` を受け取ると engine の `ProtocolVersion.isCompatible(major, minor)` で互換性を判定します.
+
+- **互換** — `compatible=true` の `HandshakeResponse` を返送
+- **非互換** — Paper 側・Velocity 側のバージョンを載せた error 文字列とともに `compatible=false` を返送
+
+`HandshakeResponse` / `StatusResponse` には Velocity 自身の `ProtocolVersion` (`MAJOR` / `MINOR` / `PATCH`) を必ず載せるため,Paper 側はレスポンスから相手のプロトコルを知ることができます.
+
+## サーバー間中継
+
+`CrossServerChatRelay.relayGlobalMessage(message, sourceServer)` が中継の核心です.
+
+```
+server.allServers
+ .filter { it != sourceServer } // 送信元を除外
+ .forEach { it.sendPluginMessage(CHANNEL, encoded) }
+```
+
+**送信元サーバーを除外**して残り全バックエンドへブロードキャストします (エコー防止の一段目) .中継件数はログに出ます.
+
+### 中継されるもの / ローカルに留まるもの
+
+中継の範囲を最小に絞っているのが設計上のポイントです.
+
+- Velocity が他サーバーへ中継するのは **`GlobalChatMessage` のみ**
+- `Handshake` / `HandshakeResponse` / `StatusRequest` / `StatusResponse` は Velocity ↔ 単一 Paper の間で完結し,転送しない
+- **DM・チャンネルチャットはそもそも Velocity へ送られない** (Paper 内でローカル完結する)
+
+### エコー / ループ防止の二段構え
+
+グローバルチャットが中継ループで多重表示されないよう,2 箇所で防いでいます.
+
+1. **Velocity 側** — 送信元サーバーを除外してブロードキャスト
+2. **Paper 側** — `messageId` による重複排除 LRU キャッシュ (TTL 60s).送信側は生成直後に自分の `messageId` を登録して自サーバーでのエコーも防ぐ
+
+## 通信フロー
+
+1. Paper がプレイヤー接続を契機に `Handshake` (自プロトコルバージョン) を送信する
+2. Velocity が `ProtocolVersion.isCompatible` で判定し `HandshakeResponse` を返送する → 互換なら Paper 側は `CONNECTED`
+3. プレイヤーがグローバルチャット (チャンネル未所属 or `!` プレフィックス) を送信 → Paper が `GlobalChatMessage` (新規 `messageId`) を Velocity へ送信し,送信元では通常チャットを表示する
+4. Velocity が送信元以外の全バックエンドへ中継する
+5. 各 Paper が受信 → `messageId` で dedup → `crossServerGlobalChatFormat` で整形して全プレイヤーへ配信する
+
+## 実装ノート
+
+- `PluginMessageHandler` の `plugin` パラメータ型が `Any` なのは,Velocity の `EventManager.register()` が `Object` を取るためです (API 自体が型安全でないので,ジェネリクス化しても実益が薄いとの判断).
+- クロスサーバーチャットを動かすには,Velocity の `velocity.toml` で `bungee-plugin-message-channel=true` (プラグインメッセージ有効) が必要です.
+
+## 関連
+
+- [設計概要](/ja/docs/developers/architecture)
+- [engine - 共通カーネル](/ja/docs/developers/engine) — protocol の詳細
+- [platform-paper - Paper / Folia プラグイン本体](/ja/docs/developers/platform-paper) — Paper 側の対向実装
diff --git a/website/src/ja/docs/developers/resource.md b/website/src/ja/docs/developers/resource.md
new file mode 100644
index 0000000..2e241b7
--- /dev/null
+++ b/website/src/ja/docs/developers/resource.md
@@ -0,0 +1,60 @@
+---
+layout: doc
+---
+
+# ビルド・リリース・バージョニング
+
+Gradle マルチモジュール構成で,engine を共有しつつ Paper / Velocity を独立した成果物としてビルド・リリースします.
+
+## ビルド構成
+
+- ルート `build.gradle.kts` — Kotlin 2.4.0 + serialization / Shadow / ktlint / dokka を管理.JVM ターゲットは **JVM_25**.テストは JUnit Platform + jacoco で,共通テスト依存を全モジュールへ注入
+- `engine` — コアライブラリ (serialization / coroutines / ktor) を `api()` で公開しプラットフォームへ伝播.Adventure は `compileOnly`.Shadow を持たない純ライブラリ
+- `platform-paper` — `version = paperVersion`.`api(project(":engine"))`.paper-api を `compileOnly`,KAML + kotlin-reflect を `implementation`.成果物は **`LunaticChat-<ver>.jar`** (classifier なし,`jar` は無効化)
+- `platform-velocity` — `version = velocityVersion`.`api(project(":engine"))`.velocity-api を `compileOnly` + `annotationProcessor` (`@Plugin` 処理用).成果物は **`LunaticChat-<ver>-velocity.jar`** (classifier で区別)
+- `dokka` — engine/paper/velocity を集約し HTML に README を include
+
+両プラットフォームの `processResources` は git short hash と `isNightly` から `version` / `gitCommitHash` / `channel` を算出し,`paper-plugin.yml` / `velocity-plugin.json` と `build-info.properties` にトークン展開します.
+
+## 独立バージョニング
+
+```properties
+# gradle.properties
+paperVersion=1.2.2
+velocityVersion=1.1.0
+```
+
+Paper と Velocity は別々のバージョン番号を持ち,独立にリリースできます.**互換性を数値バージョンではなく engine 共有の [`ProtocolVersion`](/ja/docs/developers/engine#バージョニング戦略-protocolversion) で保証している**ため,更新頻度の異なる 2 プラットフォームをそれぞれのペースでバンプ・公開できるからです.ワイヤ形式は JSON + `ignoreUnknownKeys` で前方互換,プロトコルの MAJOR 一致 + MINOR 範囲チェックで後方互換をコントロールします.
+
+## リリースワークフロー
+
+タグのパターンでリリース対象が切り替わります.
+
+| ワークフロー | トリガタグ | ビルド対象 | バージョン検証 |
+|-------------|-----------|-----------|---------------|
+| `release.yaml` | `v*` | Paper + Velocity 両方 | gradle.properties から両バージョン抽出 |
+| `release-paper.yaml` | `paper/v*` | Paper のみ | タグと `paperVersion` の一致を必須検証 |
+| `release-velocity.yaml` | `velocity/v*` | Velocity のみ | タグと `velocityVersion` の一致を必須検証 |
+
+- 共通フロー: `validate` (既存リリースの重複チェック) → `build` (mise + Gradle setup, `shadowJar`) → `release` (`gh release create --draft` + Modrinth 公開)
+- 個別ワークフロー (paper / velocity) はタグと `gradle.properties` の厳密一致を要求する点が `release.yaml` と異なる
+- Modrinth の game-versions は Paper=`26.1.x` (loader: paper, folia),Velocity=`1.21.x` + `26.1.x` (loader: velocity)
+
+## CI
+
+`ci.yaml` は main への push / PR / 手動実行で動きます.
+
+- `build_plugin` — ktlintCheck → test + jacocoTestReport → Codecov アップロード → nightly shadowJar (`-PisNightly=true`) → artifact 保持
+- `build_dokka` / `deploy_dokka` — Dokka 生成 → GitHub Pages デプロイ (main push のみ)
+- `build_docs` — `website/` を bun で format/lint/build → Cloudflare Workers (wrangler) へデプロイ (main push のみ)
+
+## 開発環境
+
+- `mise.toml` — bun / java zulu-25 (`JVM_25` と整合)
+- `x` — bash 製のデバッグサーバースクリプト.`./x <action> <platform> [--stable]` で start/stop/log/clean/rcon/help.`--stable` 省略時は nightly ビルド.`velocity` 指定時は **1 Velocity + 2 Paper** を立ち上げ,サーバー間チャット中継を実地検証できる
+- `docker/` — paper / velocity / folia の 3 環境に `compose.yaml` (`itzg/minecraft-server:java25` 等).velocity.toml は `bungee-plugin-message-channel=true` でプラグインメッセージを有効化
+
+## 関連
+
+- [設計概要](/ja/docs/developers/architecture)
+- [はじめに](/ja/docs/developers/introduction)