diff options
| author | Sho Sakuma <me@m1sk9.dev> | 2026-02-07 17:00:51 +0900 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-02-07 17:00:51 +0900 |
| commit | d680eaa8fe7a99ccd25674c1b6933096dc28d5df (patch) | |
| tree | 112c6c20abd69af5c3e6d669419d8d2045044fbb | |
| parent | 0afd010990b8f73d6db5dfb1be353dcf163a63d3 (diff) | |
| parent | 76198199c24b0d1d31d0dfa74825d1ea0cdfb097 (diff) | |
| download | LunaticChat-d680eaa8fe7a99ccd25674c1b6933096dc28d5df.tar.gz LunaticChat-d680eaa8fe7a99ccd25674c1b6933096dc28d5df.tar.bz2 LunaticChat-d680eaa8fe7a99ccd25674c1b6933096dc28d5df.zip | |
Merge pull request #87 from m1sk9/feat/cross-server-chat
feat: Implementation of Cross-Server Chat
37 files changed, 797 insertions, 364 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 6316a1e..892cf86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ### v0.8.0 - Velocity integration is now available. +- Fixed the header design for certain commands such as `/lc channel`. ### v0.7.0 diff --git a/docker/.gitignore b/docker/.gitignore index 16873f6..4c0fc30 100644 --- a/docker/.gitignore +++ b/docker/.gitignore @@ -1 +1,2 @@ -plugins/ +plugins-paper-*/ +plugins-velocity/ diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index 9efe09e..0000000 --- a/docker/README.md +++ /dev/null @@ -1,214 +0,0 @@ -# LunaticChat Docker Development Environment - -This directory contains Docker Compose configuration for testing LunaticChat with Velocity proxy integration. - -## Architecture - -``` -┌─────────────────┐ -│ Minecraft │ -│ Client │ -│ localhost:25577│ -└────────┬────────┘ - │ - v -┌─────────────────┐ -│ Velocity │ -│ Proxy │ -│ Port: 25577 │ -│ + LunaticChat │ -│ (Velocity) │ -└────────┬────────┘ - │ - v -┌─────────────────┐ -│ Paper Server │ -│ minecraft:25565│ -│ + LunaticChat │ -│ (Paper) │ -└─────────────────┘ -``` - -## Requirements - -- Docker -- Docker Compose -- Gradle (for building plugins) - -## Quick Start - -```bash -# Build plugins and start containers -./x start - -# View Minecraft server logs -./x logs - -# View Velocity proxy logs -./x vlogs - -# Restart containers -./x restart - -# Stop containers -./x stop - -# Clean up (remove volumes) -./x clean - -# Access RCON -./x rcon -``` - -## Configuration - -### LunaticChat Paper Plugin - -Configuration file: `docker/plugins/LunaticChat/config.yml` - -Key settings: -- `features.velocityIntegration.enabled: true` - Enables Velocity integration -- `debug: true` - Enables debug logging - -### Velocity Proxy - -Configuration file: `docker/velocity.toml` - -Key settings: -- `online-mode = false` - Offline mode for testing -- `player-info-forwarding-mode = "NONE"` - No player info forwarding -- `servers.minecraft = "minecraft:25565"` - Backend server connection - -## Testing Velocity Integration - -### 1. Start the environment - -```bash -./x start -``` - -Wait for both containers to start. You should see: -- Paper server: "Done! For help, type 'help'" -- Velocity: "Done (X.XXs)!" - -### 2. Connect with Minecraft client - -1. Open Minecraft Java Edition -2. Add a server with address: `localhost:25577` -3. Connect to the server - -### 3. Verify handshake - -When you join the server, LunaticChat will perform a handshake between Paper and Velocity. - -**Expected behavior:** -- Paper plugin sends handshake message to Velocity -- Velocity plugin validates plugin version and protocol version -- If compatible: connection succeeds, handshake logs appear in both containers -- If incompatible: Paper plugin disables itself with error message - -**Check logs:** - -Paper server: -```bash -./x logs | grep -i handshake -``` - -Expected output: -``` -[INFO]: [LunaticChat] Sending handshake to Velocity (Plugin: 0.8.0, Protocol: 1.0.0) -[INFO]: [LunaticChat] Velocity handshake successful with version 0.7.0 -``` - -Velocity proxy: -```bash -./x vlogs | grep -i handshake -``` - -Expected output: -``` -[INFO] [lunaticchat]: Received handshake from minecraft: Plugin=0.8.0, Protocol=1.0.0 -[INFO] [lunaticchat]: Handshake successful with minecraft -``` - -### 4. Test /lcv status command - -In-game or via RCON: -``` -/lcv status -``` - -Expected output: -- Paper Plugin Version -- Velocity Plugin Version -- Protocol Version -- Connection State: Connected -- Live status check results - -## Version Compatibility - -### Plugin Version Check -- **Rule:** Paper and Velocity plugin versions must match exactly -- **Example:** Paper 0.8.0 + Velocity 0.8.0 = ✓ PASS -- **Example:** Paper 0.8.0 + Velocity 0.7.0 = ✗ FAIL - -### Protocol Version Check -- **Rule:** MAJOR.MINOR must match (PATCH differences are OK) -- **Example:** Paper 1.0.0 + Velocity 1.0.1 = ✓ PASS -- **Example:** Paper 1.0.0 + Velocity 1.1.0 = ✗ FAIL -- **Example:** Paper 1.0.0 + Velocity 2.0.0 = ✗ FAIL - -## Troubleshooting - -### Paper plugin disabled on startup - -**Cause:** Handshake failed or timed out - -**Solutions:** -1. Check Velocity is running: `docker ps | grep velocity` -2. Check plugin versions match in both containers -3. Review error messages in Paper logs: `./x logs | grep ERROR` - -### "Handshake timeout" error - -**Cause:** Paper plugin couldn't reach Velocity proxy - -**Solutions:** -1. Verify network connectivity: `docker network inspect docker_minecraft-network` -2. Check Velocity logs for errors: `./x vlogs` -3. Restart containers: `./x restart` - -### Version mismatch errors - -**Cause:** Plugin versions don't match or protocol incompatible - -**Solutions:** -1. Rebuild both plugins: `./gradlew clean :platform-paper:shadowJar :platform-velocity:shadowJar` -2. Restart containers: `./x restart` -3. Verify versions in logs match - -## Network Details - -- **Velocity Public Port:** 25577 (connect here with Minecraft client) -- **Paper RCON Port:** 25575 (for remote commands) -- **Internal Network:** `minecraft-network` (bridge driver) -- **Paper Internal Address:** `minecraft:25565` (accessible from Velocity) - -## Useful Commands - -```bash -# Follow logs in real-time -docker compose -f docker/compose.yaml logs -f - -# Execute command in Minecraft server -docker compose -f docker/compose.yaml exec minecraft rcon-cli -> /list -> /lcv status - -# Shell access to containers -docker compose -f docker/compose.yaml exec minecraft bash -docker compose -f docker/compose.yaml exec velocity bash - -# View Velocity configuration -docker compose -f docker/compose.yaml exec velocity cat /server/velocity.toml -``` diff --git a/docker/compose.yaml b/docker/compose.yaml index 7ab8e19..f11bf95 100644 --- a/docker/compose.yaml +++ b/docker/compose.yaml @@ -7,13 +7,16 @@ services: volumes: - lunatic-debug-velocity-data:/server - ../platform-velocity/build/libs:/plugins:ro + - ./plugins-velocity:/data/plugins - ./velocity.toml:/server/velocity.toml environment: TYPE: "VELOCITY" MEMORY: "512M" ONLINE_MODE: "false" depends_on: - minecraft: + s1: + condition: service_healthy + s2: condition: service_healthy healthcheck: test: mc-health @@ -24,17 +27,65 @@ services: networks: - minecraft-network - minecraft: + s1: image: itzg/minecraft-server:java21 - container_name: debug-server + container_name: debug-server-s1 expose: - "25565" ports: - "25575:25575" volumes: - - lunatic-debug-minecraft-data:/data + - lunatic-debug-s1-data:/data + - ../platform-paper/build/libs:/plugins:ro + - ./plugins-paper-s1:/data/plugins + - ./bukkit.yml:/data/bukkit.yml + environment: + EULA: "TRUE" + TYPE: "PAPER" + VERSION: "1.21.11" + MEMORY: "1G" + + # デバッグ用高速化設定 + LEVEL_TYPE: "flat" + VIEW_DISTANCE: "3" + SIMULATION_DISTANCE: "3" + GENERATE_STRUCTURES: "false" + SPAWN_PROTECTION: "0" + ALLOW_NETHER: "false" + + # 権限関連 + OVERRIDE_SERVER_PROPERTIES: "true" + + ENABLE_RCON: "true" + RCON_PASSWORD: "minecraft" + RCON_PORT: 25575 + + DIFFICULTY: "peaceful" + MAX_PLAYERS: "3" + ONLINE_MODE: "false" + SYNC_SKIP_NEWER_IN_DESTINATION: "false" + networks: + - minecraft-network + healthcheck: + test: mc-health + start_period: 60s + interval: 10s + timeout: 5s + retries: 3 + tty: true + stdin_open: true + + s2: + image: itzg/minecraft-server:java21 + container_name: debug-server-s2 + expose: + - "25565" + ports: + - "25576:25575" + volumes: + - lunatic-debug-s2-data:/data - ../platform-paper/build/libs:/plugins:ro - - ./plugins:/data/plugins + - ./plugins-paper-s2:/data/plugins - ./bukkit.yml:/data/bukkit.yml environment: EULA: "TRUE" @@ -63,6 +114,12 @@ services: SYNC_SKIP_NEWER_IN_DESTINATION: "false" networks: - minecraft-network + healthcheck: + test: mc-health + start_period: 60s + interval: 10s + timeout: 5s + retries: 3 tty: true stdin_open: true @@ -71,5 +128,6 @@ networks: driver: bridge volumes: - lunatic-debug-minecraft-data: + lunatic-debug-s1-data: + lunatic-debug-s2-data: lunatic-debug-velocity-data: diff --git a/docker/velocity.toml b/docker/velocity.toml index 4d2cd19..1b5b374 100644 --- a/docker/velocity.toml +++ b/docker/velocity.toml @@ -60,11 +60,12 @@ show-plugins = false [servers] # Configure your servers here. Each key represents the server's name, and the value # represents the IP address of the server to connect to. -minecraft = "minecraft:25565" +s1 = "s1:25565" +s2 = "s2:25565" # In what order we should try servers when a player logs in or is kicked from a server. try = [ - "minecraft" + "s1" ] [forced-hosts] diff --git a/docs/.vitepress/config/en.ts b/docs/.vitepress/config/en.ts index d5f4dd6..1669fbb 100644 --- a/docs/.vitepress/config/en.ts +++ b/docs/.vitepress/config/en.ts @@ -130,7 +130,7 @@ export const en: DefaultTheme.Config = { text: '/lcv', items: [ { - text: '/lc status', + text: '/lcv status', link: '/en/reference/commands/lcv/status', }, ], diff --git a/docs/.vitepress/config/ja.ts b/docs/.vitepress/config/ja.ts index 2ee250e..0e3e467 100644 --- a/docs/.vitepress/config/ja.ts +++ b/docs/.vitepress/config/ja.ts @@ -130,8 +130,8 @@ export const ja: DefaultTheme.Config = { text: '/lcv', items: [ { - text: '/lc status', - link: '/en/reference/commands/lcv/status', + text: '/lcv status', + link: '/reference/commands/lcv/status', }, ], }, diff --git a/docs/src/en/guide/admin/configuration.md b/docs/src/en/guide/admin/configuration.md index ef33ddc..15c8f10 100644 --- a/docs/src/en/guide/admin/configuration.md +++ b/docs/src/en/guide/admin/configuration.md @@ -68,6 +68,19 @@ features: retentionDays: 30 # Maximum size of a single log file in megabytes. Files exceeding this size will stop accepting new entries. maxFileSizeMB: 100 + velocityIntegration: + # If enabled, enables integration with Velocity proxy plugin. + # This allows Paper and Velocity instances to communicate and verify compatibility. + enabled: false + # If enabled, global chat messages will be shared across all Paper servers connected to the Velocity proxy. + # Players on different servers can communicate through the GLOBAL chat mode. + crossServerGlobalChat: false + # Server name to display in cross-server chat (e.g., "survival", "creative", "lobby"). + # This should match the server name defined in your Velocity configuration. + serverName: "Unknown" + # Size of the message deduplication cache to prevent duplicate messages from appearing. + # Keeps track of the most recent N message IDs to filter out duplicates. + messageDeduplicationCacheSize: 100 # ---------------------------------------------- # --------- Message Format Settings -------- @@ -80,6 +93,7 @@ features: # {recipient} - The name of the message recipient # {message} - The content of the message # {channel} - The name of the chat channel (only for channel chat) +# {server} - The name of the server (only for Velocity cross-server chat) # ---------------------------------------------- messageFormat: @@ -87,6 +101,8 @@ messageFormat: directMessageFormat: "§7[§e{sender} §7>> §e{recipient}§7] §f{message}" # Configure the format for messages sent in channel chat channelMessageFormat: "§7[§b#{channel}§7] §e{sender}: §f{message}" + # Configure the format for global chat messages in Velocity integration + crossServerGlobalChatFormat: "§7[§6{server}§7] §e{sender}: §f{message}" ``` ## General Settings @@ -246,6 +262,38 @@ Specifies the maximum size (in megabytes) of channel chat log files. Log files exceeding `maxFileSizeMB` will stop accepting new entries. +### `features.velocityIntegration` + +#### `enabled` + +- Type: `boolean` +- Default: `false` + +Enables integration with Velocity proxy plugin. + +#### `crossServerGlobalChat` + +- Type: `boolean` +- Default: `false` + +Specifies whether to share chat messages across all Paper servers connected to the Velocity proxy. + +#### `serverName` + +- Type: `string` +- Default: `Unknown` + +Specifies the server name to display in Velocity cross-server chat. + +Examples: `survival`, `creative`, `lobby` + +#### `messageDeduplicationCacheSize` + +- Type: `integer` +- Default: `100` + +Specifies the size of the message deduplication cache. + ## Message Format Settings Available placeholders: @@ -254,6 +302,7 @@ Available placeholders: - `{recipient}`: Name of the message recipient - `{message}`: Content of the message - `{channel}`: Name of the chat channel (only for channel chat) +- `{server}`: Name of the server (only for Velocity cross-server chat) ### `messageFormat.directMessageFormat` @@ -268,3 +317,10 @@ Specifies the format for messages sent via direct message ([`/tell`](../../refer - Default: `§7[§b#{channel}§7] §e{sender}: §f{message}` Specifies the format for messages sent in channel chat. + +### `messageFormat.crossServerGlobalChatFormat` + +- Type: `string` +- Default: `§7[§6{server}§7] §e{sender}: §f{message}` + +Specifies the format for messages sent via Velocity cross-server global chat. diff --git a/docs/src/en/guide/admin/velocity.md b/docs/src/en/guide/admin/velocity.md index 9b4f10e..6a4e6f7 100644 --- a/docs/src/en/guide/admin/velocity.md +++ b/docs/src/en/guide/admin/velocity.md @@ -76,6 +76,6 @@ The main features available during Velocity integration are as follows: | | Compatibility | Behavior | Notes | |------------| --- | --- | --- | -| Direct Messages | ◯ | Can send DMs to players on other servers using `/tell` and `/reply` commands | | +| Direct Messages | × | `/tell` and `/reply` commands operate only within the server | Direct messages do not support Velocity integration. | | Channel Chat | × | Channel chat operates only within the server | Channel chat does not support Velocity integration. | | Kana/Romanization | ◯ | Kana/Romanization works across all servers | | diff --git a/docs/src/en/index.md b/docs/src/en/index.md index 0fe8a53..266c4a6 100644 --- a/docs/src/en/index.md +++ b/docs/src/en/index.md @@ -32,10 +32,10 @@ features: - title: Japanese Romanization Conversion details: Automatically converts messages input in romaji to Japanese icon: 🌍 - - title: Channel Chat Feature (Planned) + - title: Channel Chat Feature details: Create and manage chat channels, send private messages between specific players icon: ☎️ - - title: Multi-Platform Support (Planned) + - title: Multi-Platform Support details: Support for multi-platform Paper and Velocity, enabling cross-server chat icon: 🚀 - title: High-Speed Performance with Caching diff --git a/docs/src/en/reference/commands/lc/settings.md b/docs/src/en/reference/commands/lc/settings.md index 4d09301..8b88066 100644 --- a/docs/src/en/reference/commands/lc/settings.md +++ b/docs/src/en/reference/commands/lc/settings.md @@ -2,9 +2,9 @@ ### `/lc settings <key> [value]` -| Permission | -|--------------------------------| -| `lunaticchat.command.settings` | +| Permission | +|-----------------------------------| +| `lunaticchat.command.lc.settings` | Manages player settings for LunaticChat. If no value is specified, the current setting value will be displayed. @@ -16,3 +16,4 @@ This setting is saved by UUID, so the setting will not be lost even if you chang |----------|----------------------------------------------------------|--------| | `japanese` | Toggles romanization conversion on or off. | `true` | | `notice` | Toggles whether to receive notifications when receiving direct messages via `/tell` or `/reply`. | `true` | +| `chNotice` | Toggles whether to receive notifications when receiving channel chat messages. | `true` | diff --git a/docs/src/en/reference/commands/lc/status.md b/docs/src/en/reference/commands/lc/status.md index 365519a..1fa6a39 100644 --- a/docs/src/en/reference/commands/lc/status.md +++ b/docs/src/en/reference/commands/lc/status.md @@ -2,8 +2,8 @@ ### `/lc status` -| Permission | -|------------------------------| -| `lunaticchat.command.status` | +| Permission | +|---------------------------------| +| `lunaticchat.command.lc.status` | Displays the status of LunaticChat. diff --git a/docs/src/guide/admin/configuration.md b/docs/src/guide/admin/configuration.md index 5eb5fc6..b641549 100644 --- a/docs/src/guide/admin/configuration.md +++ b/docs/src/guide/admin/configuration.md @@ -68,6 +68,19 @@ features: retentionDays: 30 # Maximum size of a single log file in megabytes. Files exceeding this size will stop accepting new entries. maxFileSizeMB: 100 + velocityIntegration: + # If enabled, enables integration with Velocity proxy plugin. + # This allows Paper and Velocity instances to communicate and verify compatibility. + enabled: false + # If enabled, global chat messages will be shared across all Paper servers connected to the Velocity proxy. + # Players on different servers can communicate through the GLOBAL chat mode. + crossServerGlobalChat: false + # Server name to display in cross-server chat (e.g., "survival", "creative", "lobby"). + # This should match the server name defined in your Velocity configuration. + serverName: "Unknown" + # Size of the message deduplication cache to prevent duplicate messages from appearing. + # Keeps track of the most recent N message IDs to filter out duplicates. + messageDeduplicationCacheSize: 100 # ---------------------------------------------- # --------- Message Format Settings -------- @@ -80,6 +93,7 @@ features: # {recipient} - The name of the message recipient # {message} - The content of the message # {channel} - The name of the chat channel (only for channel chat) +# {server} - The name of the server (only for Velocity cross-server chat) # ---------------------------------------------- messageFormat: @@ -87,6 +101,8 @@ messageFormat: directMessageFormat: "§7[§e{sender} §7>> §e{recipient}§7] §f{message}" # Configure the format for messages sent in channel chat channelMessageFormat: "§7[§b#{channel}§7] §e{sender}: §f{message}" + # Configure the format for global chat messages in Velocity integration + crossServerGlobalChatFormat: "§7[§6{server}§7] §e{sender}: §f{message}" ``` ## General Settings @@ -246,6 +262,38 @@ LunaticChat の [`/reply`](../../reference/commands/reply.md) コマンドによ `maxFileSizeMB` を超えたログファイルは新しいエントリを受け付けなくなります. +### `features.velocityIntegration` + +#### `enabled` + +- Type: `boolean` +- Default: `false` + +Velocity プロキシプラグインとの連携を有効にします. + +#### `crossServerGlobalChat` + +- Type: `boolean` +- Default: `false` + +Velocity プロキシに接続されたすべての Paper サーバー間でチャットメッセージを共有するかどうかを指定します. + +#### `serverName` + +- Type: `string` +- Default: `Unknown` + +Velocity クロスサーバーチャットで表示されるサーバー名を指定します. + +例: `survival`, `creative`, `lobby` + +#### `messageDeduplicationCacheSize` + +- Type: `integer` +- Default: `100` + +メッセージの重複排除キャッシュのサイズを指定します. + ## Message Format Settings 使用できるプレースホルダー: diff --git a/docs/src/guide/admin/velocity.md b/docs/src/guide/admin/velocity.md index 98f5a6f..d6de2c6 100644 --- a/docs/src/guide/admin/velocity.md +++ b/docs/src/guide/admin/velocity.md @@ -74,8 +74,8 @@ Velocity 連携が有効化されている場合,プロキシサーバーを Velocity 連携中に利用可能な主な機能は以下の通りです: -| | 互換性の有無 | 挙動 | 備考 | -|------------| --- | --- | --- | -| ダイレクトメッセージ | ◯ | `/tell`, `/reply` コマンドで他サーバーのプレイヤーに DM を送信可能 | | -| チャンネルチャット | × | チャンネルチャットはサーバー内限定で動作 | チャンネルチャットは Velocity 連携に対応していません. | -| かな・ローマ字変換 | ◯ | かな・ローマ字変換は全サーバーで動作 | | +| | 互換性の有無 | 挙動 | 備考 | +|------------|--------|-----------------------------------|-----------------------------------| +| ダイレクトメッセージ | × | `/tell`, `/reply` コマンドはサーバー内限定で動作 | ダイレクトメッセージは Velocity 連携に対応していません. | +| チャンネルチャット | × | チャンネルチャットはサーバー内限定で動作 | チャンネルチャットは Velocity 連携に対応していません. | +| かな・ローマ字変換 | ◯ | かな・ローマ字変換は全サーバーで動作 | | diff --git a/docs/src/index.md b/docs/src/index.md index c3410cb..abdf453 100644 --- a/docs/src/index.md +++ b/docs/src/index.md @@ -35,7 +35,7 @@ features: - title: チャンネルチャット機能 details: チャットチャンネルを作成・管理し,特定のプレイヤー間でのプライベートメッセージを送信可能 icon: ☎️ - - title: マルチプラットフォーム対応 (実装予定) + - title: マルチプラットフォーム対応 details: Paper と Velocity のマルチプラットフォームに対応し,サーバ間チャットを実現します icon: 🚀 - title: キャッシュによる高速動作 diff --git a/docs/src/reference/commands/lc/settings.md b/docs/src/reference/commands/lc/settings.md index ab595ef..209879d 100644 --- a/docs/src/reference/commands/lc/settings.md +++ b/docs/src/reference/commands/lc/settings.md @@ -2,9 +2,9 @@ ### `/lc settings <key> [value]` -| パーミッション | -|--------------------------------| -| `lunaticchat.command.settings` | +| パーミッション | +|-----------------------------------| +| `lunaticchat.command.lc.settings` | LunaticChat のプレイヤー設定を管理します.値を指定しなかった場合は設定値が表示されます. @@ -16,3 +16,4 @@ LunaticChat のプレイヤー設定を管理します.値を指定しなか |------------|----------------------------------------------------------|--------| | `japanese` | ローマ字変換の有効化・無効化を切り替えます. | `true` | | `notice` | `/tell` や `/reply` でダイレクトメッセージを受信した際に通知を受け取るかどうかを切り替えます. | `true` | +| `chNotice` | チャンネルチャットを受信した際に通知を受け取るかどうかを切り替えます. | `true` | diff --git a/docs/src/reference/commands/lc/status.md b/docs/src/reference/commands/lc/status.md index 1b9ddcb..997d0ac 100644 --- a/docs/src/reference/commands/lc/status.md +++ b/docs/src/reference/commands/lc/status.md @@ -2,8 +2,8 @@ ### `/lc status` -| パーミッション | -|------------------------------| -| `lunaticchat.command.status` | +| パーミッション | +|---------------------------------| +| `lunaticchat.command.lc.status` | LunaticChat のステータスを表示します. diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt index 4f3a572..cee7837 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessage.kt @@ -55,4 +55,24 @@ sealed interface PluginMessage { val protocolVersion: String, val online: Boolean, ) : PluginMessage + + /** + * Global chat message (Paper ↔ Velocity ↔ Paper) + * + * @property messageId UUID for deduplication + * @property serverName Source server identifier + * @property playerId UUID as string + * @property playerName Player name + * @property message Chat message content + * @property timestamp Message timestamp (milliseconds since epoch) + */ + @Serializable + data class GlobalChatMessage( + val messageId: String, + val serverName: String, + val playerId: String, + val playerName: String, + val message: String, + val timestamp: Long = System.currentTimeMillis(), + ) : PluginMessage } diff --git a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt index 042a7dc..85b51f0 100644 --- a/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt +++ b/engine/src/main/kotlin/dev/m1sk9/lunaticChat/engine/protocol/PluginMessageCodec.kt @@ -23,6 +23,7 @@ object PluginMessageCodec { const val HANDSHAKE_RESPONSE = "handshake_response" const val STATUS_REQUEST = "status_request" const val STATUS_RESPONSE = "status_response" + const val GLOBAL_CHAT = "global_chat" } /** @@ -49,6 +50,9 @@ object PluginMessageCodec { is PluginMessage.StatusResponse -> { SubChannel.STATUS_RESPONSE to json.encodeToString(message) } + is PluginMessage.GlobalChatMessage -> { + SubChannel.GLOBAL_CHAT to json.encodeToString(message) + } } dataOut.writeUTF(subChannel) @@ -84,6 +88,9 @@ object PluginMessageCodec { SubChannel.STATUS_RESPONSE -> { json.decodeFromString<PluginMessage.StatusResponse>(messageJson) } + SubChannel.GLOBAL_CHAT -> { + json.decodeFromString<PluginMessage.GlobalChatMessage>(messageJson) + } else -> throw IllegalArgumentException("Unknown sub-channel: $subChannel") } } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt index aada4b0..42a4c86 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/LunaticChat.kt @@ -31,7 +31,6 @@ import java.util.concurrent.atomic.AtomicBoolean class LunaticChat : JavaPlugin(), Listener { - // Public API - accessed by commands (maintain backward compatibility) lateinit var directMessageHandler: DirectMessageHandler lateinit var languageManager: LanguageManager var channelManager: ChannelManager? = null @@ -40,7 +39,6 @@ class LunaticChat : var channelMessageHandler: ChannelMessageHandler? = null var channelNotificationHandler: ChannelNotificationHandler? = null - // Private services private lateinit var services: ServiceContainer private lateinit var configuration: LunaticChatConfiguration private lateinit var serviceInitializer: ServiceInitializer @@ -166,7 +164,7 @@ class LunaticChat : * Registers all event listeners. */ private fun registerEventListeners() { - EventListenerRegistry.registerAll(this, services, updateAvailable) + EventListenerRegistry.registerAll(this, services, configuration, updateAvailable) } /** diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt index 6843dd3..5ca5e52 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceContainer.kt @@ -9,6 +9,7 @@ import dev.m1sk9.lunaticChat.paper.chat.handler.DirectMessageHandler import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager +import dev.m1sk9.lunaticChat.paper.velocity.CrossServerChatManager import dev.m1sk9.lunaticChat.paper.velocity.VelocityConnectionManager /** @@ -27,6 +28,7 @@ import dev.m1sk9.lunaticChat.paper.velocity.VelocityConnectionManager * @property channelMessageHandler Optional (only when channel chat feature is enabled) * @property channelNotificationHandler Optional (only when channel chat feature is enabled) * @property velocityConnectionManager Optional (only when Velocity integration is enabled) + * @property crossServerChatManager Optional (only when Velocity integration and cross-server chat are enabled) */ data class ServiceContainer( val languageManager: LanguageManager, @@ -39,4 +41,5 @@ data class ServiceContainer( val channelMessageHandler: ChannelMessageHandler? = null, val channelNotificationHandler: ChannelNotificationHandler? = null, val velocityConnectionManager: VelocityConnectionManager? = null, + val crossServerChatManager: CrossServerChatManager? = null, ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt index fe6a4e4..dc015ed 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/ServiceInitializer.kt @@ -16,6 +16,7 @@ import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager import dev.m1sk9.lunaticChat.paper.settings.YamlPlayerSettingsStorage +import dev.m1sk9.lunaticChat.paper.velocity.CrossServerChatManager import dev.m1sk9.lunaticChat.paper.velocity.VelocityConnectionManager import io.ktor.client.HttpClient import org.bukkit.event.EventHandler @@ -57,6 +58,7 @@ class ServiceInitializer( private var channelNotificationHandler: ChannelNotificationHandler? = null private var channelMessageLogger: ChannelMessageLogger? = null private var velocityConnectionManager: VelocityConnectionManager? = null + private var crossServerChatManager: CrossServerChatManager? = null private val handshakeCompleted = AtomicBoolean(false) /** @@ -123,6 +125,17 @@ class ServiceInitializer( null } + // 7. Initialize cross-server chat manager (optional) + val crossServerManager = + if (configuration.features.velocityIntegration.enabled && + configuration.features.velocityIntegration.crossServerGlobalChat && + velocityManager != null + ) { + initializeCrossServerChatManager(velocityManager) + } else { + null + } + return ServiceContainer( languageManager = languageManager, playerSettingsManager = playerSettingsManager, @@ -134,6 +147,7 @@ class ServiceInitializer( channelMessageHandler = channelMessageHandler, channelNotificationHandler = channelNotificationHandler, velocityConnectionManager = velocityManager, + crossServerChatManager = crossServerManager, ) } @@ -341,6 +355,31 @@ class ServiceInitializer( } /** + * Initializes cross-server chat manager. + * + * @param velocityManager The Velocity connection manager + * @return The initialized CrossServerChatManager + */ + private fun initializeCrossServerChatManager(velocityManager: VelocityConnectionManager): CrossServerChatManager { + val manager = + CrossServerChatManager( + plugin = plugin, + logger = logger, + configuration = configuration, + cacheSize = configuration.features.velocityIntegration.messageDeduplicationCacheSize, + ) + crossServerChatManager = manager + + // Set the manager in VelocityConnectionManager to handle incoming messages + velocityManager.setCrossServerChatManager(manager) + + logger.info( + "Cross-server global chat initialized (cache size: ${configuration.features.velocityIntegration.messageDeduplicationCacheSize})", + ) + return manager + } + + /** * Performs handshake with Velocity proxy. */ private fun performVelocityHandshake( diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lcv/VelocityStatusCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lcv/VelocityStatusCommand.kt index 145272c..a4fb838 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lcv/VelocityStatusCommand.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lcv/VelocityStatusCommand.kt @@ -57,26 +57,6 @@ class VelocityStatusCommand( val sender = ctx.requirePlayer() val meta = plugin.pluginMeta - // Paper plugin version - sender.sendMessage( - MessageFormatter.format( - languageManager.getMessage( - "velocity.status.paperVersion", - mapOf("version" to meta.version), - ), - ), - ) - - // Protocol version - sender.sendMessage( - MessageFormatter.format( - languageManager.getMessage( - "velocity.status.protocolVersion", - mapOf("version" to ProtocolVersion.version), - ), - ), - ) - // Connection state val state = velocityConnectionManager.getState() val stateMessage = @@ -95,22 +75,44 @@ class VelocityStatusCommand( } } + // Header + sender.sendMessage( + MessageFormatter.format( + languageManager.getMessage("velocity.status.header"), + ), + ) + + // Paper plugin version + sender.sendMessage( + Component + .text(" • ", NamedTextColor.GRAY) + .append(Component.text("Paper Version: ", NamedTextColor.GRAY)) + .append(Component.text(meta.version, NamedTextColor.AQUA)), + ) + + // Protocol version sender.sendMessage( Component - .text(languageManager.getMessage("velocity.status.connectionState")) - .append(Component.text(": ")) + .text(" • ", NamedTextColor.GRAY) + .append(Component.text("Protocol Version: ", NamedTextColor.GRAY)) + .append(Component.text(ProtocolVersion.version, NamedTextColor.AQUA)), + ) + + // Connection state + sender.sendMessage( + Component + .text(" • ", NamedTextColor.GRAY) + .append(Component.text("Connection State: ", NamedTextColor.GRAY)) .append(stateMessage), ) // Velocity version (if connected) velocityConnectionManager.getVelocityVersion()?.let { velocityVersion -> sender.sendMessage( - MessageFormatter.format( - languageManager.getMessage( - "velocity.status.velocityVersion", - mapOf("version" to velocityVersion), - ), - ), + Component + .text(" • ", NamedTextColor.GRAY) + .append(Component.text("Velocity Version: ", NamedTextColor.GRAY)) + .append(Component.text(velocityVersion, NamedTextColor.AQUA)), ) } @@ -118,8 +120,8 @@ class VelocityStatusCommand( velocityConnectionManager.getLastError()?.let { error -> sender.sendMessage( Component - .text(languageManager.getMessage("velocity.status.error")) - .append(Component.text(": ", NamedTextColor.RED)) + .text(" • ", NamedTextColor.GRAY) + .append(Component.text("Error: ", NamedTextColor.GRAY)) .append(Component.text(error, NamedTextColor.RED)), ) } @@ -127,32 +129,26 @@ class VelocityStatusCommand( // Live status check (if connected) if (state == VelocityConnectionManager.ConnectionState.CONNECTED) { sender.sendMessage( - MessageFormatter.format( - languageManager.getMessage("velocity.status.checkingLiveStatus"), - ), + Component + .text(" • ", NamedTextColor.GRAY) + .append(Component.text("Checking live status...", NamedTextColor.YELLOW)), ) velocityConnectionManager .requestStatus(sender) .thenAccept { response -> sender.sendMessage( - MessageFormatter.format( - languageManager.getMessage( - "velocity.status.liveStatusSuccess", - mapOf( - "version" to response.velocityVersion, - "protocol" to response.protocolVersion, - "online" to response.online.toString(), - ), - ), - ), + Component + .text(" ✓ ", NamedTextColor.GREEN) + .append(Component.text("Online Players: ", NamedTextColor.GRAY)) + .append(Component.text(response.online.toString(), NamedTextColor.YELLOW)), ) }.exceptionally { throwable -> sender.sendMessage( Component - .text(languageManager.getMessage("velocity.status.liveStatusFailed")) - .color(NamedTextColor.RED) - .append(Component.text(": ${throwable.message}", NamedTextColor.RED)), + .text(" ✗ ", NamedTextColor.RED) + .append(Component.text("Live status check failed: ", NamedTextColor.GRAY)) + .append(Component.text(throwable.message ?: "Unknown error", NamedTextColor.RED)), ) null } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt index 164a0a8..33952be 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/ConfigManager.kt @@ -55,6 +55,21 @@ class ConfigManager { velocityIntegration = VelocityIntegrationConfig( enabled = configFile.getBoolean("features.velocityIntegration.enabled", false), + crossServerGlobalChat = + configFile.getBoolean( + "features.velocityIntegration.crossServerGlobalChat", + false, + ), + serverName = + configFile.getString( + "features.velocityIntegration.serverName", + "Unknown", + ) ?: "Unknown", + messageDeduplicationCacheSize = + configFile.getInt( + "features.velocityIntegration.messageDeduplicationCacheSize", + 100, + ), ), ), messageFormat = @@ -69,6 +84,11 @@ class ConfigManager { "messageFormat.channelMessageFormat", "§7[§b#{channel}§7] §e{sender}: §f{message}", )!!, + crossServerGlobalChatFormat = + configFile.getString( + "messageFormat.crossServerGlobalChatFormat", + "§7[§6{server}§7] §e{sender}: §f{message}", + )!!, ), debug = configFile.getBoolean("debug", false), checkForUpdates = configFile.getBoolean("checkForUpdates", false), diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt index 2e623fa..d31ec4e 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/MessageFormatConfig.kt @@ -3,4 +3,5 @@ package dev.m1sk9.lunaticChat.paper.config.key data class MessageFormatConfig( val directMessageFormat: String = "§7[§e{sender} §7>> §e{recipient}§7] §f{message}", val channelMessageFormat: String = "§7[§b#{channel}§7] §e{sender}: §f{message}", + val crossServerGlobalChatFormat: String = "§7[§6{server}§7] §e{sender}: §f{message}", ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt index 3cf42bf..51a67b6 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/config/key/VelocityIntegrationConfig.kt @@ -2,4 +2,7 @@ package dev.m1sk9.lunaticChat.paper.config.key data class VelocityIntegrationConfig( val enabled: Boolean = false, + val crossServerGlobalChat: Boolean = false, + val serverName: String = "Unknown", + val messageDeduplicationCacheSize: Int = 100, ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/EventListenerRegistry.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/EventListenerRegistry.kt index 1979b6b..2bdca37 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/EventListenerRegistry.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/EventListenerRegistry.kt @@ -3,6 +3,7 @@ package dev.m1sk9.lunaticChat.paper.listener import dev.m1sk9.lunaticChat.paper.LunaticChat import dev.m1sk9.lunaticChat.paper.ServiceContainer import dev.m1sk9.lunaticChat.paper.common.SpyPermissionManager +import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration import java.util.concurrent.atomic.AtomicBoolean /** @@ -17,11 +18,13 @@ object EventListenerRegistry { * * @param plugin The plugin instance * @param services The initialized services + * @param configuration The plugin configuration * @param updateAvailable Atomic flag for update availability */ fun registerAll( plugin: LunaticChat, services: ServiceContainer, + configuration: LunaticChatConfiguration, updateAvailable: AtomicBoolean, ) { val pluginManager = plugin.server.pluginManager @@ -40,12 +43,21 @@ object EventListenerRegistry { plugin, ) - // Conditionally register chat listener when all required components are available - if (services.chatModeManager != null && - services.channelManager != null && - services.channelMessageHandler != null && - services.romajiConverter != null - ) { + // Register chat listener when channel chat is enabled OR velocity cross-server chat is enabled + // Channel-related services (chatModeManager, channelManager, channelMessageHandler) can be null + // when channel chat is disabled, but cross-server chat can still work + val shouldRegisterChatListener = + ( + services.chatModeManager != null && + services.channelManager != null && + services.channelMessageHandler != null + ) || + ( + configuration.features.velocityIntegration.enabled && + configuration.features.velocityIntegration.crossServerGlobalChat + ) + + if (shouldRegisterChatListener) { pluginManager.registerEvents( PlayerChatListener( chatModeManager = services.chatModeManager, @@ -53,7 +65,8 @@ object EventListenerRegistry { channelMessageHandler = services.channelMessageHandler, romajiConverter = services.romajiConverter, settingsManager = services.playerSettingsManager, - languageManager = services.languageManager, + configuration = configuration, + crossServerChatManager = services.crossServerChatManager, ), plugin, ) diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt index 03423e5..4df4889 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/listener/PlayerChatListener.kt @@ -4,10 +4,10 @@ import dev.m1sk9.lunaticChat.engine.chat.ChatMode import dev.m1sk9.lunaticChat.paper.chat.ChatModeManager import dev.m1sk9.lunaticChat.paper.chat.channel.ChannelManager import dev.m1sk9.lunaticChat.paper.chat.handler.ChannelMessageHandler +import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration import dev.m1sk9.lunaticChat.paper.converter.RomanjiConverter -import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager -import dev.m1sk9.lunaticChat.paper.i18n.MessageFormatter import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager +import dev.m1sk9.lunaticChat.paper.velocity.CrossServerChatManager import io.papermc.paper.event.player.AsyncChatEvent import kotlinx.coroutines.runBlocking import net.kyori.adventure.text.Component @@ -17,15 +17,43 @@ import org.bukkit.event.EventPriority import org.bukkit.event.Listener class PlayerChatListener( - private val chatModeManager: ChatModeManager, - private val channelManager: ChannelManager, - private val channelMessageHandler: ChannelMessageHandler, - private val romajiConverter: RomanjiConverter, + private val chatModeManager: ChatModeManager?, + private val channelManager: ChannelManager?, + private val channelMessageHandler: ChannelMessageHandler?, + private val romajiConverter: RomanjiConverter?, private val settingsManager: PlayerSettingsManager, - private val languageManager: LanguageManager, + private val configuration: LunaticChatConfiguration, + private val crossServerChatManager: CrossServerChatManager?, ) : Listener { private val plainTextSerializer = PlainTextComponentSerializer.plainText() + /** + * Handles global chat message routing. + * Checks if Velocity cross-server chat is enabled and routes accordingly. + */ + private fun handleGlobalChat( + event: AsyncChatEvent, + displayMessage: String, + ) { + val velocityIntegrationEnabled = configuration.features.velocityIntegration.enabled + val crossServerChatEnabled = configuration.features.velocityIntegration.crossServerGlobalChat + + if (velocityIntegrationEnabled && crossServerChatEnabled && crossServerChatManager != null) { + // Send to Velocity for cross-server broadcast + crossServerChatManager.sendGlobalMessage( + event.player.uniqueId, + event.player.name, + displayMessage, + ) + + // Display as normal chat on the sender's server (no special formatting) + event.message(Component.text(displayMessage)) + } else { + // Existing behavior: normal Minecraft chat + event.message(Component.text(displayMessage)) + } + } + @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) fun onChat(event: AsyncChatEvent) { val player = event.player @@ -48,24 +76,29 @@ class PlayerChatListener( } val effectiveMode = - if (hasPrefix) { - val currentMode = chatModeManager.getChatMode(player.uniqueId) - currentMode.toggle() + if (chatModeManager != null) { + if (hasPrefix) { + val currentMode = chatModeManager.getChatMode(player.uniqueId) + currentMode.toggle() + } else { + chatModeManager.getChatMode(player.uniqueId) + } } else { - chatModeManager.getChatMode(player.uniqueId) + // Default to GLOBAL when chatModeManager is not available + ChatMode.GLOBAL } // Handle romaji conversion if enabled // Uses explicit timeout to prevent long blocking (1s max instead of 3s) // Note: AsyncChatEvent runs on async thread, so runBlocking here doesn't block main thread val displayMessage = - if (settings.japaneseConversionEnabled) { + if (settings.japaneseConversionEnabled && romajiConverter != null) { runCatching { runBlocking { kotlinx.coroutines .withTimeoutOrNull(1000) { romajiConverter - .convert(messageWithoutPrefix) + ?.convert(messageWithoutPrefix) }?.let { "$messageWithoutPrefix §e($it)" } ?: messageWithoutPrefix } }.getOrElse { messageWithoutPrefix } @@ -76,30 +109,29 @@ class PlayerChatListener( // Route message based on chat mode when (effectiveMode) { ChatMode.GLOBAL -> { - event.message(Component.text(displayMessage)) + handleGlobalChat(event, displayMessage) } ChatMode.CHANNEL -> { - val hasActiveChannel = channelManager.getPlayerChannel(player.uniqueId) != null + // Channel chat requires channelManager and channelMessageHandler + if (channelManager != null && channelMessageHandler != null) { + val hasActiveChannel = channelManager.getPlayerChannel(player.uniqueId) != null - if (hasActiveChannel) { - // Cancel event and clear all data to prevent other plugins from capturing it - // Even MONITOR priority listeners with ignoreCancelled=false won't get useful data - // Message will be delivered by ChannelMessageHandler instead - // Logging is handled by our own ChannelMessageLogger - event.isCancelled = true - event.viewers().clear() - event.message(Component.empty()) - channelMessageHandler.sendChannelMessage(player, messageWithoutPrefix) + if (hasActiveChannel) { + // Cancel event and clear all data to prevent other plugins from capturing it + // Even MONITOR priority listeners with ignoreCancelled=false won't get useful data + // Message will be delivered by ChannelMessageHandler instead + // Logging is handled by our own ChannelMessageLogger + event.isCancelled = true + event.viewers().clear() + event.message(Component.empty()) + channelMessageHandler.sendChannelMessage(player, messageWithoutPrefix) + } else { + // Auto-fallback to global chat (with Velocity support if enabled) + handleGlobalChat(event, displayMessage) + } } else { - // Auto-fallback to global chat - event.message(Component.text(displayMessage)) - - // Send warning to player - player.sendMessage( - MessageFormatter.format( - languageManager.getMessage("channel.autoFallback"), - ), - ) + // Channel chat not available, fallback to global chat (with Velocity support if enabled) + handleGlobalChat(event, displayMessage) } } } diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt new file mode 100644 index 0000000..53eced1 --- /dev/null +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/CrossServerChatManager.kt @@ -0,0 +1,206 @@ +package dev.m1sk9.lunaticChat.paper.velocity + +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage +import dev.m1sk9.lunaticChat.paper.config.LunaticChatConfiguration +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer +import org.bukkit.plugin.Plugin +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import java.util.logging.Level +import java.util.logging.Logger + +/** + * Manages cross-server global chat messages + * + * Handles: + * - Sending global chat messages to Velocity + * - Processing incoming messages from Velocity + * - Message deduplication using LRU cache + */ +class CrossServerChatManager( + private val plugin: Plugin, + private val logger: Logger, + private val configuration: LunaticChatConfiguration, + private val cacheSize: Int = 100, +) { + companion object { + private const val CLEANUP_THRESHOLD_MILLIS = 60_000L + } + + /** + * Cache of recently processed message IDs (messageId -> timestamp) + * Used for deduplication + */ + private val processedMessages = ConcurrentHashMap<String, Long>() + + /** + * Sends a global chat message to Velocity for cross-server broadcast + * + * @param playerId Player UUID + * @param playerName Player name + * @param message Chat message content + */ + fun sendGlobalMessage( + playerId: UUID, + playerName: String, + message: String, + ) { + try { + val messageId = UUID.randomUUID().toString() + val serverName = configuration.features.velocityIntegration.serverName + + // Mark as processed immediately to prevent echo + processedMessages[messageId] = System.currentTimeMillis() + + val globalChatMessage = + PluginMessage.GlobalChatMessage( + messageId = messageId, + serverName = serverName, + playerId = playerId.toString(), + playerName = playerName, + message = message, + ) + + // Schedule Bukkit API calls on the main server thread + plugin.server.scheduler.runTask( + plugin, + Runnable { + try { + // Send to Velocity + val player = plugin.server.getPlayer(playerId) + if (player != null) { + player.sendPluginMessage( + plugin, + "lunaticchat:main", + dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec + .encode(globalChatMessage), + ) + logger.info("Sent global chat message to Velocity: messageId=$messageId, player=$playerName") + } else { + logger.warning("Cannot send global chat message: player $playerId not found") + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Failed to send plugin message on main thread", e) + } + }, + ) + + // Cleanup old messages if cache is too large + if (processedMessages.size > cacheSize) { + cleanupOldMessages() + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Failed to send global chat message", e) + } + } + + /** + * Handles incoming global chat message from Velocity + * + * @param message Global chat message + */ + fun handleIncomingMessage(message: PluginMessage.GlobalChatMessage) { + try { + // Check if already processed (deduplication) + if (!shouldProcessMessage(message.messageId)) { + logger.fine("Ignoring duplicate message: messageId=${message.messageId}") + return + } + + // Mark as processed + processedMessages[message.messageId] = System.currentTimeMillis() + + // Broadcast to all players on this server + val formattedMessage = formatCrossServerMessage(message) + + plugin.server.scheduler.runTask( + plugin, + Runnable { + plugin.server.onlinePlayers.forEach { player -> + player.sendMessage(formattedMessage) + } + }, + ) + + logger.info( + "Broadcasted global chat message from ${message.serverName}: " + + "player=${message.playerName}, messageId=${message.messageId}", + ) + + // Cleanup if needed + if (processedMessages.size > cacheSize) { + cleanupOldMessages() + } + } catch (e: Exception) { + logger.log(Level.SEVERE, "Failed to handle incoming global chat message", e) + } + } + + /** + * Formats a cross-server chat message using the configured format + * + * @param message Global chat message + * @return Formatted Component + */ + private fun formatCrossServerMessage(message: PluginMessage.GlobalChatMessage): Component { + val format = configuration.messageFormat.crossServerGlobalChatFormat + val formattedText = + format + .replace("{server}", message.serverName) + .replace("{sender}", message.playerName) + .replace("{message}", message.message) + + return LegacyComponentSerializer.legacySection().deserialize(formattedText) + } + + /** + * Checks if a message should be processed (not a duplicate) + * + * @param messageId Message ID to check + * @return true if message should be processed, false if it's a duplicate + */ + private fun shouldProcessMessage(messageId: String): Boolean = !processedMessages.containsKey(messageId) + + /** + * Removes old messages from the cache (LRU cleanup) + * Keeps only the most recent messages + */ + private fun cleanupOldMessages() { + try { + val currentTime = System.currentTimeMillis() + val cutoffTime = currentTime - CLEANUP_THRESHOLD_MILLIS + + // Collect keys to remove (ConcurrentHashMap iterator doesn't support remove()) + val keysToRemove = mutableListOf<String>() + processedMessages.entries.forEach { entry -> + if (entry.value < cutoffTime) { + keysToRemove.add(entry.key) + } + } + + // Remove expired entries + keysToRemove.forEach { key -> + processedMessages.remove(key) + } + var removedCount = keysToRemove.size + + // If still over cache size, remove oldest entries + if (processedMessages.size > cacheSize) { + val sortedEntries = processedMessages.entries.sortedBy { it.value } + val toRemove = processedMessages.size - cacheSize + + sortedEntries.take(toRemove).forEach { entry -> + processedMessages.remove(entry.key) + removedCount++ + } + } + + if (removedCount > 0) { + logger.fine("Cleaned up $removedCount old messages from deduplication cache") + } + } catch (e: Exception) { + logger.log(Level.WARNING, "Failed to cleanup old messages", e) + } + } +} diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt index e77910b..1a7e667 100644 --- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt +++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/velocity/VelocityConnectionManager.kt @@ -16,6 +16,7 @@ class VelocityConnectionManager( private val plugin: Plugin, private val pluginVersion: String, private val logger: Logger, + private var crossServerChatManager: CrossServerChatManager? = null, ) : PluginMessageListener { companion object { private const val CHANNEL = "lunaticchat:main" @@ -61,6 +62,16 @@ class VelocityConnectionManager( } /** + * Sets the cross-server chat manager + * Called after initialization to avoid circular dependency + * + * @param manager Cross-server chat manager + */ + fun setCrossServerChatManager(manager: CrossServerChatManager) { + this.crossServerChatManager = manager + } + + /** * Performs handshake * * @param player Player to use for sending messages @@ -150,6 +161,10 @@ class VelocityConnectionManager( /** * Plugin message received + * + * @param channel plugin message channel + * @param player player who sent the message + * @param message message byte array */ override fun onPluginMessageReceived( channel: String, @@ -159,11 +174,10 @@ class VelocityConnectionManager( if (channel != CHANNEL) return try { - val pluginMessage = PluginMessageCodec.decode(message) - - when (pluginMessage) { + when (val pluginMessage = PluginMessageCodec.decode(message)) { is PluginMessage.HandshakeResponse -> handleHandshakeResponse(pluginMessage) is PluginMessage.StatusResponse -> handleStatusResponse(pluginMessage) + is PluginMessage.GlobalChatMessage -> handleGlobalChatMessage(pluginMessage) else -> logger.warning("Unexpected message type: ${pluginMessage::class.simpleName}") } } catch (e: Exception) { @@ -174,6 +188,8 @@ class VelocityConnectionManager( /** * Handles handshake response + * + * @param response Handshake response message */ private fun handleHandshakeResponse(response: PluginMessage.HandshakeResponse) { val future = handshakeFuture ?: return @@ -195,6 +211,8 @@ class VelocityConnectionManager( /** * Handles status response + * + * @param response Status response message */ private fun handleStatusResponse(response: PluginMessage.StatusResponse) { logger.info( @@ -211,6 +229,20 @@ class VelocityConnectionManager( } /** + * Handles global chat message from Velocity + * + * @param message Global chat message + */ + private fun handleGlobalChatMessage(message: PluginMessage.GlobalChatMessage) { + val manager = crossServerChatManager + if (manager != null) { + manager.handleIncomingMessage(message) + } else { + logger.warning("Received global chat message but CrossServerChatManager is not initialized") + } + } + + /** * Shutdown */ fun shutdown() { @@ -221,16 +253,22 @@ class VelocityConnectionManager( /** * Gets current connection state + * + * @return Connection state */ fun getState(): ConnectionState = state /** * Gets Velocity version (if connected) + * + * @return Velocity version or null if not connected */ fun getVelocityVersion(): String? = velocityVersion /** * Gets last error message + * + * @return Last error message or null if none */ fun getLastError(): String? = lastError } diff --git a/platform-paper/src/main/resources/config.yml b/platform-paper/src/main/resources/config.yml index 7968717..3788917 100644 --- a/platform-paper/src/main/resources/config.yml +++ b/platform-paper/src/main/resources/config.yml @@ -69,6 +69,15 @@ features: # If enabled, enables integration with Velocity proxy plugin. # This allows Paper and Velocity instances to communicate and verify compatibility. enabled: false + # If enabled, global chat messages will be shared across all Paper servers connected to the Velocity proxy. + # Players on different servers can communicate through the GLOBAL chat mode. + crossServerGlobalChat: false + # Server name to display in cross-server chat (e.g., "survival", "creative", "lobby"). + # This should match the server name defined in your Velocity configuration. + serverName: "Unknown" + # Size of the message deduplication cache to prevent duplicate messages from appearing. + # Keeps track of the most recent N message IDs to filter out duplicates. + messageDeduplicationCacheSize: 100 # ---------------------------------------------- # --------- Message Format Settings -------- @@ -81,6 +90,7 @@ features: # {recipient} - The name of the message recipient # {message} - The content of the message # {channel} - The name of the chat channel (only for channel chat) +# {server} - The name of the server (only for Velocity cross-server chat) # ---------------------------------------------- messageFormat: @@ -88,4 +98,6 @@ messageFormat: directMessageFormat: "§7[§e{sender} §7>> §e{recipient}§7] §f{message}" # Configure the format for messages sent in channel chat channelMessageFormat: "§7[§b#{channel}§7] §e{sender}: §f{message}" + # Configure the format for global chat messages in Velocity integration + crossServerGlobalChatFormat: "§7[§6{server}§7] §e{sender}: §f{message}" diff --git a/platform-paper/src/main/resources/languages/en.yml b/platform-paper/src/main/resources/languages/en.yml index d8d11fc..f19c494 100644 --- a/platform-paper/src/main/resources/languages/en.yml +++ b/platform-paper/src/main/resources/languages/en.yml @@ -44,7 +44,7 @@ settings: channel: help: - header: "=== Channel Commands ===" + header: "--- Channel Commands ---" create: "/lc channel create <channelId> <name> [description] [isPrivate] - Create a new channel" list: "/lc channel list - List all public channels" join: "/lc channel join <channelId> - Join a channel" @@ -66,7 +66,7 @@ channel: invalidId: "Invalid channel ID '{id}'. Must be 3-30 characters using only letters, numbers, underscores, and hyphens" limitExceeded: "Cannot create channel: server has reached the maximum limit of {limit} channels" list: - header: "=== Public Channels ({count}) ===" + header: "--- Public Channels ({count}) ---" empty: "No public channels available" error: "Failed to retrieve channel list" clickToJoin: "Click to join" @@ -96,7 +96,7 @@ channel: notMember: "You are not a member of channel '{channelId}'" error: "Failed to switch channel" status: - header: "=== Your Channel Status ===" + header: "--- Your Channel Status ---" activeChannel: "Active Channel" noActiveChannel: "No active channel" channelList: "Your Channels ({count})" @@ -104,7 +104,7 @@ channel: clickToSwitch: "Click to switch" error: "Failed to retrieve channel status" info: - header: "=== Channel Information ===" + header: "--- Channel Information ---" name: "Name" id: "ID" owner: "Owner" @@ -119,7 +119,6 @@ channel: playerLeft: "{player} left {channel}" playerKicked: "{player} was kicked from {channel} by {kicker}" playerBanned: "{player} was banned from {channel} by {banner}" - autoFallback: "No active channel - message sent to global chat" invite: success: "Invited {player} to {channel}" noActiveChannel: "You don't have an active channel" @@ -200,6 +199,7 @@ toggle: velocity: status: + header: "--- LunaticChat Velocity Cooperation Status ---" paperVersion: "Paper Plugin Version: v{version}" velocityVersion: "Velocity Plugin Version: v{version}" protocolVersion: "Protocol Version: v{version}" diff --git a/platform-paper/src/main/resources/languages/ja.yml b/platform-paper/src/main/resources/languages/ja.yml index 2300754..ed02f5a 100644 --- a/platform-paper/src/main/resources/languages/ja.yml +++ b/platform-paper/src/main/resources/languages/ja.yml @@ -44,7 +44,7 @@ settings: channel: help: - header: "=== チャンネルコマンド ===" + header: "--- チャンネルコマンド ---" create: "/lc channel create <チャンネルID> <名前> [説明] [プライベート] - チャンネルを作成します" list: "/lc channel list - 公開チャンネル一覧を表示します" join: "/lc channel join <チャンネルID> - チャンネルに参加します" @@ -66,7 +66,7 @@ channel: invalidId: "無効なチャンネルID '{id}' です。3-30文字の英数字、アンダースコア、ハイフンのみ使用できます" limitExceeded: "チャンネルを作成できません: サーバーはチャンネル数の上限 ({limit}件) に達しています" list: - header: "=== 公開チャンネル ({count}件) ===" + header: "--- 公開チャンネル ({count}件) ---" empty: "公開チャンネルはありません" error: "チャンネル一覧の取得に失敗しました" clickToJoin: "クリックして参加" @@ -96,7 +96,7 @@ channel: notMember: "チャンネル '{channelId}' のメンバーではありません" error: "チャンネルの切り替えに失敗しました" status: - header: "=== あなたのチャンネルステータス ===" + header: "--- あなたのチャンネルステータス ---" activeChannel: "アクティブチャンネル" noActiveChannel: "アクティブなチャンネルがありません" channelList: "参加中のチャンネル ({count}件)" @@ -104,7 +104,7 @@ channel: clickToSwitch: "クリックして切り替え" error: "チャンネルステータスの取得に失敗しました" info: - header: "=== チャンネル情報 ===" + header: "--- チャンネル情報 ---" name: "名前" id: "ID" owner: "オーナー" @@ -119,7 +119,6 @@ channel: playerLeft: "{player}がチャンネル {channel} から退出しました" playerKicked: "{player}は{kicker}によってチャンネル {channel} から追放されました" playerBanned: "{player}は{banner}によってチャンネル {channel} から永久追放されました" - autoFallback: "アクティブなチャンネルがないため、メッセージはグローバルチャットに送信されました" invite: success: "{player}をチャンネル {channel} に招待しました" noActiveChannel: "アクティブなチャンネルがありません" @@ -200,6 +199,7 @@ toggle: velocity: status: + header: "--- LunaticChat Velocity 連携ステータス ---" paperVersion: "Paper プラグインバージョン: v{version}" velocityVersion: "Velocity プラグインバージョン: v{version}" protocolVersion: "プロトコルバージョン: v{version}" diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt index 1d7fb90..77b8c97 100644 --- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/LunaticChat.kt @@ -6,6 +6,7 @@ import com.velocitypowered.api.event.proxy.ProxyInitializeEvent import com.velocitypowered.api.event.proxy.ProxyShutdownEvent import com.velocitypowered.api.plugin.Plugin import com.velocitypowered.api.proxy.ProxyServer +import dev.m1sk9.lunaticChat.velocity.messaging.CrossServerChatRelay import dev.m1sk9.lunaticChat.velocity.messaging.PluginMessageHandler import org.slf4j.Logger @@ -29,11 +30,19 @@ class LunaticChat private val logger: Logger, ) { private var messageHandler: PluginMessageHandler? = null + private var crossServerChatRelay: CrossServerChatRelay? = null @Subscribe fun onProxyInitialization(event: ProxyInitializeEvent) { logger.info("Initializing LunaticChat Velocity plugin") + // Initialize cross-server chat relay + crossServerChatRelay = + CrossServerChatRelay( + server = server, + logger = logger, + ) + // Initialize plugin message handler messageHandler = PluginMessageHandler( @@ -41,6 +50,7 @@ class LunaticChat server = server, logger = logger, pluginVersion = "0.8.0", + crossServerChatRelay = crossServerChatRelay!!, ) messageHandler?.initialize() diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt new file mode 100644 index 0000000..bedc302 --- /dev/null +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/CrossServerChatRelay.kt @@ -0,0 +1,53 @@ +package dev.m1sk9.lunaticChat.velocity.messaging + +import com.velocitypowered.api.proxy.ProxyServer +import com.velocitypowered.api.proxy.messages.MinecraftChannelIdentifier +import com.velocitypowered.api.proxy.server.RegisteredServer +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessage +import dev.m1sk9.lunaticChat.engine.protocol.PluginMessageCodec +import org.slf4j.Logger + +/** + * Relays global chat messages between servers + * + * When a Paper server sends a global chat message to Velocity, + * this class broadcasts it to all other connected servers. + */ +class CrossServerChatRelay( + private val server: ProxyServer, + private val logger: Logger, +) { + companion object { + private val CHANNEL = MinecraftChannelIdentifier.create("lunaticchat", "main") + } + + /** + * Relays a global chat message to all servers except the source + * + * @param message Global chat message to relay + * @param sourceServer The server that sent the message + */ + fun relayGlobalMessage( + message: PluginMessage.GlobalChatMessage, + sourceServer: RegisteredServer, + ) { + try { + val encodedMessage = PluginMessageCodec.encode(message) + var relayCount = 0 + + server.allServers + .filter { it != sourceServer } + .forEach { targetServer -> + targetServer.sendPluginMessage(CHANNEL, encodedMessage) + relayCount++ + } + + logger.info( + "Relayed global chat message from ${message.serverName} to $relayCount servers " + + "(messageId=${message.messageId}, player=${message.playerName})", + ) + } catch (e: Exception) { + logger.error("Failed to relay global chat message: ${e.message}", e) + } + } +} diff --git a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt index b235ba1..fb69fa4 100644 --- a/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt +++ b/platform-velocity/src/main/kotlin/dev/m1sk9/lunaticChat/velocity/messaging/PluginMessageHandler.kt @@ -18,6 +18,7 @@ class PluginMessageHandler( private val server: ProxyServer, private val logger: Logger, private val pluginVersion: String, + private val crossServerChatRelay: CrossServerChatRelay, ) { companion object { private val CHANNEL = MinecraftChannelIdentifier.create("lunaticchat", "main") @@ -46,15 +47,16 @@ class PluginMessageHandler( } try { - val message = PluginMessageCodec.decode(event.data) - - when (message) { + when (val message = PluginMessageCodec.decode(event.data)) { is PluginMessage.Handshake -> { handleHandshake(source, message) } is PluginMessage.StatusRequest -> { handleStatusRequest(source) } + is PluginMessage.GlobalChatMessage -> { + handleGlobalChatMessage(source, message) + } else -> { logger.warn("Unexpected message type from Paper: ${message::class.simpleName}") } @@ -152,6 +154,21 @@ class PluginMessageHandler( } /** + * Handles global chat message and relays to other servers + */ + private fun handleGlobalChatMessage( + connection: ServerConnection, + message: PluginMessage.GlobalChatMessage, + ) { + logger.info( + "Received global chat message from ${connection.serverInfo.name}: " + + "messageId=${message.messageId}, player=${message.playerName}", + ) + + crossServerChatRelay.relayGlobalMessage(message, connection.server) + } + + /** * Shutdown */ fun shutdown() { @@ -6,17 +6,19 @@ DOCKER_COMPOSE="docker compose -f docker/compose.yaml" help() { cat <<EOF -Usage: ./x <command> +Usage: ./x <command> [options] Commands: - start Build with gradlew and start docker compose - restart Restart minecraft and velocity containers - stop Stop docker compose - clean Stop docker compose and remove volumes - rcon Open rcon-cli for minecraft container - logs Show minecraft container logs - vlogs Show velocity container logs - help Show this help message + start Build with gradlew and start docker compose + restart Restart s1, s2 and velocity containers + stop Stop docker compose + clean Stop docker compose and remove volumes + rcon [server] Open rcon-cli for server container (default: s1) + Available servers: s1, s2 + logs [server] Show server container logs (default: s1) + Available servers: s1, s2 + vlogs Show velocity container logs + help Show this help message EOF } @@ -27,7 +29,7 @@ case "${1:-}" in $DOCKER_COMPOSE up ;; restart) - $DOCKER_COMPOSE restart minecraft velocity + $DOCKER_COMPOSE restart s1 s2 velocity ;; stop) $DOCKER_COMPOSE down @@ -36,10 +38,20 @@ case "${1:-}" in $DOCKER_COMPOSE down -v ;; rcon) - $DOCKER_COMPOSE exec minecraft rcon-cli + SERVER="${2:-s1}" + if [[ "$SERVER" != "s1" && "$SERVER" != "s2" ]]; then + echo "Error: Invalid server '$SERVER'. Available servers: s1, s2" + exit 1 + fi + $DOCKER_COMPOSE exec $SERVER rcon-cli ;; logs) - $DOCKER_COMPOSE logs -f minecraft + SERVER="${2:-s1}" + if [[ "$SERVER" != "s1" && "$SERVER" != "s2" ]]; then + echo "Error: Invalid server '$SERVER'. Available servers: s1, s2" + exit 1 + fi + $DOCKER_COMPOSE logs -f $SERVER ;; vlogs) $DOCKER_COMPOSE logs -f velocity |
