platform-paper - Paper / Folia プラグイン本体
platform-paper はプラグインの本体です.
Bukkit / Paper / Folia API・Adventure・Brigadier・Plugin Messaging といったプラットフォーム API との橋渡しに徹する層で,ドメインモデルやアルゴリズム・プロトコルは engine に委譲します.
paper 側は「Bukkit / Folia という現実」を吸収するアダプタとして働き,engine の純粋なモデルをプラットフォームの都合 (スケジューラ・スレッド・イベント) に接続するのが役割です.
エントリポイントと DI (Service Container)
外部 DI フレームワークを使わず,手動 DI でサービスを組み立てます.「構築の責務」と「保持の責務」を分離しているのが要点です.
LunaticChat(JavaPlugin+Listener) — プラグインのエントリポイントServiceInitializer— サービスの構築・初期化順序・shutdown を担当ServiceContainer— 構築済みサービスを保持するイミュータブルなdata classPluginCoroutineScope—SupervisorJob+Dispatchers.Default.UpdateCheckerなどの非ブロッキング実行に使う
ライフサイクル
onEnable の流れは次の通りです.
saveDefaultConfig()→ConfigManagerでLunaticChatConfigurationを生成し,MessageFormatHolderとConfigurationReloaderを構築HttpClient(CIO)とPluginCoroutineScopeを初期化ServiceInitializer.initialize()→ServiceContainerを受け取る- コマンドから使う公開プロパティへサービスを移し替え
schedulePeriodicTasks()→registerCommands()→registerEventListeners()checkForUpdatesが有効ならUpdateCheckerを起動
onDisable は pluginScope.cancel() → serviceInitializer.shutdown() の順で,設定・キャッシュ・チャンネル・ログ・Velocity 接続を順に閉じます.
ServiceContainer と ServiceInitializer
ServiceContainer は,常時利用可能なサービス (languageManager / playerSettingsManager / directMessageHandler) を非 null,機能ゲート対象 (channelManager / velocityConnectionManager など) を nullable フィールド (デフォルト null) として保持します.null-assertion (!!) をコードから排除する狙いです.
ServiceInitializer.initialize() は依存順にサービスを生成します.
LanguageManager(コマンドより前,全機能の前提)PlayerSettingsManager(DM 通知などに常時必要)- Japanese conversion (optional)
- Channel 群 —
ChannelManager/ChannelMembershipManager/ChannelMessageHandler/ChannelNotificationHandler,ログ有効時はChannelMessageLogger(optional) DirectMessageHandler(settings・romaji・language に依存)- Velocity integration (optional)
- 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 許容性で表現しています.
設計思想の全体像は 設計概要 を参照してください.
コマンドフレームワーク (アノテーション駆動 + 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 でフックします.
処理の流れ:
- メッセージを plain text 化し,先頭の
!(グローバル強制プレフィックス) を判定する !のみで本文が空なら,イベントをキャンセルして終了する (空メッセージを流さない)- 送信者の設定でローマ字変換が有効なら
convertWithRomajiを通す 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 が返信先を sealed interface ReplyTarget (Local = UUID / Remote = プレイヤー名+サーバー名) として追跡し,getReplyTarget() は「自分に送ってきた人 → 自分が送った人」の優先順で解決しながら検証します.Local はオンラインであること,Remote は RemotePlayerRegistry がそのサーバーに在席を報告していることが条件です.
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を KAML でLunaticChatConfigurationにデシリアライズする.デフォルト値の定義箇所をデータクラス 1 箇所に限定するためで,以前のドット記法の手組みマッパーは同じデフォルトを二重に持っており,実際に乖離していた (checkForUpdatesがconfig.ymlとデータクラスの双方と食い違い,messageLoggingブロックはドキュメント化されていながら一度も読まれていなかった)- 失敗はファイル単位ではなく設定単位で処理する.
YamlExceptionが出た場合は該当キーをドキュメントから取り除いてデコードを再試行するため,読めない値 1 つの影響はその値だけに留まる.全体をデフォルトに落とすのは「YAML として成立していない」場合だけで,いずれのケースもonEnableの外へ例外を投げない ConfigManager.loadStrictly—/lc reloadが使う読み取り経路.pruning ループはloadConfigurationと共有するが,結果の解釈が逆で,1 つでも pruning が発生した時点でファイル全体を拒否し,読み取れなかった設定をまとめて報告する.設定を含まないドキュメントも拒否する.コメントのみのファイルと書き込み途中のファイルを見分けられず,それを理由に全フォーマットをリセットするのは何もしないより悪いためMessageFormatHolder— 設定のうち唯一実行時に差し替わる部分.LunaticChatConfigurationより意図的に狭く,この型を受け取ることが「値が変わりうる」,LunaticChatConfigurationを受け取ることが「起動時に凍結された」というシグナルになる.設定全体を holder に入れると,差し替わるのはmessageFormatだけであるため,どのバージョンのファイルとも一致しないツリーができてしまうConfigurationReloader— ファイルを読み直して holder を差し替える.各設定を「適用済み」(MessageFormatConfigから leaf 単位) と「再起動が必要」(データクラスのequalsに乗せた block 単位) に分類し,memberPropertiesを走査するテストが,どちらの表にも載っていない設定がconfig.ymlに増えた時点で失敗する.2 つのリストは比較基準が異なり,適用済みは現在有効なフォーマットとの差分,再起動が必要は実際にサーバーが起動した設定との差分である.messageFormat以外を適用できないのは,Paper がコマンドをCOMMANDSライフサイクルイベントでしか登録できず,Bukkit にリスナーの解除手段がなく,キャッシュ保存タスクがキャンセル用のハンドルを保持していないためLenientBoolean—yes/no/on/offも受け付けるシリアライザ付きのBooleantypealias.Bukkit はconfig.ymlを YAML 1.1 として読んでいたためこれらは真偽値だったが,kaml が読む YAML 1.2 では単なる文字列であり,黙ってリセットするとcheckForUpdates: noがデフォルトの逆の値に反転してしまう- 機能デフォルト:
quickReplies=true,japaneseConversion=false,channelChat=false,velocityIntegration=false config/key以下にFeaturesConfig/ChannelChatFeatureConfig/JapaneseConversionFeatureConfig/VelocityIntegrationConfig/QuickRepliesFeatureConfig/MessageFormatConfig/ChannelMessageLoggingConfig
i18n
Language(enum) —EN/JA.未知コードは EN にフォールバックLanguageManager— 起動時にresources/languages/を KAML でロードし,ネストした YAML をドット記法 (toggle.on等) にフラット化する.getMessage(key, placeholders)は 選択言語 → EN フォールバック で解決し{placeholder}を置換,未発見はキー自身を返す.EN が無ければ致命エラーMessageFormatter(object) —[LC]プレフィックス付きの AdventureComponentを生成し,{braces}プレースホルダを正規表現で検出して色分けする
converter — ローマ字→日本語変換
ローマ字変換はアルゴリズム・API クライアント・キャッシュ・プラットフォーム都合 (タイムアウト,スケジューリング) のすべてがここにあります.かつては「プラットフォーム非依存の純ロジック」として engine にありましたが,呼び出し元は platform-paper だけであり,engine に置いたままでは Velocity の成果物が使わない Ktor を同梱することになるため移されました.
KanaConverter(object) — Trie でローマ字→ひらがなに変換.sealed class TrieNode { Leaf, Branch }の不変構造で,4 文字 (xtsu→っ) 〜1 文字 (a→あ) を網羅.isValidRomaji()で変換前検証,toHiragana()は最長一致+促音処理を行う純アルゴリズムGoogleIMEClient— KtorHttpClientを DI で受け取り,Google IME (langpair=ja-Hira|ja) でひらがな→漢字仮名交じりに変換.レスポンスの各セグメント第 1 候補を連結するRomanjiConverter— 2 段変換のオーケストレータ.単語ごとに キャッシュ確認 →KanaConverter→GoogleIMEClient.単語は並行して変換され,API 失敗時はメッセージ全体を失敗させずひらがなにフォールバックするConversionCache—CacheDataを JSON 永続化.メモリキャッシュ+デバウンス保存 (maxEntries超過時に削除されるのは古い順ではなく任意の 10%,ConcurrentHashMap が順不同であるため,との FIXME あり)RomajiConversionHelper—convertWithRomaji()はsuspend関数でwithTimeoutOrNull(既定 1000ms) により上限が設けられ,成功時"元文 §e(変換)",失敗/タイムアウト時は原文を返す.convertWithRomajiBlocking()はこれをrunBlockingで包んだもので,イベントをキャンセルするか否かを return 前に決めなければならないAsyncChatEventだけが使う.コマンドハンドラは tick スレッドで動くため suspend 版を使う必要がある
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 を使い,未設定はデフォルト trueYamlPlayerSettingsStorage— KAML でplayer-settings.yamlを read/write.5 秒デバウンス保存.バックアップファイルは存在せず,読み込み失敗時はログを出して空の設定にフォールバックするため,全プレイヤーの設定が黙ってデフォルトに戻るUpdateChecker— GitHub Releases API を Ktor で叩き semver 比較.結果は sealedUpdateCheckResultSoundCollector— 通知音の AdventureSound定数と Player 拡張関数PermissionCollector—@PermissionDsl++LunaticChatPermissionNode演算子で権限を集める DSL.requirePermissionは engine のRequirePermissionExceptionを投げる