summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorSho Sakuma <me@m1sk9.dev>2026-09-28 23:43:13 +0900
committerSho Sakuma <me@m1sk9.dev>2026-09-28 23:43:13 +0900
commita22fce70db7b43aadd8baa42e87a4430a8b1e05f (patch)
tree4cb170c50669db3ca7e046b8447294e8201146cc
parent479000d491f8b1363a1e6a6e8f6fac85e0fc74bd (diff)
downloadLunaticChat-a22fce70db7b43aadd8baa42e87a4430a8b1e05f.tar.gz
LunaticChat-a22fce70db7b43aadd8baa42e87a4430a8b1e05f.tar.bz2
LunaticChat-a22fce70db7b43aadd8baa42e87a4430a8b1e05f.zip
fix(paper): splice display names as components and share role labels
Serializing the display name into the legacy text let its color run into everything after it, and legacy text cannot hold hex colors or hover and click events, so names styled by other plugins came out wrong. The name is now spliced into the parsed component instead. {role} is resolved only when the format uses it, since the default formats do not and it costs a member scan and a lookup per message. /lc channel status now shows the same localized role names instead of a second, hard-coded [OWNER] / [MOD] vocabulary that only one of the two places translated. Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt16
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt4
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChannelRoleLabel.kt13
-rw-r--r--platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt50
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt35
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt30
-rw-r--r--platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt62
-rw-r--r--website/src/docs/developers/platform-paper.md2
-rw-r--r--website/src/ja/docs/developers/platform-paper.md2
9 files changed, 178 insertions, 36 deletions
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
index b9a8239..82430a9 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandler.kt
@@ -13,9 +13,9 @@ import dev.m1sk9.lunaticChat.paper.common.playMessageSendNotification
import dev.m1sk9.lunaticChat.paper.config.MessageFormatHolder
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.i18n.channelMessage
+import dev.m1sk9.lunaticChat.paper.i18n.channelRoleLabel
import dev.m1sk9.lunaticChat.paper.settings.PlayerSettingsManager
import net.kyori.adventure.text.Component
-import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
import org.bukkit.Bukkit
import org.bukkit.entity.Player
@@ -98,18 +98,12 @@ class ChannelMessageHandler(
sender = sender.name,
channel = context.channel.name,
channelId = context.channel.id,
- displayName = LegacyComponentSerializer.legacySection().serialize(sender.displayName()),
+ displayName = sender.displayName(),
message = message,
world = sender.world.name,
- role = roleLabel(context.members.firstOrNull { it.playerId == sender.uniqueId }?.role),
- )
-
- private fun roleLabel(role: ChannelRole?): String =
- languageManager.getMessage(
- when (role) {
- ChannelRole.OWNER -> "channel.role.owner"
- ChannelRole.MODERATOR -> "channel.role.moderator"
- ChannelRole.MEMBER, null -> "channel.role.member"
+ role = {
+ val role = context.members.firstOrNull { it.playerId == sender.uniqueId }?.role ?: ChannelRole.MEMBER
+ languageManager.channelRoleLabel(role)
},
)
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt
index ae2ebfe..3b14959 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommand.kt
@@ -12,6 +12,7 @@ import dev.m1sk9.lunaticChat.paper.command.core.CommandContext
import dev.m1sk9.lunaticChat.paper.command.core.LunaticSubCommand
import dev.m1sk9.lunaticChat.paper.i18n.LanguageManager
import dev.m1sk9.lunaticChat.paper.i18n.MessageFormatter
+import dev.m1sk9.lunaticChat.paper.i18n.channelRoleLabel
import io.papermc.paper.command.brigadier.CommandSourceStack
import io.papermc.paper.command.brigadier.Commands
import net.kyori.adventure.text.Component
@@ -90,9 +91,8 @@ class ChannelStatusCommand(
val playerName = Bukkit.getOfflinePlayer(member.playerId).name ?: return@mapNotNull null
val roleText =
when (member.role) {
- ChannelRole.OWNER -> " [OWNER]"
- ChannelRole.MODERATOR -> " [MOD]"
ChannelRole.MEMBER -> ""
+ else -> " [${languageManager.channelRoleLabel(member.role)}]"
}
playerName + roleText
}
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChannelRoleLabel.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChannelRoleLabel.kt
new file mode 100644
index 0000000..c0d3e0a
--- /dev/null
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChannelRoleLabel.kt
@@ -0,0 +1,13 @@
+package dev.m1sk9.lunaticChat.paper.i18n
+
+import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole
+
+/** The name of [role] in the configured language, as both `{role}` and `/lc channel status` show it. */
+fun LanguageManager.channelRoleLabel(role: ChannelRole): String =
+ getMessage(
+ when (role) {
+ ChannelRole.OWNER -> "channel.role.owner"
+ ChannelRole.MODERATOR -> "channel.role.moderator"
+ ChannelRole.MEMBER -> "channel.role.member"
+ },
+ )
diff --git a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt
index 85e6b52..f65bb96 100644
--- a/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt
+++ b/platform-paper/src/main/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormat.kt
@@ -2,6 +2,7 @@ package dev.m1sk9.lunaticChat.paper.i18n
import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.TextReplacementConfig
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
/**
@@ -30,21 +31,21 @@ fun MessageFormatConfig.channelMessage(
sender: String,
channel: String,
channelId: String,
- displayName: String,
+ displayName: Component,
message: String,
world: String,
- role: String,
+ role: () -> String,
): Component =
channelMessageFormat.render(
mapOf(
ChatPlaceholder.SENDER to sender,
ChatPlaceholder.CHANNEL to channel,
ChatPlaceholder.CHANNEL_ID to channelId,
- ChatPlaceholder.DISPLAY_NAME to displayName,
ChatPlaceholder.MESSAGE to message,
ChatPlaceholder.WORLD to world,
- ChatPlaceholder.ROLE to role,
),
+ deferred = mapOf(ChatPlaceholder.ROLE to role),
+ components = mapOf(ChatPlaceholder.DISPLAY_NAME to displayName),
)
fun MessageFormatConfig.crossServerGlobalChat(
@@ -60,15 +61,46 @@ fun MessageFormatConfig.crossServerGlobalChat(
),
)
-private val PLACEHOLDER = Regex("""\{([A-Za-z_]+)}""")
+internal val CHAT_PLACEHOLDER_PATTERN = Regex("""\{([A-Za-z_]+)}""")
// One pass over the format rather than one replace per placeholder, so a value that itself
// contains "{message}" (a channel named that way, say) is inserted literally instead of being
// substituted again by the next replace.
-private fun String.render(values: Map<ChatPlaceholder, String>): Component {
+//
+// Component values are spliced in after parsing instead of being serialized into the legacy text:
+// legacy codes have no closing tag, so a colored display name would recolor everything after it,
+// and legacy text cannot hold hex colors or hover and click events at all.
+//
+// Deferred values are computed only when the format uses them, since the default formats leave
+// out the ones that cost a lookup on every message.
+private fun String.render(
+ values: Map<ChatPlaceholder, String>,
+ deferred: Map<ChatPlaceholder, () -> String> = emptyMap(),
+ components: Map<ChatPlaceholder, Component> = emptyMap(),
+): Component {
+ val spliced = mutableSetOf<ChatPlaceholder>()
+ val resolved = mutableMapOf<ChatPlaceholder, String>()
val text =
- replace(PLACEHOLDER) { match ->
- ChatPlaceholder.fromKey(match.groupValues[1])?.let(values::get) ?: match.value
+ replace(CHAT_PLACEHOLDER_PATTERN) { match ->
+ when (val placeholder = ChatPlaceholder.fromKey(match.groupValues[1])) {
+ null -> match.value
+ in components -> placeholder.marker.also { spliced += placeholder }
+ in deferred -> resolved.getOrPut(placeholder) { deferred.getValue(placeholder)() }
+ else -> values[placeholder] ?: match.value
+ }
}
- return LegacyComponentSerializer.legacySection().deserialize(text)
+ val parsed: Component = LegacyComponentSerializer.legacySection().deserialize(text)
+ return spliced.fold(parsed) { rendered, placeholder ->
+ rendered.replaceText(
+ TextReplacementConfig
+ .builder()
+ .matchLiteral(placeholder.marker)
+ .replacement(components.getValue(placeholder))
+ .build(),
+ )
+ }
}
+
+// NUL rather than a printable token: the client refuses to send control characters, so no value
+// substituted as text - a message, a channel name - can contain the marker and be replaced by it.
+private val ChatPlaceholder.marker: String get() = "\u0000$key\u0000"
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt
index 24f2897..57f60da 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/chat/handler/ChannelMessageHandlerTest.kt
@@ -124,7 +124,40 @@ class ChannelMessageHandlerTest {
handler().sendChannelMessage(owner, "hello")
verify { member.sendMessage(capture(delivered)) }
- assertEquals("ch-1|§cQueen|world_nether|Owner", LegacyComponentSerializer.legacySection().serialize(delivered.captured))
+ assertEquals("ch-1|§cQueen§r|world_nether|Owner", LegacyComponentSerializer.legacySection().serialize(delivered.captured))
+ }
+
+ @Test
+ fun `a moderator's role renders as the moderator label`() {
+ every { channelManager.getPlayerChannelContext(senderId) } returns
+ ChannelContext(
+ channel = channel,
+ members =
+ listOf(
+ TestUtils.createTestChannelMember(channelId = channel.id, playerId = senderId, role = ChannelRole.MODERATOR),
+ TestUtils.createTestChannelMember(channelId = channel.id, playerId = memberId),
+ ),
+ )
+ every { languageManager.getMessage("channel.role.moderator") } returns "Moderator"
+ messageFormats.replace(MessageFormatConfig(channelMessageFormat = "{role}"))
+ val delivered = slot<Component>()
+
+ handler().sendChannelMessage(sender, "hello")
+
+ verify { member.sendMessage(capture(delivered)) }
+ assertEquals("Moderator", LegacyComponentSerializer.legacySection().serialize(delivered.captured))
+ }
+
+ @Test
+ fun `a regular member's role renders as the member label`() {
+ every { languageManager.getMessage("channel.role.member") } returns "Member"
+ messageFormats.replace(MessageFormatConfig(channelMessageFormat = "{role}"))
+ val delivered = slot<Component>()
+
+ handler().sendChannelMessage(sender, "hello")
+
+ verify { member.sendMessage(capture(delivered)) }
+ assertEquals("Member", LegacyComponentSerializer.legacySection().serialize(delivered.captured))
}
@Test
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt
index bbcfafb..1b26538 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/command/impl/lc/channel/ChannelStatusCommandTest.kt
@@ -1,5 +1,6 @@
package dev.m1sk9.lunaticChat.paper.command.impl.lc.channel
+import dev.m1sk9.lunaticChat.engine.chat.channel.ChannelRole
import dev.m1sk9.lunaticChat.engine.command.CommandResult
import dev.m1sk9.lunaticChat.paper.LunaticChat
import dev.m1sk9.lunaticChat.paper.TestUtils
@@ -11,11 +12,14 @@ import io.mockk.every
import io.mockk.mockk
import io.mockk.mockkStatic
import io.mockk.unmockkStatic
+import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
import org.bukkit.Bukkit
import org.bukkit.OfflinePlayer
import java.util.UUID
import kotlin.test.Test
import kotlin.test.assertIs
+import kotlin.test.assertTrue
class ChannelStatusCommandTest {
private val testUUID = UUID.fromString("00000001-0000-0000-0000-000000000000")
@@ -78,6 +82,32 @@ class ChannelStatusCommandTest {
}
@Test
+ fun `members are listed with their role in the configured language`() {
+ val deps = createDependencies()
+ every { deps.languageManager.getMessage("channel.role.owner", any()) } returns "オーナー"
+ val channel = TestUtils.createTestChannel(id = channelId, ownerId = testUUID)
+ val members = listOf(TestUtils.createTestChannelMember(channelId = channelId, playerId = testUUID, role = ChannelRole.OWNER))
+ every { deps.channelManager.getPlayerChannel(testUUID) } returns channelId
+ every { deps.channelManager.getChannel(channelId) } returns Result.success(channel)
+ every { deps.channelManager.getChannelMembers(channelId) } returns Result.success(members)
+ every { deps.membershipManager.getPlayerChannels(testUUID) } returns listOf(channelId)
+ val sent = mutableListOf<Component>()
+ every { deps.mockPlayer.sendMessage(capture(sent)) } returns Unit
+
+ mockkStatic(Bukkit::class)
+ try {
+ mockBukkitOfflinePlayer()
+
+ deps.command.execute(deps.ctx)
+ } finally {
+ unmockkStatic(Bukkit::class)
+ }
+
+ val shown = sent.joinToString("\n") { PlainTextComponentSerializer.plainText().serialize(it) }
+ assertTrue("Player1 [オーナー]" in shown, shown)
+ }
+
+ @Test
fun `execute without active channel should return Success`() {
val deps = createDependencies()
diff --git a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt
index c977fd1..666a17e 100644
--- a/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt
+++ b/platform-paper/src/test/kotlin/dev/m1sk9/lunaticChat/paper/i18n/ChatFormatTest.kt
@@ -2,25 +2,34 @@ package dev.m1sk9.lunaticChat.paper.i18n
import dev.m1sk9.lunaticChat.paper.config.key.MessageFormatConfig
import net.kyori.adventure.text.Component
+import net.kyori.adventure.text.TextComponent
+import net.kyori.adventure.text.event.HoverEvent
import net.kyori.adventure.text.format.NamedTextColor
+import net.kyori.adventure.text.format.TextColor
import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
import kotlin.reflect.full.memberProperties
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
+import kotlin.test.assertNull
import kotlin.test.assertTrue
class ChatFormatTest {
private val defaults = MessageFormatConfig()
- private val placeholderPattern = Regex("""\{([A-Za-z_]+)}""")
+ private val queen = Component.text("Queen")
private fun Component.legacy(): String = LegacyComponentSerializer.legacySection().serialize(this)
+ private fun Component.leafContaining(text: String): Component =
+ generateSequence(listOf(this)) { level -> level.flatMap { it.children() }.ifEmpty { null } }
+ .flatten()
+ .first { it is TextComponent && text in it.content() }
+
private fun MessageFormatConfig.renderAll(): List<String> =
listOf(
directMessage("Alice", "Bob", "hi", "world"),
- channelMessage("Alice", "general", "general", "Queen", "hi", "world", "Owner"),
+ channelMessage("Alice", "general", "general", queen, "hi", "world", { "Owner" }),
crossServerGlobalChat("lobby", "Alice", "hi"),
).map { it.legacy() }
@@ -33,7 +42,7 @@ class ChatFormatTest {
fun `the default channel message format renders channel, sender and message`() {
assertEquals(
"§7[§b#general§7] §eAlice: §fhi",
- defaults.channelMessage("Alice", "general", "general", "Queen", "hi", "world", "Owner").legacy(),
+ defaults.channelMessage("Alice", "general", "general", queen, "hi", "world", { "Owner" }).legacy(),
)
}
@@ -55,18 +64,49 @@ class ChatFormatTest {
assertEquals(
"general-1 Queen world_nether Owner",
- formats.channelMessage("Alice", "General", "general-1", "Queen", "hi", "world_nether", "Owner").legacy(),
+ formats.channelMessage("Alice", "General", "general-1", queen, "hi", "world_nether", { "Owner" }).legacy(),
)
}
@Test
- fun `section sign codes in a display name become styles`() {
+ fun `a display name keeps its own color without recoloring the rest of the format`() {
+ val formats = MessageFormatConfig(channelMessageFormat = "{display_name}: {message}")
+ val displayName = Component.text("Queen", NamedTextColor.RED)
+
+ val rendered = formats.channelMessage("Alice", "general", "general", displayName, "hi", "world", { "Owner" })
+
+ assertEquals("Queen: hi", PlainTextComponentSerializer.plainText().serialize(rendered))
+ assertEquals(NamedTextColor.RED, rendered.leafContaining("Queen").color())
+ assertNull(rendered.leafContaining(": hi").color())
+ }
+
+ @Test
+ fun `a display name without a color of its own takes the format's color`() {
+ val formats = MessageFormatConfig(channelMessageFormat = "§e{display_name}")
+
+ assertEquals("§eQueen", formats.channelMessage("Alice", "general", "general", queen, "hi", "world", { "Owner" }).legacy())
+ }
+
+ @Test
+ fun `a display name keeps hex colors and hover events`() {
val formats = MessageFormatConfig(channelMessageFormat = "{display_name}")
+ val hover = HoverEvent.showText(Component.text("Level 42"))
+ val displayName = Component.text("Queen", TextColor.color(0x12AB34)).hoverEvent(hover)
+
+ val rendered = formats.channelMessage("Alice", "general", "general", displayName, "hi", "world", { "Owner" })
+
+ val queenLeaf = rendered.leafContaining("Queen")
+ assertEquals(TextColor.color(0x12AB34), queenLeaf.color())
+ assertEquals(hover, queenLeaf.hoverEvent())
+ }
+
+ @Test
+ fun `the role is not computed when the format does not use it`() {
+ var computed = false
- val rendered = formats.channelMessage("Alice", "general", "general", "§cQueen", "hi", "world", "Owner")
+ defaults.channelMessage("Alice", "general", "general", queen, "hi", "world") { "Owner".also { computed = true } }
- assertEquals("Queen", PlainTextComponentSerializer.plainText().serialize(rendered))
- assertEquals(NamedTextColor.RED, rendered.color())
+ assertFalse(computed)
}
@Test
@@ -80,7 +120,7 @@ class ChatFormatTest {
fun `a value containing a placeholder is inserted literally`() {
val formats = MessageFormatConfig(channelMessageFormat = "<{channel}> {message}")
- assertEquals("<{message}> hi", formats.channelMessage("Alice", "{message}", "general", "Queen", "hi", "world", "Owner").legacy())
+ assertEquals("<{message}> hi", formats.channelMessage("Alice", "{message}", "general", queen, "hi", "world", { "Owner" }).legacy())
}
@Test
@@ -102,7 +142,7 @@ class ChatFormatTest {
fun `every placeholder in the default formats is a known one`() {
MessageFormatConfig::class.memberProperties.forEach { property ->
val format = property.get(defaults) as String
- placeholderPattern.findAll(format).forEach {
+ CHAT_PLACEHOLDER_PATTERN.findAll(format).forEach {
val name = it.groupValues[1]
assertTrue(ChatPlaceholder.fromKey(name) != null, "${property.name} uses unknown {$name}")
}
@@ -133,6 +173,6 @@ class ChatFormatTest {
val keys = ChatPlaceholder.entries.map { it.key }
assertEquals(keys.size, keys.toSet().size)
- keys.forEach { assertTrue(Regex("[A-Za-z_]+").matches(it), "$it cannot be matched by the renderer") }
+ keys.forEach { assertTrue(CHAT_PLACEHOLDER_PATTERN.matches("{$it}"), "$it cannot be matched by the renderer") }
}
}
diff --git a/website/src/docs/developers/platform-paper.md b/website/src/docs/developers/platform-paper.md
index 084d23f..6e6b7f4 100644
--- a/website/src/docs/developers/platform-paper.md
+++ b/website/src/docs/developers/platform-paper.md
@@ -158,7 +158,7 @@ Channel state itself is managed by the `chat/channel` package.
- `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
-- `ChatPlaceholder` / `ChatFormat.kt` — the placeholder vocabulary of `messageFormat` and its one renderer: entry points per format (`directMessage` / `channelMessage` / `crossServerGlobalChat`) substitute in a single pass and hand the result to `LegacyComponentSerializer`, so handlers never spell a placeholder name
+- `ChatPlaceholder` / `ChatFormat.kt` — the placeholder vocabulary of `messageFormat` and its one renderer: entry points per format (`directMessage` / `channelMessage` / `crossServerGlobalChat`) substitute in a single pass and hand the result to `LegacyComponentSerializer`, then splice in Component values such as `{display_name}` so their styles and events stay scoped to them. Handlers never spell a placeholder name
## converter — Romaji-to-Japanese conversion
diff --git a/website/src/ja/docs/developers/platform-paper.md b/website/src/ja/docs/developers/platform-paper.md
index 930c696..edcd858 100644
--- a/website/src/ja/docs/developers/platform-paper.md
+++ b/website/src/ja/docs/developers/platform-paper.md
@@ -158,7 +158,7 @@ config フラグ
- `Language` (enum) — `EN` / `JA`.未知コードは EN にフォールバック
- `LanguageManager` — 起動時に `resources/languages/` を KAML でロードし,ネストした YAML をドット記法 (`toggle.on` 等) にフラット化する.`getMessage(key, placeholders)` は 選択言語 → EN フォールバック で解決し `{placeholder}` を置換,未発見はキー自身を返す.EN が無ければ致命エラー
- `MessageFormatter` (`object`) — `[LC]` プレフィックス付きの Adventure `Component` を生成し,`{braces}` プレースホルダを正規表現で検出して色分けする
-- `ChatPlaceholder` / `ChatFormat.kt` — `messageFormat` のプレースホルダ語彙と唯一のレンダラ.フォーマットごとの入口 (`directMessage` / `channelMessage` / `crossServerGlobalChat`) が 1 パスで置換し,結果を `LegacyComponentSerializer` に渡す.ハンドラがプレースホルダ名を綴ることはない
+- `ChatPlaceholder` / `ChatFormat.kt` — `messageFormat` のプレースホルダ語彙と唯一のレンダラ.フォーマットごとの入口 (`directMessage` / `channelMessage` / `crossServerGlobalChat`) が 1 パスで置換し,結果を `LegacyComponentSerializer` に渡したうえで,`{display_name}` のような Component 値を差し込む (スタイルやイベントがその値の範囲に閉じるように).ハンドラがプレースホルダ名を綴ることはない
## converter — ローマ字→日本語変換