feat: kommand-lib対応

This commit is contained in:
Keisuke Hirata 2025-12-07 06:54:36 +09:00
parent dd69f06346
commit cef9b6f371
9 changed files with 343 additions and 61 deletions

View File

@ -0,0 +1,94 @@
# Brigadier対応マイグレーション完了レポート
## 概要
kommand-libのBrigadier対応に合わせて、npc-mannequinプロジェクトのコマンドを更新しました。
## 実施日時
2025-12-07
## 変更内容
### 1. 影響範囲の調査
以下のファイルを調査しました:
- `src/main/kotlin/net/hareworks/npc-mannequin/commands/MannequinCommands.kt`
- その他のKotlinソースファイル
**調査結果:**
- `coordinates()` の使用: なし
- `Coordinates3` の使用: なし
- 破壊的変更の影響: **なし**座標系APIは使用していない
### 2. 必要な修正
kommand-libの内部APIが変更されたため、以下の修正が必要でした
#### `remainingInput()` 関数の更新
**変更前:**
```kotlin
private fun KommandContext.remainingInput(offset: Int): String? {
if (args.size <= offset) return null
return args.drop(offset).joinToString(" ").trim().ifEmpty { null }
}
```
**変更後:**
```kotlin
private fun KommandContext.remainingInput(offset: Int): String? {
// Get the full raw input from Brigadier's CommandContext
val fullInput = internal.input
// Split by whitespace to get individual tokens
val tokens = fullInput.split(Regex("\\s+"))
// Check if we have enough tokens
if (tokens.size <= offset) return null
// Join the remaining tokens after the offset
return tokens.drop(offset).joinToString(" ").trim().ifEmpty { null }
}
```
**理由:**
- 旧版の `KommandContext.args` プロパティが削除された
- 新版では Brigadier の `CommandContext.getInput()` を使用して生の入力を取得
- `internal.input` で Brigadier の `CommandContext` にアクセス可能
### 3. ビルド検証
```bash
./gradlew build --no-daemon
```
**結果:** ✅ BUILD SUCCESSFUL
## マイグレーションガイドとの対応
kommand-lib/MIGRATION_GUIDE.md に記載されている変更点:
1. ✅ **`coordinates()` の型変更** - 該当なし(使用していない)
2. ✅ **内部処理の改善** - 新しいBrigadier Lifecycle APIに対応済み
3. ✅ **依存関係の確認** - Paper API 1.21以降を使用
## 影響を受ける機能
以下のコマンドで `remainingInput()` を使用しているため、動作確認が推奨されます:
- `/mannequin set <id> description text <text>` - MiniMessageテキストの設定
- `/mannequin set <id> command console set <command>` - コンソールコマンドの設定
- `/mannequin set <id> command player set <command>` - プレイヤーコマンドの設定
## 今後の注意点
- kommand-libの内部APIは今後も変更される可能性があるため、`internal` プロパティの使用は最小限に抑えることが望ましい
- 可能であれば、kommand-lib側で公式に `remainingInput` のようなユーティリティを提供することを検討
## まとめ
✅ マイグレーション完了
✅ ビルド成功
⚠️ 実機での動作確認を推奨(特にテキスト入力系のコマンド)

4
.gitignore vendored
View File

@ -1,5 +1,3 @@
# Ignore Gradle project-specific cache directory
.gradle .gradle
bin
# Ignore Gradle build output directory
build build

@ -1 +1 @@
Subproject commit 6c62d3306e2cc0e0fefe8ec7fb9b64a47caae3cb Subproject commit 25b40427eddf5a0f49da088aa3ea1ff7ac757539

View File

