summaryrefslogtreecommitdiff
path: root/website/src/docs/developers/platform-paper.md
blob: dbba4f107eba350b4c271d66b3d4c8929cb1daf7 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
---
layout: doc
---

# platform-paper - Paper / Folia Plugin

`platform-paper` is the plugin itself.

It is the layer that bridges to the platform APIs — Bukkit / Paper / Folia, Adventure, Brigadier, Plugin Messaging — and delegates domain models, algorithms, and the protocol to [engine](/docs/developers/engine).

The paper side acts as an adapter absorbing "the reality of Bukkit / Folia", connecting `engine`'s pure models to platform concerns (scheduler, threads, events).

## Entry point and DI (Service Container)

Features are assembled via manual DI, without an external DI framework. The key idea is **separating "the responsibility of construction" from "the responsibility of holding"**.

- `LunaticChat` (`JavaPlugin` + `Listener`) — the plugin entry point
- `ServiceInitializer` — handles service construction, initialization order, and shutdown
- `ServiceContainer` — an immutable `data class` holding the constructed services
- `PluginCoroutineScope``SupervisorJob` + `Dispatchers.Default`; used for non-blocking work such as `UpdateChecker`

### Lifecycle

The `onEnable` flow:

1. `saveDefaultConfig()` → build `LunaticChatConfiguration` via `ConfigManager`
2. Initialize `HttpClient(CIO)` and `PluginCoroutineScope`
3. `ServiceInitializer.initialize()` → receive a `ServiceContainer`
4. Move services into the public properties used by commands
5. `schedulePeriodicTasks()``registerCommands()``registerEventListeners()`
6. Start `UpdateChecker` if `checkForUpdates` is enabled

`onDisable` runs `pluginScope.cancel()``serviceInitializer.shutdown()`, closing settings, caches, channels, logs, and the Velocity connection in order.

### ServiceContainer and ServiceInitializer

`ServiceContainer` holds always-available services (`languageManager` / `playerSettingsManager` / `directMessageHandler`) as non-null, and feature-gated ones (`channelManager` / `velocityConnectionManager`, etc.) as nullable fields (default null). The aim is to eliminate null-assertions (`!!`) from the codebase.

`ServiceInitializer.initialize()` creates services in dependency order.

1. `LanguageManager` (before commands; a prerequisite for all features)
2. `PlayerSettingsManager` (always needed, e.g. for DM notifications)
3. Japanese conversion (optional)
4. Channel group — `ChannelManager` / `ChannelMembershipManager` / `ChannelMessageHandler` / `ChannelNotificationHandler`, plus `ChannelMessageLogger` when logging is enabled (optional)
5. `DirectMessageHandler` (depends on settings, romaji, language)
6. Velocity integration (optional)
7. Cross-server chat (only when velocity is enabled, `crossServerGlobalChat` is on, and the velocity manager is non-null)

### Feature Gating

This `initialize()` is where feature toggling actually happens. Japanese conversion / Channel group / Velocity integration / Cross-server chat are **created only when their config flag is true, and are `null` otherwise**.

```
config flag
  → ServiceInitializer creates a nullable service
  → stored in a nullable field on ServiceContainer
  → command / listener / SettingHandler registration branches on a null check
```

A disabled feature's service simply does not exist at the type level, and its code path is never built. The presence of a feature is expressed through Kotlin's null-safety.

