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.
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 pointServiceInitializer— handles service construction, initialization order, and shutdownServiceContainer— an immutabledata classholding the constructed servicesPluginCoroutineScope—SupervisorJob+Dispatchers.Default; used for non-blocking work such asUpdateChecker
Lifecycle
The onEnable flow:
saveDefaultConfig()→ buildLunaticChatConfigurationviaConfigManager, then create theMessageFormatHolderand theConfigurationReloaderover it- Initialize
HttpClient(CIO)andPluginCoroutineScope ServiceInitializer.initialize()→ receive aServiceContainer- Move services into the public properties used by commands
schedulePeriodicTasks()→registerCommands()→registerEventListeners()- Start
UpdateCheckerifcheckForUpdatesis 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.
LanguageManager(before commands; a prerequisite for all features)PlayerSettingsManager(always needed, e.g. for DM notifications)- Japanese conversion (optional)
- Channel group —
ChannelManager/ChannelMembershipManager/ChannelMessageHandler/ChannelNotificationHandler, plusChannelMessageLoggerwhen logging is enabled (optional) DirectMessageHandler(depends on settings, romaji, language)- Velocity integration (optional)
- Cross-server chat (only when velocity is enabled,
crossServerGlobalChatis 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 checkA 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.
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
@Deprecatedis present, it swaps in a handler that returns an error message at runtime - If
@Permissionis present, it attaches Brigadier's.requires { source.sender.hasPermission(perm) } handleResult()converts the engine'sCommandResultinto an Adventure message plus theIntfromtoBrigadierResult()withAliases()clones a Brigadier node to create alias nodes, andapplyMethodPermission()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 abuild()method plus a method-level@PermissionandapplyMethodPermission("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:
- Serialize the message to plain text and check for a leading
!(the force-global prefix) - If it is
!with an empty body, cancel the event and return (don't emit an empty message) - If the sender has romaji conversion enabled, run it through
convertWithRomaji - 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 toChannelMessageHandler.sendChannelMessage()(local to the server) - Otherwise (no active channel, or a
!prefix) →handleGlobalChat(). If velocity cross-server is enabled, send toCrossServerChatManager.sendGlobalMessage()while also displaying normal chat; otherwise, normal chat only
Direct messages (DirectMessageHandler)
Manages /tell・/reply state. Two ConcurrentHashMaps, lastMessager / lastRecipient, track reply targets as a sealed interface ReplyTarget of Local (a UUID) or Remote (a player name plus server name). getReplyTarget() resolves in the order "whoever messaged me → whoever I messaged", validating as it goes: a Local target must be online, and a Remote target must still be reported on that server by RemotePlayerRegistry.
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 inchannelsCache/membersCache/activeChannels(ConcurrentHashMap), and its CRUD returnskotlin.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 orderChannelStorage— persistsChannelDataas 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) —SpyPermissionManagerandPlayerPresenceListenerare always registered;PlayerChatListeneris 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 settingsSpyPermissionManager(object : Listener) — caches holders of theSpypermission on join/quit; referenced by the DM and channel handlers
config
ConfigManager— deserializesconfig.ymlintoLunaticChatConfigurationwith KAML, so each default lives in exactly one place: on the data class. It replaced a hand-written dotted-key mapper that repeated every default a second time, and they had already drifted —checkForUpdatesdisagreed with bothconfig.ymland the data class, and the wholemessageLoggingblock was documented but never read- Failure is handled per setting, not per file: on a
YamlExceptionthe offending key is pruned from the document and decoding is retried, so one unreadable value costs only itself. Only a document that is not YAML at all falls back to defaults wholesale, and neither case is allowed to throw out ofonEnable ConfigManager.loadStrictly— the reading/lc reloaduses. It shares the pruning loop withloadConfigurationbut reads the outcome the other way round: any setting that had to be pruned means the whole file is refused, with every unreadable setting named at once. A document holding no settings is refused too, because a reload cannot tell a comments-only file apart from one caught mid-write, and resetting every format over that is worse than doing nothingMessageFormatHolder— the one part of the configuration that is live. Deliberately narrower thanLunaticChatConfiguration: taking this type says the value can change under you, takingLunaticChatConfigurationsays it was frozen at startup. A holder over the whole configuration would produce a tree matching no version of the file, since onlymessageFormatis ever swappedConfigurationReloader— re-reads the file and swaps the holder. It classifies every setting into applied (leaf by leaf, fromMessageFormatConfig) or restart-required (block by block, riding on the data classequals), and a test overmemberPropertiesfails whenconfig.ymlgrows a setting neither table covers. The two lists are measured against different baselines: applied against the formats currently in effect, restart-required against the configuration the server actually started on. Nothing beyondmessageFormatcan be applied, because Paper registers commands only through itsCOMMANDSlifecycle event, Bukkit cannot unregister a listener, and the cache saver keeps no task handle to cancelLenientBoolean— aBooleantypealias with a serializer that still acceptsyes/no/on/off. Bukkit readconfig.ymlas YAML 1.1, where those are booleans; kaml reads YAML 1.2, where they are plain strings, and silently resetting them would have flippedcheckForUpdates: noto its opposite default- Feature defaults:
quickReplies=true,japaneseConversion=false,channelChat=false,velocityIntegration=false - Under
config/key:FeaturesConfig/ChannelChatFeatureConfig/JapaneseConversionFeatureConfig/VelocityIntegrationConfig/QuickRepliesFeatureConfig/MessageFormatConfig/ChannelMessageLoggingConfig
i18n
Language(enum) —EN/JA; unknown codes fall back to ENLanguageManager— loadsresources/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 errorMessageFormatter(object) — produces an AdventureComponentwith a[LC]prefix and highlights{braces}placeholders detected by regex
converter — Romaji-to-Japanese conversion
Romaji conversion lives here in full: the algorithm, the API client, the cache, and the platform concerns (timeouts, scheduling). It used to sit in engine as platform-independent pure logic, but platform-paper is its only caller, and keeping it in engine made the Velocity artifact carry Ktor for nothing.
KanaConverter(object) — romaji to hiragana with a Trie. An immutablesealed class TrieNode { Leaf, Branch }covers mappings from 4 characters (xtsu→っ) down to 1 (a→あ).isValidRomaji()validates before conversion;toHiragana()is a pure longest-match algorithm with sokuon handlingGoogleIMEClient— receives a KtorHttpClientvia DI and converts hiragana to kanji-kana via Google IME (langpair=ja-Hira|ja), concatenating the top candidate of each segmentRomanjiConverter— the two-stage orchestrator. Per word: cache lookup →KanaConverter→GoogleIMEClient. Words are converted concurrently, and an API failure degrades to hiragana rather than failing the messageConversionCache— persistsCacheDataas JSON. In-memory cache plus debounced save (a FIXME notes that eviction onmaxEntriesoverflow drops an arbitrary 10%, not the oldest, becauseConcurrentHashMapis unordered)RomajiConversionHelper—convertWithRomaji()issuspendand bounded bywithTimeoutOrNull(default 1000ms), returning"original §e(converted)"on success and the original text on failure or timeout.convertWithRomajiBlocking()wraps it inrunBlockingforAsyncChatEvent, the one caller that must decide whether to cancel the event before returning; command handlers run on the tick thread and must use the suspending form
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) — managesConnectionState(DISCONNECTED / HANDSHAKING / CONNECTED / FAILED). It encodes and sends the engine'sPluginMessage.Handshake, timing out after 5 seconds. To avoid a circular dependency,CrossServerChatManageris injected afterward (setter injection)- The handshake runs only once, triggered by the first player join (
AtomicBoolean). It is scheduled 1 second after the join viaasyncScheduler, and the result is received asHandshakeResult.Success/Error CrossServerChatManager— the send/receive and deduplication of global chat. On send, it registers the generatedmessageIdin the cache immediately to prevent an echo on its own server (stage one); on receive, it prevents duplicate display with a dedup cache keyed bymessageId(TTL 60s, oldest-first cleanup when overcacheSize). Bukkit API calls are moved to the main thread viascheduler.runTask
settings / common
PlayerSettingsManager— manages three boolean settings inConcurrentHashMaps. Uses the engine DTOs; unset values default to trueYamlPlayerSettingsStorage— reads/writesplayer-settings.yamlwith KAML; debounced save (5s). There is no backup file: a load failure is logged and falls back to empty settings, which means every player silently returns to defaultsUpdateChecker— hits the GitHub Releases API via Ktor and compares semver. The result is a sealedUpdateCheckResultSoundCollector— AdventureSoundconstants for notifications plus Player extension functionsPermissionCollector— a DSL that collects permissions via@PermissionDsl+ the+LunaticChatPermissionNodeoperator.requirePermissionthrows the engine'sRequirePermissionException