Compare commits
2 Commits
5f88a0cc7a
...
cef9b6f371
| Author | SHA1 | Date | |
|---|---|---|---|
| cef9b6f371 | |||
| dd69f06346 |
94
.agent/BRIGADIER_MIGRATION.md
Normal file
94
.agent/BRIGADIER_MIGRATION.md
Normal 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
4
.gitignore
vendored
|
|
@ -1,5 +1,3 @@
|
||||||
# Ignore Gradle project-specific cache directory
|
|
||||||
.gradle
|
.gradle
|
||||||
|
bin
|
||||||
# Ignore Gradle build output directory
|
|
||||||
build
|
build
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,8 @@ repositories {
|
||||||
dependencies {
|
dependencies {
|
||||||
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
|
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-stdlib")
|
implementation("org.jetbrains.kotlin:kotlin-stdlib")
|
||||||
implementation("net.hareworks:kommand-lib:1.1")
|
implementation("net.hareworks:kommand-lib")
|
||||||
implementation("net.hareworks:permits-lib:1.1")
|
implementation("net.hareworks:permits-lib")
|
||||||
implementation("net.kyori:adventure-text-minimessage:4.17.0")
|
implementation("net.kyori:adventure-text-minimessage:4.17.0")
|
||||||
implementation("net.kyori:adventure-text-serializer-plain:4.17.0")
|
implementation("net.kyori:adventure-text-serializer-plain:4.17.0")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1 +1 @@
|
||||||
Subproject commit 6c62d3306e2cc0e0fefe8ec7fb9b64a47caae3cb
|
Subproject commit 25b40427eddf5a0f49da088aa3ea1ff7ac757539
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
package net.hareworks.npc_mannequin
|
|
||||||
|
|
||||||
import net.hareworks.kommand_lib.KommandLib
|
|
||||||
import net.hareworks.permits_lib.PermitsLib
|
|
||||||
import org.bukkit.plugin.ServicePriority
|
|
||||||
import org.bukkit.plugin.java.JavaPlugin
|
|
||||||
|
|
||||||
class GhostDisplaysPlugin : JavaPlugin() {
|
|
||||||
private var permissionSession: MutationSession? = null
|
|
||||||
private var kommand: KommandLib? = null
|
|
||||||
|
|
||||||
override fun onEnable() {
|
|
||||||
instance = this
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onDisable() {
|
|
||||||
instance = null
|
|
||||||
}
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
@Volatile
|
|
||||||
private var instance: GhostDisplaysPlugin? = null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
42
src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt
Normal file
42
src/main/kotlin/net/hareworks/npc-mannequin/Plugin.kt
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
package net.hareworks.npc_mannequin
|
||||||
|
|
||||||
|
import net.hareworks.kommand_lib.KommandLib
|
||||||
|
import java.util.logging.Level
|
||||||
|
import net.hareworks.npc_mannequin.commands.MannequinCommands
|
||||||
|
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.storage.MannequinStorage
|
||||||
|
import net.hareworks.permits_lib.PermitsLib
|
||||||
|
import net.hareworks.permits_lib.bukkit.MutationSession
|
||||||
|
import org.bukkit.plugin.ServicePriority
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
|
class Plugin : JavaPlugin() {
|
||||||
|
private var kommand: KommandLib? = null
|
||||||
|
private lateinit var registry: MannequinRegistry
|
||||||
|
private var permissionSession: MutationSession? = null
|
||||||
|
|
||||||
|
override fun onEnable() {
|
||||||
|
val storage = MannequinStorage(this)
|
||||||
|
val controller = MannequinController(this)
|
||||||
|
registry = MannequinRegistry(this, storage, controller)
|
||||||
|
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)
|
||||||
|
logger.info("Loaded ${registry.all().size} mannequin definitions.")
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDisable() {
|
||||||
|
server.servicesManager.unregisterAll(this)
|
||||||
|
kommand?.unregister()
|
||||||
|
permissionSession?.clearAll()
|
||||||
|
permissionSession = null
|
||||||
|
kommand = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,468 @@
|
||||||
|
package net.hareworks.npc_mannequin.commands
|
||||||
|
|
||||||
|
import java.util.Locale
|
||||||
|
import net.hareworks.kommand_lib.KommandLib
|
||||||
|
import net.hareworks.kommand_lib.context.KommandContext
|
||||||
|
import net.hareworks.kommand_lib.kommand
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinHiddenLayer
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinSettings
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.StoredLocation
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.StoredProfile
|
||||||
|
import net.hareworks.npc_mannequin.service.MannequinRegistry
|
||||||
|
import net.hareworks.npc_mannequin.text.TextSerializers
|
||||||
|
import net.hareworks.permits_lib.bukkit.MutationSession
|
||||||
|
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 org.bukkit.entity.Entity
|
||||||
|
import org.bukkit.entity.Mannequin
|
||||||
|
import org.bukkit.entity.Player
|
||||||
|
import org.bukkit.entity.Pose
|
||||||
|
import org.bukkit.inventory.MainHand
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
|
object MannequinCommands {
|
||||||
|
fun register(
|
||||||
|
plugin: JavaPlugin,
|
||||||
|
registry: MannequinRegistry,
|
||||||
|
permissionSession: MutationSession?
|
||||||
|
): KommandLib = kommand(plugin) {
|
||||||
|
permissions {
|
||||||
|
namespace = "npc-mannequin"
|
||||||
|
rootSegment = "command"
|
||||||
|
defaultDescription { ctx ->
|
||||||
|
"Allows /${ctx.commandName} ${ctx.path.joinToString(" ")}"
|
||||||
|
}
|
||||||
|
permissionSession?.let { session(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
command("mannequin", listOf("mnpc", "mnq", "npcmannequin")) {
|
||||||
|
description = "Register and manage mannequin NPCs"
|
||||||
|
permission = "npc-mannequin.command.mannequin"
|
||||||
|
|
||||||
|
executes { listMannequins(registry) }
|
||||||
|
|
||||||
|
literal("list") {
|
||||||
|
executes { listMannequins(registry) }
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("register") {
|
||||||
|
string("id") {
|
||||||
|
selector("target") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val entity = argument<List<Entity>>("target").firstOrNull { it is Mannequin } as? Mannequin
|
||||||
|
if (entity == null) {
|
||||||
|
error("Selector must target at least one mannequin entity.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
runCatching {
|
||||||
|
registry.register(id, entity, overwrite = false)
|
||||||
|
}.onSuccess {
|
||||||
|
success("Registered mannequin '$id' from entity ${entity.uniqueId}.")
|
||||||
|
}.onFailure {
|
||||||
|
error(it.message ?: "Failed to register mannequin.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
literal("--overwrite") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val entity =
|
||||||
|
argument<List<Entity>>("target").firstOrNull { it is Mannequin } as? Mannequin
|
||||||
|
if (entity == null) {
|
||||||
|
error("Selector must target at least one mannequin entity.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.register(id, entity, overwrite = true)
|
||||||
|
success("Replaced mannequin '$id' with entity ${entity.uniqueId}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("create") {
|
||||||
|
string("id") {
|
||||||
|
executes {
|
||||||
|
val player = requirePlayer() ?: return@executes
|
||||||
|
val id = argument<String>("id")
|
||||||
|
runCatching {
|
||||||
|
registry.create(id, player.location, MannequinSettings())
|
||||||
|
}.onSuccess {
|
||||||
|
success("Spawned mannequin '$id' at ${formatLocation(it.location)}.")
|
||||||
|
}.onFailure {
|
||||||
|
error(it.message ?: "Failed to create mannequin.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("move") {
|
||||||
|
string("id") {
|
||||||
|
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
|
||||||
|
executes {
|
||||||
|
val player = requirePlayer() ?: return@executes
|
||||||
|
val id = argument<String>("id")
|
||||||
|
runCatching {
|
||||||
|
registry.relocate(id, player.location)
|
||||||
|
}.onSuccess {
|
||||||
|
success("Updated location of '$id' to ${formatLocation(it.location)}.")
|
||||||
|
}.onFailure {
|
||||||
|
error(it.message ?: "Failed to move mannequin.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("apply") {
|
||||||
|
string("id") {
|
||||||
|
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
runCatching {
|
||||||
|
registry.apply(id, spawnIfMissing = true)
|
||||||
|
?: throw IllegalStateException("Mannequin '$id' is not spawned and has no stored position.")
|
||||||
|
}.onSuccess {
|
||||||
|
success("Applied stored settings to '$id'.")
|
||||||
|
}.onFailure {
|
||||||
|
error(it.message ?: "Failed to apply mannequin settings.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("remove") {
|
||||||
|
string("id") {
|
||||||
|
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
runCatching { registry.remove(id, deleteEntity = false) }
|
||||||
|
.onSuccess { success("Removed mannequin '$id' but kept the entity in the world.") }
|
||||||
|
.onFailure { error(it.message ?: "Failed to remove mannequin.") }
|
||||||
|
}
|
||||||
|
literal("--delete-entity") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
runCatching { registry.remove(id, deleteEntity = true) }
|
||||||
|
.onSuccess { success("Removed mannequin '$id' and deleted its entity.") }
|
||||||
|
.onFailure { error(it.message ?: "Failed to remove mannequin.") }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("set") {
|
||||||
|
string("id") {
|
||||||
|
suggests { prefix -> registry.all().map { it.id }.filter { it.startsWith(prefix) } }
|
||||||
|
literal("pose") {
|
||||||
|
string("pose") {
|
||||||
|
suggests { prefix ->
|
||||||
|
MannequinSettings.POSES.map { it.name.lowercase() }
|
||||||
|
.filter { it.startsWith(prefix.lowercase()) }
|
||||||
|
}
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val poseToken = argument<String>("pose")
|
||||||
|
val pose = runCatching { Pose.valueOf(poseToken.uppercase()) }.getOrNull()
|
||||||
|
if (pose == null || pose !in MannequinSettings.POSES) {
|
||||||
|
error("Unknown or invalid pose '$poseToken'.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.updateSettings(id) { it.copy(pose = pose) }
|
||||||
|
success("Updated pose for '$id' to ${pose.name.lowercase()}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("mainhand") {
|
||||||
|
string("hand") {
|
||||||
|
suggests { prefix ->
|
||||||
|
MainHand.entries.map { it.name.lowercase() }
|
||||||
|
.filter { it.startsWith(prefix.lowercase()) }
|
||||||
|
}
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val handToken = argument<String>("hand")
|
||||||
|
val mainHand = runCatching { MainHand.valueOf(handToken.uppercase()) }.getOrNull()
|
||||||
|
if (mainHand == null) {
|
||||||
|
error("Unknown hand '$handToken'.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.updateSettings(id) { it.copy(mainHand = mainHand) }
|
||||||
|
success("Updated main hand for '$id' to ${mainHand.name.lowercase()}.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("immovable") {
|
||||||
|
string("state") {
|
||||||
|
suggests { prefix ->
|
||||||
|
listOf(
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
).filter { it.startsWith(prefix.lowercase()) }
|
||||||
|
}
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val input = argument<String>("state")
|
||||||
|
val state = parseBoolean(input)
|
||||||
|
if (state == null) {
|
||||||
|
error("Value must be true/false/on/off.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.updateSettings(id) { it.copy(immovable = state) }
|
||||||
|
success("Set immovable for '$id' to $state.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("description") {
|
||||||
|
literal("text") {
|
||||||
|
greedyString("content") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val payload = argument<String>("content")
|
||||||
|
val component = runCatching { TextSerializers.miniMessage(payload) }
|
||||||
|
.onFailure { error("MiniMessage parse failed: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
|
if (component == null) {
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.updateSettings(id) {
|
||||||
|
it.copy(
|
||||||
|
description = component,
|
||||||
|
hideDescription = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
success("Updated description for '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
literal("clear") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
registry.updateSettings(id) { it.copy(description = null, hideDescription = false) }
|
||||||
|
success("Cleared custom description for '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
literal("hide") {
|
||||||
|
string("state") {
|
||||||
|
suggests { prefix ->
|
||||||
|
listOf(
|
||||||
|
"true",
|
||||||
|
"false",
|
||||||
|
"on",
|
||||||
|
"off"
|
||||||
|
).filter { it.startsWith(prefix.lowercase()) }
|
||||||
|
}
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val input = argument<String>("state")
|
||||||
|
val state = parseBoolean(input)
|
||||||
|
if (state == null) {
|
||||||
|
error("Value must be true/false/on/off.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.updateSettings(id) { it.copy(hideDescription = state) }
|
||||||
|
success(if (state) "Description hidden for '$id'." else "Description visible for '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("layers") {
|
||||||
|
literal("hide") {
|
||||||
|
string("layer") {
|
||||||
|
suggests { prefix -> layerSuggestions(prefix) }
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val layerName = argument<String>("layer")
|
||||||
|
val layer = MannequinHiddenLayer.fromKey(layerName)
|
||||||
|
if (layer == null) {
|
||||||
|
error("Unknown layer '$layerName'.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.updateSettings(id) { it.copy(hiddenLayers = it.hiddenLayers + layer) }
|
||||||
|
success("Hid layer ${layer.key} for '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
literal("show") {
|
||||||
|
string("layer") {
|
||||||
|
suggests { prefix -> layerSuggestions(prefix) }
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val layerName = argument<String>("layer")
|
||||||
|
val layer = MannequinHiddenLayer.fromKey(layerName)
|
||||||
|
if (layer == null) {
|
||||||
|
error("Unknown layer '$layerName'.")
|
||||||
|
return@executes
|
||||||
|
}
|
||||||
|
registry.updateSettings(id) { it.copy(hiddenLayers = it.hiddenLayers - layer) }
|
||||||
|
success("Enabled layer ${layer.key} for '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
literal("clear") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
registry.updateSettings(id) { it.copy(hiddenLayers = emptySet()) }
|
||||||
|
success("Cleared hidden layers for '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
literal("profile") {
|
||||||
|
literal("player") {
|
||||||
|
player("source") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
val source = argument<Player>("source")
|
||||||
|
val stored = StoredProfile.from(source.playerProfile)
|
||||||
|
registry.updateSettings(id) { it.copy(profile = stored) }
|
||||||
|
success("Copied profile from ${source.name} into '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
literal("clear") {
|
||||||
|
executes {
|
||||||
|
val id = argument<String>("id")
|
||||||
|
registry.updateSettings(id) { it.copy(profile = null) }
|
||||||
|
success("Cleared stored profile for '$id'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val DESCRIPTION_TEXT_OFFSET = 4
|
||||||
|
private const val COMMAND_TEXT_OFFSET = 5
|
||||||
|
|
||||||
|
private fun KommandContext.listMannequins(registry: MannequinRegistry) {
|
||||||
|
val entries = registry.all()
|
||||||
|
if (entries.isEmpty()) {
|
||||||
|
sender.sendMessage(Component.text("No mannequins registered.", NamedTextColor.YELLOW))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sender.sendMessage(Component.text("Registered mannequins (${entries.size}):", NamedTextColor.GRAY))
|
||||||
|
entries.forEach { record ->
|
||||||
|
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)
|
||||||
|
sender.sendMessage(
|
||||||
|
idComponent
|
||||||
|
.append(statusComponent)
|
||||||
|
.append(Component.text(" @ $location", NamedTextColor.GRAY))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun KommandContext.requirePlayer(): Player? {
|
||||||
|
return sender as? Player ?: run {
|
||||||
|
sender.sendMessage(Component.text("This command can only be run by a player.", NamedTextColor.RED))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun KommandContext.success(message: String) {
|
||||||
|
sender.sendMessage(Component.text(message, NamedTextColor.GREEN))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun KommandContext.error(message: String) {
|
||||||
|
sender.sendMessage(Component.text(message, NamedTextColor.RED))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun layerSuggestions(prefix: String): List<String> =
|
||||||
|
MannequinHiddenLayer.entries.map { it.key }.filter { it.startsWith(prefix.lowercase()) }
|
||||||
|
|
||||||
|
private fun parseBoolean(input: String): Boolean? = when (input.lowercase()) {
|
||||||
|
"true", "on", "yes", "1" -> true
|
||||||
|
"false", "off", "no", "0" -> false
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatLocation(location: StoredLocation?): String =
|
||||||
|
location?.let {
|
||||||
|
val x = String.format(Locale.US, "%.2f", it.x)
|
||||||
|
val y = String.format(Locale.US, "%.2f", it.y)
|
||||||
|
val z = String.format(Locale.US, "%.2f", it.z)
|
||||||
|
"${it.world} ($x, $y, $z)"
|
||||||
|
} ?: "unknown location"
|
||||||
|
|
@ -0,0 +1,178 @@
|
||||||
|
package net.hareworks.npc_mannequin.mannequin
|
||||||
|
|
||||||
|
import com.destroystokyo.paper.SkinParts
|
||||||
|
import com.destroystokyo.paper.profile.PlayerProfile
|
||||||
|
import com.destroystokyo.paper.profile.ProfileProperty
|
||||||
|
import io.papermc.paper.datacomponent.item.ResolvableProfile
|
||||||
|
import net.kyori.adventure.text.Component
|
||||||
|
import org.bukkit.Location
|
||||||
|
import org.bukkit.entity.Mannequin
|
||||||
|
import org.bukkit.entity.Pose
|
||||||
|
import org.bukkit.inventory.MainHand
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Immutable snapshot of a mannequin definition that can be persisted and replayed.
|
||||||
|
*/
|
||||||
|
data class MannequinSettings(
|
||||||
|
val pose: Pose = Pose.STANDING,
|
||||||
|
val mainHand: MainHand = MainHand.RIGHT,
|
||||||
|
val immovable: Boolean = false,
|
||||||
|
val description: Component? = null,
|
||||||
|
val hideDescription: Boolean = false,
|
||||||
|
val hiddenLayers: Set<MannequinHiddenLayer> = emptySet(),
|
||||||
|
val profile: StoredProfile? = null,
|
||||||
|
val respawnDelay: Int = 0,
|
||||||
|
val serverCommand: String? = null,
|
||||||
|
val playerCommand: String? = null
|
||||||
|
) {
|
||||||
|
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 {
|
||||||
|
val skinParts = entity.skinParts
|
||||||
|
return MannequinSettings(
|
||||||
|
pose = entity.pose,
|
||||||
|
mainHand = entity.mainHand,
|
||||||
|
immovable = entity.isImmovable,
|
||||||
|
description = entity.description,
|
||||||
|
hideDescription = entity.description == Component.empty(),
|
||||||
|
hiddenLayers = MannequinHiddenLayer.fromSkinParts(skinParts),
|
||||||
|
profile = StoredProfile.from(entity.profile)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Named layers that can individually be hidden on top of the default mannequin skin.
|
||||||
|
*/
|
||||||
|
enum class MannequinHiddenLayer(val key: String) {
|
||||||
|
CAPE("cape"),
|
||||||
|
JACKET("jacket"),
|
||||||
|
LEFT_SLEEVE("left_sleeve"),
|
||||||
|
RIGHT_SLEEVE("right_sleeve"),
|
||||||
|
LEFT_PANTS("left_pants_leg"),
|
||||||
|
RIGHT_PANTS("right_pants_leg"),
|
||||||
|
HAT("hat");
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun fromKey(input: String): MannequinHiddenLayer? =
|
||||||
|
entries.firstOrNull { it.key.equals(input, ignoreCase = true) }
|
||||||
|
|
||||||
|
fun fromSkinParts(parts: SkinParts): Set<MannequinHiddenLayer> {
|
||||||
|
val hidden = mutableSetOf<MannequinHiddenLayer>()
|
||||||
|
if (!parts.hasCapeEnabled()) hidden += CAPE
|
||||||
|
if (!parts.hasJacketEnabled()) hidden += JACKET
|
||||||
|
if (!parts.hasLeftSleeveEnabled()) hidden += LEFT_SLEEVE
|
||||||
|
if (!parts.hasRightSleeveEnabled()) hidden += RIGHT_SLEEVE
|
||||||
|
if (!parts.hasLeftPantsEnabled()) hidden += LEFT_PANTS
|
||||||
|
if (!parts.hasRightPantsEnabled()) hidden += RIGHT_PANTS
|
||||||
|
if (!parts.hasHatsEnabled()) hidden += HAT
|
||||||
|
return hidden
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Serializable skeleton of a player/mannequin profile (skin/cape/model definition).
|
||||||
|
*/
|
||||||
|
data class StoredProfile(
|
||||||
|
val name: String?,
|
||||||
|
val uuid: UUID?,
|
||||||
|
val properties: List<StoredProfileProperty>,
|
||||||
|
) {
|
||||||
|
fun toResolvable(): ResolvableProfile {
|
||||||
|
val builder = ResolvableProfile.resolvableProfile()
|
||||||
|
name?.let { builder.name(it) }
|
||||||
|
uuid?.let { builder.uuid(it) }
|
||||||
|
if (properties.isEmpty()) {
|
||||||
|
builder.addProperties(emptyList())
|
||||||
|
} else {
|
||||||
|
properties.forEach { builder.addProperty(ProfileProperty(it.name, it.value, it.signature)) }
|
||||||
|
}
|
||||||
|
return builder.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(profile: ResolvableProfile): StoredProfile {
|
||||||
|
val props = profile.properties()
|
||||||
|
.map { StoredProfileProperty(it.name, it.value, it.signature) }
|
||||||
|
return StoredProfile(
|
||||||
|
name = profile.name(),
|
||||||
|
uuid = profile.uuid(),
|
||||||
|
properties = props
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun from(profile: PlayerProfile): StoredProfile {
|
||||||
|
val props = profile.properties.map { StoredProfileProperty(it.name, it.value, it.signature) }
|
||||||
|
return StoredProfile(
|
||||||
|
name = profile.name,
|
||||||
|
uuid = profile.id,
|
||||||
|
properties = props
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class StoredProfileProperty(
|
||||||
|
val name: String,
|
||||||
|
val value: String,
|
||||||
|
val signature: String?
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small serializable wrapper around a Bukkit location so we can persist mannequins across restarts.
|
||||||
|
*/
|
||||||
|
data class StoredLocation(
|
||||||
|
val world: String,
|
||||||
|
val x: Double,
|
||||||
|
val y: Double,
|
||||||
|
val z: Double,
|
||||||
|
val yaw: Float,
|
||||||
|
val pitch: Float
|
||||||
|
) {
|
||||||
|
fun toLocation(base: org.bukkit.Server): Location? {
|
||||||
|
val worldObj = base.getWorld(world) ?: return null
|
||||||
|
return Location(worldObj, x, y, z, yaw, pitch)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
fun from(location: Location): StoredLocation = StoredLocation(
|
||||||
|
world = location.world?.name ?: throw IllegalStateException("Location has no world"),
|
||||||
|
x = location.x,
|
||||||
|
y = location.y,
|
||||||
|
z = location.z,
|
||||||
|
yaw = location.yaw,
|
||||||
|
pitch = location.pitch
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Full registry entry containing the data snapshot and bookkeeping metadata.
|
||||||
|
*/
|
||||||
|
data class MannequinRecord(
|
||||||
|
val id: String,
|
||||||
|
val settings: MannequinSettings,
|
||||||
|
val location: StoredLocation?,
|
||||||
|
val entityId: UUID?
|
||||||
|
) {
|
||||||
|
fun updateSettings(next: MannequinSettings): MannequinRecord =
|
||||||
|
copy(settings = next)
|
||||||
|
|
||||||
|
fun updateLocation(next: StoredLocation?): MannequinRecord =
|
||||||
|
copy(location = next)
|
||||||
|
|
||||||
|
fun updateEntityId(next: UUID?): MannequinRecord =
|
||||||
|
copy(entityId = next)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package net.hareworks.npc_mannequin.service
|
||||||
|
|
||||||
|
import com.destroystokyo.paper.SkinParts
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinHiddenLayer
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinRecord
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinSettings
|
||||||
|
import net.kyori.adventure.text.Component
|
||||||
|
import org.bukkit.Location
|
||||||
|
import org.bukkit.entity.Mannequin
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
|
class MannequinController(private val plugin: JavaPlugin) {
|
||||||
|
|
||||||
|
fun extractSettings(entity: Mannequin): MannequinSettings = MannequinSettings.from(entity)
|
||||||
|
|
||||||
|
fun applySettings(entity: Mannequin, settings: MannequinSettings) {
|
||||||
|
entity.pose = settings.pose
|
||||||
|
entity.mainHand = settings.mainHand
|
||||||
|
entity.isImmovable = settings.immovable
|
||||||
|
val skinParts = entity.skinParts.mutableCopy()
|
||||||
|
applyLayers(settings.hiddenLayers, skinParts)
|
||||||
|
entity.setSkinParts(skinParts)
|
||||||
|
val description = when {
|
||||||
|
settings.hideDescription -> Component.empty()
|
||||||
|
settings.description != null -> settings.description
|
||||||
|
else -> Mannequin.defaultDescription()
|
||||||
|
}
|
||||||
|
entity.description = description
|
||||||
|
val profile = settings.profile?.toResolvable() ?: Mannequin.defaultProfile()
|
||||||
|
entity.profile = profile
|
||||||
|
}
|
||||||
|
|
||||||
|
fun spawn(location: Location, settings: MannequinSettings): Mannequin {
|
||||||
|
val world = location.world ?: throw IllegalStateException("Cannot spawn mannequin without world")
|
||||||
|
return world.spawn(location, Mannequin::class.java) { mannequin ->
|
||||||
|
applySettings(mannequin, settings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun locate(record: MannequinRecord): Mannequin? {
|
||||||
|
record.entityId?.let { plugin.server.getEntity(it) as? Mannequin }?.let { return it }
|
||||||
|
val location = record.location?.toLocation(plugin.server) ?: return null
|
||||||
|
val world = location.world ?: return null
|
||||||
|
val results = world.getNearbyEntitiesByType(Mannequin::class.java, location, 0.75, 0.75) { true }
|
||||||
|
return results.minByOrNull { it.location.distanceSquared(location) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun applyLayers(hidden: Set<MannequinHiddenLayer>, parts: SkinParts.Mutable) {
|
||||||
|
parts.setCapeEnabled(!hidden.contains(MannequinHiddenLayer.CAPE))
|
||||||
|
parts.setJacketEnabled(!hidden.contains(MannequinHiddenLayer.JACKET))
|
||||||
|
parts.setLeftSleeveEnabled(!hidden.contains(MannequinHiddenLayer.LEFT_SLEEVE))
|
||||||
|
parts.setRightSleeveEnabled(!hidden.contains(MannequinHiddenLayer.RIGHT_SLEEVE))
|
||||||
|
parts.setLeftPantsEnabled(!hidden.contains(MannequinHiddenLayer.LEFT_PANTS))
|
||||||
|
parts.setRightPantsEnabled(!hidden.contains(MannequinHiddenLayer.RIGHT_PANTS))
|
||||||
|
parts.setHatsEnabled(!hidden.contains(MannequinHiddenLayer.HAT))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package net.hareworks.npc_mannequin.service
|
||||||
|
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinRecord
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinSettings
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.StoredLocation
|
||||||
|
import net.hareworks.npc_mannequin.storage.MannequinStorage
|
||||||
|
import org.bukkit.Location
|
||||||
|
import org.bukkit.entity.Mannequin
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
|
||||||
|
class MannequinRegistry(
|
||||||
|
private val plugin: JavaPlugin,
|
||||||
|
private val storage: MannequinStorage,
|
||||||
|
private val controller: MannequinController
|
||||||
|
) {
|
||||||
|
private val records: MutableMap<String, MannequinRecord> = storage.load().toMutableMap()
|
||||||
|
|
||||||
|
fun all(): Collection<MannequinRecord> = records.values.sortedBy { it.id }
|
||||||
|
|
||||||
|
fun find(id: String): MannequinRecord? = records[id]
|
||||||
|
|
||||||
|
fun require(id: String): MannequinRecord =
|
||||||
|
records[id] ?: error("Mannequin '$id' is not registered.")
|
||||||
|
|
||||||
|
fun register(id: String, entity: Mannequin, overwrite: Boolean = false): MannequinRecord {
|
||||||
|
if (!overwrite && records.containsKey(id)) {
|
||||||
|
error("Mannequin '$id' already exists. Use overwrite to replace it.")
|
||||||
|
}
|
||||||
|
val snapshot = controller.extractSettings(entity)
|
||||||
|
val record = MannequinRecord(
|
||||||
|
id = id,
|
||||||
|
settings = snapshot,
|
||||||
|
location = StoredLocation.from(entity.location),
|
||||||
|
entityId = entity.uniqueId
|
||||||
|
)
|
||||||
|
records[id] = record
|
||||||
|
persist()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
fun create(id: String, location: Location, template: MannequinSettings = MannequinSettings()): MannequinRecord {
|
||||||
|
if (records.containsKey(id)) {
|
||||||
|
error("Mannequin '$id' already exists.")
|
||||||
|
}
|
||||||
|
val mannequin = controller.spawn(location, template)
|
||||||
|
val record = MannequinRecord(
|
||||||
|
id = id,
|
||||||
|
settings = template,
|
||||||
|
location = StoredLocation.from(location),
|
||||||
|
entityId = mannequin.uniqueId
|
||||||
|
)
|
||||||
|
records[id] = record
|
||||||
|
persist()
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
fun updateSettings(id: String, updater: (MannequinSettings) -> MannequinSettings): MannequinRecord {
|
||||||
|
val existing = require(id)
|
||||||
|
val nextSettings = updater(existing.settings)
|
||||||
|
val updated = existing.updateSettings(nextSettings)
|
||||||
|
records[id] = updated
|
||||||
|
persist()
|
||||||
|
controller.locate(updated)?.let { controller.applySettings(it, nextSettings) }
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
fun apply(id: String, spawnIfMissing: Boolean = true): Mannequin? {
|
||||||
|
val record = require(id)
|
||||||
|
var entity = controller.locate(record)
|
||||||
|
if (entity == null && spawnIfMissing) {
|
||||||
|
val location = record.location?.toLocation(plugin.server)
|
||||||
|
?: error("Mannequin '$id' does not have a saved location to respawn.")
|
||||||
|
entity = controller.spawn(location, record.settings)
|
||||||
|
}
|
||||||
|
entity?.let {
|
||||||
|
controller.applySettings(it, record.settings)
|
||||||
|
records[id] = record.updateEntityId(it.uniqueId)
|
||||||
|
persist()
|
||||||
|
}
|
||||||
|
return entity
|
||||||
|
}
|
||||||
|
|
||||||
|
fun relocate(id: String, location: Location, teleport: Boolean = true): MannequinRecord {
|
||||||
|
val record = require(id)
|
||||||
|
val stored = StoredLocation.from(location)
|
||||||
|
val updated = record.updateLocation(stored)
|
||||||
|
val entity = if (teleport) controller.locate(updated) else null
|
||||||
|
val finalRecord = if (entity != null) {
|
||||||
|
entity.teleport(location)
|
||||||
|
updated.updateEntityId(entity.uniqueId)
|
||||||
|
} else {
|
||||||
|
updated
|
||||||
|
}
|
||||||
|
records[id] = finalRecord
|
||||||
|
persist()
|
||||||
|
return finalRecord
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unlink(id: String): MannequinRecord {
|
||||||
|
val record = require(id)
|
||||||
|
val updated = record.updateEntityId(null)
|
||||||
|
records[id] = updated
|
||||||
|
persist()
|
||||||
|
return updated
|
||||||
|
}
|
||||||
|
|
||||||
|
fun remove(id: String, deleteEntity: Boolean) {
|
||||||
|
val record = require(id)
|
||||||
|
records.remove(id)
|
||||||
|
persist()
|
||||||
|
if (deleteEntity) {
|
||||||
|
controller.locate(record)?.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun locate(id: String): Mannequin? = controller.locate(require(id))
|
||||||
|
|
||||||
|
private fun persist() {
|
||||||
|
storage.save(records.values)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
package net.hareworks.npc_mannequin.storage
|
||||||
|
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinHiddenLayer
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinRecord
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.MannequinSettings
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.StoredLocation
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.StoredProfile
|
||||||
|
import net.hareworks.npc_mannequin.mannequin.StoredProfileProperty
|
||||||
|
import net.hareworks.npc_mannequin.text.TextSerializers
|
||||||
|
import org.bukkit.configuration.ConfigurationSection
|
||||||
|
import org.bukkit.configuration.file.YamlConfiguration
|
||||||
|
import org.bukkit.entity.Pose
|
||||||
|
import org.bukkit.inventory.MainHand
|
||||||
|
import org.bukkit.plugin.java.JavaPlugin
|
||||||
|
import java.io.File
|
||||||
|
import java.io.IOException
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
class MannequinStorage(private val plugin: JavaPlugin) {
|
||||||
|
private val file: File by lazy {
|
||||||
|
plugin.dataFolder.mkdirs()
|
||||||
|
File(plugin.dataFolder, "mannequins.yml")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun load(): Map<String, MannequinRecord> {
|
||||||
|
if (!file.exists()) return emptyMap()
|
||||||
|
val config = YamlConfiguration()
|
||||||
|
try {
|
||||||
|
config.load(file)
|
||||||
|
} catch (ex: Exception) {
|
||||||
|
plugin.logger.severe("Failed to load mannequin data: ${ex.message}")
|
||||||
|
return emptyMap()
|
||||||
|
}
|
||||||
|
val records = mutableMapOf<String, MannequinRecord>()
|
||||||
|
val section = config.getConfigurationSection("mannequins") ?: return emptyMap()
|
||||||
|
for (key in section.getKeys(false)) {
|
||||||
|
val record = section.getConfigurationSection(key)?.let { deserializeRecord(key, it) } ?: continue
|
||||||
|
records[key] = record
|
||||||
|
}
|
||||||
|
return records
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(records: Collection<MannequinRecord>) {
|
||||||
|
val config = YamlConfiguration()
|
||||||
|
val root = config.createSection("mannequins")
|
||||||
|
records.forEach { record ->
|
||||||
|
val section = root.createSection(record.id)
|
||||||
|
serializeRecord(section, record)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
config.save(file)
|
||||||
|
} catch (ex: IOException) {
|
||||||
|
plugin.logger.severe("Failed to save mannequin data: ${ex.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deserializeRecord(id: String, section: ConfigurationSection): MannequinRecord? {
|
||||||
|
val pose = section.getString("pose")?.let { runCatching { Pose.valueOf(it) }.getOrNull() } ?: Pose.STANDING
|
||||||
|
val mainHand = section.getString("mainHand")
|
||||||
|
?.let { runCatching { MainHand.valueOf(it) }.getOrNull() } ?: MainHand.RIGHT
|
||||||
|
val immovable = section.getBoolean("immovable", false)
|
||||||
|
val hideDescription = section.getBoolean("hideDescription", false)
|
||||||
|
val description = section.getString("description")?.let { TextSerializers.miniMessage(it) }
|
||||||
|
val hiddenLayers = section.getStringList("hiddenLayers")
|
||||||
|
.mapNotNull { MannequinHiddenLayer.fromKey(it) }
|
||||||
|
.toSet()
|
||||||
|
val profileSection = section.getConfigurationSection("profile")
|
||||||
|
val profile = profileSection?.let(::deserializeProfile)
|
||||||
|
val locationSection = section.getConfigurationSection("location")
|
||||||
|
val entityId = section.getString("entityId")?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||||
|
val location = locationSection?.let {
|
||||||
|
val world = it.getString("world") ?: return@let null
|
||||||
|
val x = it.getDouble("x")
|
||||||
|
val y = it.getDouble("y")
|
||||||
|
val z = it.getDouble("z")
|
||||||
|
val yaw = it.getDouble("yaw").toFloat()
|
||||||
|
val pitch = it.getDouble("pitch").toFloat()
|
||||||
|
StoredLocation(world, x, y, z, yaw, pitch)
|
||||||
|
}
|
||||||
|
val settings = MannequinSettings(
|
||||||
|
pose = pose,
|
||||||
|
mainHand = mainHand,
|
||||||
|
immovable = immovable,
|
||||||
|
description = description,
|
||||||
|
hideDescription = hideDescription,
|
||||||
|
hiddenLayers = hiddenLayers,
|
||||||
|
profile = profile,
|
||||||
|
serverCommand = section.getString("serverCommand"),
|
||||||
|
playerCommand = section.getString("playerCommand")
|
||||||
|
)
|
||||||
|
return MannequinRecord(id, settings, location, entityId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serializeRecord(section: ConfigurationSection, record: MannequinRecord) {
|
||||||
|
section.set("pose", record.settings.pose.name)
|
||||||
|
section.set("mainHand", record.settings.mainHand.name)
|
||||||
|
section.set("immovable", record.settings.immovable)
|
||||||
|
section.set("hideDescription", record.settings.hideDescription)
|
||||||
|
section.set("description", TextSerializers.miniMessage(record.settings.description))
|
||||||
|
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 ->
|
||||||
|
val profileSection = section.createSection("profile")
|
||||||
|
profileSection.set("name", profile.name)
|
||||||
|
profileSection.set("uuid", profile.uuid?.toString())
|
||||||
|
val properties = profile.properties.mapIndexed { index, property ->
|
||||||
|
mapOf(
|
||||||
|
"name" to property.name,
|
||||||
|
"value" to property.value,
|
||||||
|
"signature" to property.signature
|
||||||
|
)
|
||||||
|
}
|
||||||
|
profileSection.set("properties", properties)
|
||||||
|
}
|
||||||
|
record.location?.let {
|
||||||
|
val locationSection = section.createSection("location")
|
||||||
|
locationSection.set("world", it.world)
|
||||||
|
locationSection.set("x", it.x)
|
||||||
|
locationSection.set("y", it.y)
|
||||||
|
locationSection.set("z", it.z)
|
||||||
|
locationSection.set("yaw", it.yaw)
|
||||||
|
locationSection.set("pitch", it.pitch)
|
||||||
|
}
|
||||||
|
section.set("entityId", record.entityId?.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deserializeProfile(section: ConfigurationSection): StoredProfile? {
|
||||||
|
val name = section.getString("name")
|
||||||
|
val uuid = section.getString("uuid")?.let { runCatching { UUID.fromString(it) }.getOrNull() }
|
||||||
|
val properties = section.getList("properties")
|
||||||
|
?.filterIsInstance<Map<*, *>>()
|
||||||
|
?.mapNotNull { entry ->
|
||||||
|
val propertyName = entry["name"] as? String ?: return@mapNotNull null
|
||||||
|
val value = entry["value"] as? String ?: return@mapNotNull null
|
||||||
|
val signature = entry["signature"] as? String
|
||||||
|
StoredProfileProperty(propertyName, value, signature)
|
||||||
|
}
|
||||||
|
?: emptyList()
|
||||||
|
if (name == null && uuid == null && properties.isEmpty()) return null
|
||||||
|
return StoredProfile(name, uuid, properties)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package net.hareworks.npc_mannequin.text
|
||||||
|
|
||||||
|
import net.kyori.adventure.text.Component
|
||||||
|
import net.kyori.adventure.text.minimessage.MiniMessage
|
||||||
|
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer
|
||||||
|
|
||||||
|
object TextSerializers {
|
||||||
|
private val miniMessage = MiniMessage.miniMessage()
|
||||||
|
private val plain = PlainTextComponentSerializer.plainText()
|
||||||
|
|
||||||
|
fun miniMessage(serialized: String?): Component? =
|
||||||
|
serialized?.takeIf { it.isNotBlank() }?.let { miniMessage.deserialize(it) }
|
||||||
|
|
||||||
|
fun miniMessage(component: Component?): String? =
|
||||||
|
component?.let { miniMessage.serialize(it) }
|
||||||
|
|
||||||
|
fun plain(component: Component?): String =
|
||||||
|
component?.let { plain.serialize(it) }.orEmpty()
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user