For the design rationale, see the [Design Overview](/docs/developers/architecture#service-container-pattern-feature-gating).

## Command framework (annotation-driven + Brigadier)

A command's definition and its metadata (permission, aliases, player-only) are declared together in one place, then **read via Kotlin reflection and mapped onto the Brigadier tree**.

### Annotations

- `@Command(name, aliases, description)` — command name, aliases, description
- `@Permission(KClass<out LunaticChatPermissionNode>)` — required permission (specified by type via the engine's permission node)
- `@PlayerOnly` — a player-only marker

### LunaticCommand

The abstract base for all commands. It lazily reads the annotations on the class, and `buildWithChecks()` wraps the subclass's `buildCommand()` to inject shared behavior.

- If `@Deprecated` is present, it swaps in a handler that returns an error message at runtime
- If `@Permission` is present, it attaches Brigadier's `.requires { source.sender.hasPermission(perm) }`
- `handleResult()` converts the engine's `CommandResult` into an Adventure message plus the `Int` from `toBrigadierResult()`
- `withAliases()` clones a Brigadier node to create alias nodes, and `applyMethodPermission()` reflects a **method-level** `@Permission`

### CommandRegistry

`register` / `registerAll` accumulate commands, and `initialize()` registers a handler on Paper's `LifecycleEvents.COMMANDS`. The actual Brigadier tree construction (`buildWithChecks().build()`) happens inside that lifecycle event.

### Convention: root and nested subcommands

- **Root command** — annotate the class with `@Command`
- **Nested subcommand** — no `@Command`; apply permission via a `build()` method plus a method-level `@Permission` and `applyMethodPermission("build", …)`

### Command hierarchy

| Command | Aliases | Registration condition |
|---------|---------|------------------------|
| `lc` (→ settings / status / channel) | `lunaticchat` | Always |
| `channel` (14 subcommands) | `ch` | When channelChat is enabled |
| `tell` | `t` / `msg` / `m` / `w` / `whisper` | Always |
| `reply` | `r` | When quickReplies is enabled |
| `lcv` (→ status) | `lunaticvelocity` | When velocity is enabled |

`settings` iterates `SettingKey.values()` to dynamically generate on/off/status nodes for each key and delegates to `SettingHandlerRegistry`. Adding a setting is a three-step process: "add a SettingKey → implement a Handler → register it in the Registry".

## Chat processing

### Routing (PlayerChatListener)

This is where routing happens, deciding **"local (channel) vs. global (possibly via the proxy)"**. It hooks `AsyncChatEvent` at `EventPriority.HIGHEST, ignoreCancelled = true`.

Flow:

1. Serialize the message to plain text and check for a leading `!` (the force-global prefix)
2. If it is `!` with an empty body, cancel the event and return (don't emit an empty message)
3. If the sender has romaji conversion enabled, run it through `convertWithRomaji`
4. Determine whether the player has an active channel via `channelManager.getPlayerChannel()`

Branches:

- **Active channel and no `!`** → `event.isCancelled = true` + `viewers().clear()` + `message(empty)` to stop normal chat, then route to `ChannelMessageHandler.sendChannelMessage()` (local to the server)
- **Otherwise** (no active channel, or a `!` prefix) → `handleGlobalChat()`. If velocity cross-server is enabled, send to `CrossServerChatManager.sendGlobalMessage()` while also displaying normal chat; otherwise, normal chat only

### Direct messages (DirectMessageHandler)

Manages `/tell`・`/reply` state. Two `ConcurrentHashMap`s, `lastMessager` / `lastRecipient`, track reply targets 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 in `channelsCache` / `membersCache` / `activeChannels` (`ConcurrentHashMap`), and its CRUD returns `kotlin.Result`, wrapping engine exceptions on failure. It checks config limits (0 = unlimited)
- `ChannelMembershipManager` — the business logic for join/leave/switch/role. `joinChannel()` checks existence / already-active / BAN / private-invite / already-a-member / membership limit in order
- `ChannelStorage` — persists `ChannelData` as JSON (`channels.json`)
- `ChannelMessageLogger` — an asynchronous NDJSON logger with daily rotation, a size cap, and periodic deletion of files past the retention period

## Listener registration

- `EventListenerRegistry` (`object`) — `SpyPermissionManager` and `PlayerPresenceListener` are always registered; `PlayerChatListener` is registered only when channel / velocity cross-server / romaji is enabled (Feature Gating again)
- `PlayerPresenceListener` — on Join: update notification, nightly warning, active-channel restoration notice; on Quit: clear DM references, deactivate the active channel, and save settings
- `SpyPermissionManager` (`object : Listener`) — caches holders of the `Spy` permission on join/quit; referenced by the DM and channel handlers

## config

- `ConfigManager` — deserializes `config.yml` into `LunaticChatConfiguration` with **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 — `checkForUpdates` disagreed with both `config.yml` and the data class, and the whole `messageLogging` block was documented but never read
- Failure is handled per setting, not per file: on a `YamlException` the 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 of `onEnable`
- `LenientBoolean` — a `Boolean` typealias with a serializer that still accepts `yes` / `no` / `on` / `off`. Bukkit read `config.yml` as YAML 1.1, where those are booleans; kaml reads YAML 1.2, where they are plain strings, and silently resetting them would have flipped `checkForUpdates: no` to 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 EN
- `LanguageManager` — loads `resources/languages/` with KAML at startup and flattens the nested YAML into dotted keys (`toggle.on`, etc.). `getMessage(key, placeholders)` resolves with selected-language → EN fallback and substitutes `{placeholder}`, returning the key itself if not found. A missing EN is a fatal error
- `MessageFormatter` (`object`) — produces an Adventure `Component` with a `[LC]` prefix and highlights `{braces}` placeholders detected by regex

## converter — 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 immutable `sealed 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 handling
- `GoogleIMEClient` — receives a Ktor `HttpClient` via DI and converts hiragana to kanji-kana via Google IME (`langpair=ja-Hira|ja`), concatenating the top candidate of each segment
- `RomanjiConverter` — 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 message
- `ConversionCache` — persists `CacheData` as JSON. In-memory cache plus debounced save (a FIXME notes that eviction on `maxEntries` overflow drops an arbitrary 10%, not the oldest, because `ConcurrentHashMap` is unordered)
- `RomajiConversionHelper``convertWithRomaji()` is `suspend` and bounded by `withTimeoutOrNull` (default 1000ms), returning `"original §e(converted)"` on success and the original text on failure or timeout. `convertWithRomajiBlocking()` wraps it in `runBlocking` for `AsyncChatEvent`, 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`) — manages `ConnectionState` (DISCONNECTED / HANDSHAKING / CONNECTED / FAILED). It encodes and sends the engine's `PluginMessage.Handshake`, timing out after 5 seconds. To avoid a circular dependency, `CrossServerChatManager` is injected afterward (setter injection)
- The handshake runs **only once, triggered by the first player join** (`AtomicBoolean`). It is scheduled 1 second after the join via `asyncScheduler`, and the result is received as `HandshakeResult.Success` / `Error`
- `CrossServerChatManager` — the send/receive and **deduplication** of global chat. On send, it registers the generated `messageId` in the cache immediately to prevent an echo on its own server (stage one); on receive, it prevents duplicate display with a dedup cache keyed by `messageId` (TTL 60s, oldest-first cleanup when over `cacheSize`). Bukkit API calls are moved to the main thread via `scheduler.runTask`

## settings / common

- `PlayerSettingsManager` — manages three boolean settings in `ConcurrentHashMap`s. Uses the engine DTOs; unset values default to true
- `YamlPlayerSettingsStorage` — reads/writes `player-settings.yaml` with KAML; 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 defaults
- `UpdateChecker` — hits the GitHub Releases API via Ktor and compares semver. The result is a sealed `UpdateCheckResult`
- `SoundCollector` — Adventure `Sound` constants for notifications plus Player extension functions
- `PermissionCollector` — a DSL that collects permissions via `@PermissionDsl` + the `+LunaticChatPermissionNode` operator. `requirePermission` throws the engine's `RequirePermissionException`

## Related

- [Design Overview](/docs/developers/architecture)
- [engine - Shared Kernel](/docs/developers/engine)
- [platform-velocity - Velocity Plugin](/docs/developers/platform-velocity)