@ -1,22 +1,33 @@
package net.hareworks.npc_mannequin package net.hareworks.npc_mannequin
import net.hareworks.kommand_lib.KommandLib import net.hareworks.kommand_lib.KommandLib
import java.util.logging.Level
import net.hareworks.npc_mannequin.commands.MannequinCommands import net.hareworks.npc_mannequin.commands.MannequinCommands
import net.hareworks.npc_mannequin.service.MannequinController import net.hareworks.npc_mannequin.service.MannequinController
import net.hareworks.npc_mannequin.service.MannequinListener
import net.hareworks.npc_mannequin.service.MannequinRegistry import net.hareworks.npc_mannequin.service.MannequinRegistry
import net.hareworks.npc_mannequin.storage.MannequinStorage import net.hareworks.npc_mannequin.storage.MannequinStorage
import net.hareworks.permits_lib.PermitsLib
import net.hareworks.permits_lib.bukkit.MutationSession
import org.bukkit.plugin.ServicePriority import org.bukkit.plugin.ServicePriority
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
class Plugin : JavaPlugin() { class Plugin : JavaPlugin() {
private var kommand: KommandLib? = null private var kommand: KommandLib? = null
private lateinit var registry: MannequinRegistry private lateinit var registry: MannequinRegistry
private var permissionSession: MutationSession? = null
override fun onEnable() { override fun onEnable() {
val storage = MannequinStorage(this) val storage = MannequinStorage(this)
val controller = MannequinController(this) val controller = MannequinController(this)
registry = MannequinRegistry(this, storage, controller) registry = MannequinRegistry(this, storage, controller)
kommand = MannequinCommands(this, registry).register() permissionSession = runCatching { PermitsLib.session(this) }
.onFailure { logger.log(Level.WARNING, "Failed to acquire permits session", it) }
.getOrNull()
server.pluginManager.registerEvents(MannequinListener(this, registry), this)
kommand = MannequinCommands.register(this, registry, permissionSession)
server.servicesManager.register(MannequinRegistry::class.java, registry, this, ServicePriority.Normal) server.servicesManager.register(MannequinRegistry::class.java, registry, this, ServicePriority.Normal)
logger.info("Loaded ${registry.all().size} mannequin definitions.") logger.info("Loaded ${registry.all().size} mannequin definitions.")
} }
@ -24,5 +35,8 @@ class Plugin : JavaPlugin() {
override fun onDisable() { override fun onDisable() {
server.servicesManager.unregisterAll(this) server.servicesManager.unregisterAll(this)
kommand?.unregister() kommand?.unregister()
permissionSession?.clearAll()
permissionSession = null
kommand = null
} }
} }

View File

@ -1,5 +1,6 @@
package net.hareworks.npc_mannequin.commands package net.hareworks.npc_mannequin.commands
import java.util.Locale
import net.hareworks.kommand_lib.KommandLib import net.hareworks.kommand_lib.KommandLib
import net.hareworks.kommand_lib.context.KommandContext import net.hareworks.kommand_lib.context.KommandContext
import net.hareworks.kommand_lib.kommand import net.hareworks.kommand_lib.kommand
@ -9,8 +10,10 @@ import net.hareworks.npc_mannequin.mannequin.StoredLocation
import net.hareworks.npc_mannequin.mannequin.StoredProfile import net.hareworks.npc_mannequin.mannequin.StoredProfile
import net.hareworks.npc_mannequin.service.MannequinRegistry import net.hareworks.npc_mannequin.service.MannequinRegistry
import net.hareworks.npc_mannequin.text.TextSerializers import net.hareworks.npc_mannequin.text.TextSerializers
import net.hareworks.permits_lib.PermitsLib import net.hareworks.permits_lib.bukkit.MutationSession
import net.kyori.adventure.text.Component import net.kyori.adventure.text.Component
import net.kyori.adventure.text.event.ClickEvent
import net.kyori.adventure.text.event.HoverEvent
import net.kyori.adventure.text.format.NamedTextColor import net.kyori.adventure.text.format.NamedTextColor
import org.bukkit.entity.Entity import org.bukkit.entity.Entity
import org.bukkit.entity.Mannequin import org.bukkit.entity.Mannequin
@ -18,25 +21,25 @@ import org.bukkit.entity.Player
import org.bukkit.entity.Pose import org.bukkit.entity.Pose
import org.bukkit.inventory.MainHand import org.bukkit.inventory.MainHand
import org.bukkit.plugin.java.JavaPlugin import org.bukkit.plugin.java.JavaPlugin
import java.util.Locale
class MannequinCommands( object MannequinCommands {
private val plugin: JavaPlugin, fun register(
private val registry: MannequinRegistry plugin: JavaPlugin,
) { registry: MannequinRegistry,
fun register(): KommandLib = kommand(plugin) { permissionSession: MutationSession?
): KommandLib = kommand(plugin) {
permissions { permissions {
namespace = "hareworks" namespace = "npc-mannequin"
rootSegment = "command" rootSegment = "command"
defaultDescription { ctx -> defaultDescription { ctx ->
"Allows /${ctx.commandName} ${ctx.path.joinToString(" ")}" "Allows /${ctx.commandName} ${ctx.path.joinToString(" ")}"
} }
session { PermitsLib.session(it) } permissionSession?.let { session(it) }
} }
command("mannequin", listOf("mnpc", "mnq", "npcmannequin")) { command("mannequin", listOf("mnpc", "mnq", "npcmannequin")) {
description = "Register and manage mannequin NPCs" description = "Register and manage mannequin NPCs"
permission = "hareworks.command.mannequin" permission = "npc-mannequin.command.mannequin"
executes { listMannequins(registry) } executes { listMannequins(registry) }
@ -65,7 +68,8 @@ class MannequinCommands(
literal("--overwrite") { literal("--overwrite") {
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
val entity = argument<List<Entity>>("target").firstOrNull { it is Mannequin } as? Mannequin val entity =
argument<List<Entity>>("target").firstOrNull { it is Mannequin } as? Mannequin
if (entity == null) { if (entity == null) {
error("Selector must target at least one mannequin entity.") error("Selector must target at least one mannequin entity.")
return@executes return@executes
@ -96,6 +100,7 @@ class MannequinCommands(
literal("move") { literal("move") {
string("id") { string("id") {
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
executes { executes {
val player = requirePlayer() ?: return@executes val player = requirePlayer() ?: return@executes
val id = argument<String>("id") val id = argument<String>("id")
@ -112,6 +117,7 @@ class MannequinCommands(
literal("apply") { literal("apply") {
string("id") { string("id") {
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
runCatching { runCatching {
@ -128,6 +134,7 @@ class MannequinCommands(
literal("remove") { literal("remove") {
string("id") { string("id") {
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
runCatching { registry.remove(id, deleteEntity = false) } runCatching { registry.remove(id, deleteEntity = false) }
@ -147,15 +154,19 @@ class MannequinCommands(
literal("set") { literal("set") {
string("id") { string("id") {
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
literal("pose") { literal("pose") {
string("pose") { string("pose") {
suggests { prefix -> Pose.entries.map { it.name.lowercase() }.filter { it.startsWith(prefix.lowercase()) } } suggests { prefix ->
MannequinSettings.POSES.map { it.name.lowercase() }
.filter { it.startsWith(prefix.lowercase()) }
}
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
val poseToken = argument<String>("pose") val poseToken = argument<String>("pose")
val pose = runCatching { Pose.valueOf(poseToken.uppercase()) }.getOrNull() val pose = runCatching { Pose.valueOf(poseToken.uppercase()) }.getOrNull()
if (pose == null) { if (pose == null || pose !in MannequinSettings.POSES) {
error("Unknown pose '$poseToken'.") error("Unknown or invalid pose '$poseToken'.")
return@executes return@executes
} }
registry.updateSettings(id) { it.copy(pose = pose) } registry.updateSettings(id) { it.copy(pose = pose) }
@ -166,7 +177,10 @@ class MannequinCommands(
literal("mainhand") { literal("mainhand") {
string("hand") { string("hand") {
suggests { prefix -> MainHand.entries.map { it.name.lowercase() }.filter { it.startsWith(prefix.lowercase()) } } suggests { prefix ->
MainHand.entries.map { it.name.lowercase() }
.filter { it.startsWith(prefix.lowercase()) }
}
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
val handToken = argument<String>("hand") val handToken = argument<String>("hand")
@ -183,7 +197,14 @@ class MannequinCommands(
literal("immovable") { literal("immovable") {
string("state") { string("state") {
suggests { prefix -> listOf("true", "false", "on", "off").filter { it.startsWith(prefix.lowercase()) } } suggests { prefix ->
listOf(
"true",
"false",
"on",
"off"
).filter { it.startsWith(prefix.lowercase()) }
}
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
val input = argument<String>("state") val input = argument<String>("state")
@ -200,23 +221,26 @@ class MannequinCommands(
literal("description") { literal("description") {
literal("text") { literal("text") {
greedyString("content") {
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
val payload = remainingInput(DESCRIPTION_TEXT_OFFSET) val payload = argument<String>("content")
if (payload.isNullOrBlank()) {
error("Provide MiniMessage text after the command, e.g. /mannequin set $id description text <text>")
return@executes
}
val component = runCatching { TextSerializers.miniMessage(payload) } val component = runCatching { TextSerializers.miniMessage(payload) }
.onFailure { error("MiniMessage parse failed: ${it.message}") } .onFailure { error("MiniMessage parse failed: ${it.message}") }
.getOrNull() .getOrNull()
if (component == null) { if (component == null) {
return@executes return@executes
} }
registry.updateSettings(id) { it.copy(description = component, hideDescription = false) } registry.updateSettings(id) {
it.copy(
description = component,
hideDescription = false
)
}
success("Updated description for '$id'.") success("Updated description for '$id'.")
} }
} }
}
literal("clear") { literal("clear") {
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
@ -226,7 +250,14 @@ class MannequinCommands(
} }
literal("hide") { literal("hide") {
string("state") { string("state") {
suggests { prefix -> listOf("true", "false", "on", "off").filter { it.startsWith(prefix.lowercase()) } } suggests { prefix ->
listOf(
"true",
"false",
"on",
"off"
).filter { it.startsWith(prefix.lowercase()) }
}
executes { executes {
val id = argument<String>("id") val id = argument<String>("id")
val input = argument<String>("state") val input = argument<String>("state")
@ -304,6 +335,62 @@ class MannequinCommands(
} }
} }
} }
literal("respawn-delay") {
integer("seconds") {
executes {
val id = argument<String>("id")
val seconds = argument<Int>("seconds")
registry.updateSettings(id) { it.copy(respawnDelay = seconds) }
if (seconds < 0) {
success("Disabled auto-respawn for '$id'.")
} else {
success("Set auto-respawn delay for '$id' to $seconds seconds.")
}
}
}
}
literal("command") {
literal("console") {
literal("set") {
greedyString("command") {
executes {
val id = argument<String>("id")
val cmd = argument<String>("command")
registry.updateSettings(id) { it.copy(serverCommand = cmd) }
success("Set console command for '$id'.")
}
}
}
literal("clear") {
executes {
val id = argument<String>("id")
registry.updateSettings(id) { it.copy(serverCommand = null) }
success("Cleared console command for '$id'.")
}
}
}
literal("player") {
literal("set") {
greedyString("command") {
executes {
val id = argument<String>("id")
val cmd = argument<String>("command")
registry.updateSettings(id) { it.copy(playerCommand = cmd) }
success("Set player command for '$id'.")
}
}
}
literal("clear") {
executes {
val id = argument<String>("id")
registry.updateSettings(id) { it.copy(playerCommand = null) }
success("Cleared player command for '$id'.")
}
}
}
}
} }
} }
} }
@ -311,6 +398,7 @@ class MannequinCommands(
} }
private const val DESCRIPTION_TEXT_OFFSET = 4 private const val DESCRIPTION_TEXT_OFFSET = 4
private const val COMMAND_TEXT_OFFSET = 5
private fun KommandContext.listMannequins(registry: MannequinRegistry) { private fun KommandContext.listMannequins(registry: MannequinRegistry) {
val entries = registry.all() val entries = registry.all()
@ -320,11 +408,28 @@ private fun KommandContext.listMannequins(registry: MannequinRegistry) {
} }
sender.sendMessage(Component.text("Registered mannequins (${entries.size}):", NamedTextColor.GRAY)) sender.sendMessage(Component.text("Registered mannequins (${entries.size}):", NamedTextColor.GRAY))
entries.forEach { record -> entries.forEach { record ->
val status = if (registry.locate(record.id) != null) "active" else "offline" val isAlive = registry.locate(record.id) != null
val statusText = if (isAlive) "active" else "offline"
val statusColor = if (isAlive) NamedTextColor.GREEN else NamedTextColor.RED
val idComponent = Component.text("- ${record.id}: ", NamedTextColor.WHITE)
.clickEvent(ClickEvent.suggestCommand("/mannequin set ${record.id} "))
.hoverEvent(HoverEvent.showText(Component.text("Click to configure settings", NamedTextColor.GRAY)))
val statusBase = Component.text(statusText, statusColor)
val statusComponent = if (!isAlive) {
statusBase
.clickEvent(ClickEvent.runCommand("/mannequin apply ${record.id}"))
.hoverEvent(HoverEvent.showText(Component.text("Click to respawn", NamedTextColor.YELLOW)))
} else {
statusBase
.hoverEvent(HoverEvent.showText(Component.text("Mannequin is active", NamedTextColor.GREEN)))
}
val location = formatLocation(record.location) val location = formatLocation(record.location)
sender.sendMessage( sender.sendMessage(
Component.text("- ${record.id}: ", NamedTextColor.WHITE) idComponent
.append(Component.text(status, if (status == "active") NamedTextColor.GREEN else NamedTextColor.DARK_GRAY)) .append(statusComponent)
.append(Component.text(" @ $location", NamedTextColor.GRAY)) .append(Component.text(" @ $location", NamedTextColor.GRAY))
) )
} }
@ -345,11 +450,6 @@ private fun KommandContext.error(message: String) {
sender.sendMessage(Component.text(message, NamedTextColor.RED)) sender.sendMessage(Component.text(message, NamedTextColor.RED))
} }
private fun KommandContext.remainingInput(offset: Int): String? {
if (args.size <= offset) return null
return args.drop(offset).joinToString(" ").trim().ifEmpty { null }
}
private fun layerSuggestions(prefix: String): List<String> = private fun layerSuggestions(prefix: String): List<String> =
MannequinHiddenLayer.entries.map { it.key }.filter { it.startsWith(prefix.lowercase()) } MannequinHiddenLayer.entries.map { it.key }.filter { it.startsWith(prefix.lowercase()) }

View File

@ -21,9 +21,22 @@ data class MannequinSettings(
val description: Component? = null, val description: Component? = null,
val hideDescription: Boolean = false, val hideDescription: Boolean = false,
val hiddenLayers: Set<MannequinHiddenLayer> = emptySet(), val hiddenLayers: Set<MannequinHiddenLayer> = emptySet(),
val profile: StoredProfile? = null val profile: StoredProfile? = null,
val respawnDelay: Int = 0,
val serverCommand: String? = null,
val playerCommand: String? = null
) { ) {
companion object { companion object {
val POSES = setOf(
Pose.STANDING, // Typical upright standing pose
Pose.SLEEPING, // Laying horizontal, as if in a bed
Pose.SNEAKING, // Crouched/sneaking posture
Pose.SWIMMING, // Horizontal swimming animation
Pose.SPIN_ATTACK, // Riptide trident spin animation
Pose.LONG_JUMPING, // Goat long jump animation (arms raised)
Pose.FALL_FLYING // Elytra gliding pose
)
fun from(entity: Mannequin): MannequinSettings { fun from(entity: Mannequin): MannequinSettings {
val skinParts = entity.skinParts val skinParts = entity.skinParts
return MannequinSettings( return MannequinSettings(

View File

@ -0,0 +1,59 @@
package net.hareworks.npc_mannequin.service
import com.destroystokyo.paper.event.entity.EntityRemoveFromWorldEvent
import org.bukkit.entity.Mannequin
import org.bukkit.event.EventHandler
import org.bukkit.event.Listener
import org.bukkit.plugin.java.JavaPlugin
class MannequinListener(
private val plugin: JavaPlugin,
private val registry: MannequinRegistry
) : Listener {
@EventHandler
fun onEntityRemove(event: EntityRemoveFromWorldEvent) {
val entity = event.entity
if (entity !is Mannequin) return
// If the entity is being removed because the chunk is unloading, we don't want to respawn it.
// Usually, isDead is false in that case.
// We only care if the entity is actually destroyed/killed.
if (!entity.isDead) return
val record = registry.all().firstOrNull { it.entityId == entity.uniqueId } ?: return
val delaySeconds = record.settings.respawnDelay
if (delaySeconds < 0) return
val delayTicks = (delaySeconds * 20).toLong()
plugin.server.scheduler.runTaskLater(plugin, Runnable {
// Verify record still exists (wasn't removed from registry while waiting)
if (registry.find(record.id) == null) return@Runnable
registry.apply(record.id, spawnIfMissing = true)
}, delayTicks)
}
@EventHandler
fun onInteract(event: org.bukkit.event.player.PlayerInteractEntityEvent) {
val entity = event.rightClicked
if (entity !is Mannequin) return
val record = registry.all().firstOrNull { it.entityId == entity.uniqueId } ?: return
val settings = record.settings
val player = event.player
settings.serverCommand?.let { cmd ->
val cleanCmd = if (cmd.startsWith("/")) cmd.substring(1) else cmd
val finalCmd = cleanCmd.replace("<player>", player.name)
plugin.server.dispatchCommand(plugin.server.consoleSender, finalCmd)
}
settings.playerCommand?.let { cmd ->
val cleanCmd = if (cmd.startsWith("/")) cmd.substring(1) else cmd
val finalCmd = cleanCmd.replace("<player>", player.name)
player.performCommand(finalCmd)
}
}
}

View File

@ -106,11 +106,11 @@ class MannequinRegistry(
fun remove(id: String, deleteEntity: Boolean) { fun remove(id: String, deleteEntity: Boolean) {
val record = require(id) val record = require(id)
records.remove(id)
persist()
if (deleteEntity) { if (deleteEntity) {
controller.locate(record)?.remove() controller.locate(record)?.remove()
} }
records.remove(id)
persist()
} }
fun locate(id: String): Mannequin? = controller.locate(require(id)) fun locate(id: String): Mannequin? = controller.locate(require(id))

View File

@ -84,7 +84,9 @@ class MannequinStorage(private val plugin: JavaPlugin) {
description = description, description = description,
hideDescription = hideDescription, hideDescription = hideDescription,
hiddenLayers = hiddenLayers, hiddenLayers = hiddenLayers,
profile = profile profile = profile,
serverCommand = section.getString("serverCommand"),
playerCommand = section.getString("playerCommand")
) )
return MannequinRecord(id, settings, location, entityId) return MannequinRecord(id, settings, location, entityId)
} }
@ -96,6 +98,8 @@ class MannequinStorage(private val plugin: JavaPlugin) {
section.set("hideDescription", record.settings.hideDescription) section.set("hideDescription", record.settings.hideDescription)
section.set("description", TextSerializers.miniMessage(record.settings.description)) section.set("description", TextSerializers.miniMessage(record.settings.description))
section.set("hiddenLayers", record.settings.hiddenLayers.map { it.key }) section.set("hiddenLayers", record.settings.hiddenLayers.map { it.key })
section.set("serverCommand", record.settings.serverCommand)
section.set("playerCommand", record.settings.playerCommand)
record.settings.profile?.let { profile -> record.settings.profile?.let { profile ->
val profileSection = section.createSection("profile") val profileSection = section.createSection("profile")
profileSection.set("name", profile.name) profileSection.set("name", profile.name)