- Permits-libを用いたダイナミックPermission対応
- ビルド設定の見直し
This commit is contained in:
Keisuke Hirata 2025-11-28 09:02:25 +09:00
parent dec60a1c46
commit 8b69ad484d
14 changed files with 454 additions and 61 deletions

View File

@ -9,6 +9,7 @@ Paper/Bukkit サーバー向けのコマンド定義を DSL で記述するた
- 1 つの定義から実行とタブ補完の両方を生成
- パーミッションや条件をノード単位で宣言し、子ノードへ自動伝播
- `suggests {}` で引数ごとの補完候補を柔軟に制御
- `permits-lib` との連携により、コマンドツリーから Bukkit パーミッションを自動生成し、`compileOnly` 依存として参照可能
## 依存関係
@ -24,6 +25,8 @@ plugins {
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
compileOnly("org.jetbrains.kotlin:kotlin-stdlib")
// ../permits-lib を includeBuild しているので module 参照でOK
compileOnly("net.hareworks.hcu:permits-lib:1.0")
}
```
@ -107,6 +110,40 @@ class EconomyPlugin : JavaPlugin() {
- `command` や各ノードの `condition { sender -> ... }` で実行条件 (例: コンソール禁止) を追加できます。
- ルートレベルで `executes { ... }` を指定すると、引数なしで `/eco` を実行した場合に呼び出されます。
## 自動パーミッション生成 (`permits-lib`)
`permits-lib``compileOnly` で参照している場合、DSL から Bukkit パーミッションツリーを自動生成できます。
```kotlin
commands = kommand(this) {
permissions {
namespace = "hareworks"
rootSegment = "command"
defaultDescription { ctx ->
"Allows /${ctx.commandName} (${ctx.path.joinToString(" ")})"
}
}
command("eco") {
permission {
description = "Allows /eco"
tags("economy")
}
literal("give") {
permission {
description = "Allows /eco give"
}
}
}
}
```
- `permissions { ... }` で名前空間やルートセグメント (`rootSegment = "command"`) を定義すると、`hareworks.command.eco`, `hareworks.command.eco.give` のような ID が自動生成され、`permits-lib` の `MutationSession` を使って Bukkit に適用されます。
- 各 `command`/`literal`/`argument` ブロック内で `permission { ... }` を宣言すると、説明文・デフォルト値・タグ・パスの上書きを細かく制御できます。`skipPermission()` を呼び出せば、そのノードだけ自動生成から除外されます。
- `requires("custom.id")` を指定した場合も、同じ ID が DSL の実行と `permits-lib` への登録の両方で利用されます (名前空間外の ID は登録対象外になります)。
- `KommandLib` のライフサイクルに合わせて `MutationSession` が適用/解除されるため、プラグインの有効化・無効化に伴い Bukkit のパーミッションリストも最新状態に保たれます。
## 組み込み引数の一覧
| DSL | 返り値 | 補足 |

View File

@ -1,7 +1,7 @@
import net.minecrell.pluginyml.bukkit.BukkitPluginDescription
import net.minecrell.pluginyml.paper.PaperPluginDescription
group = "net.hareworks.hcu"
version = "1.0"
group = "net.hareworks"
version = "1.1"
plugins {
kotlin("jvm") version "2.2.21"
@ -18,22 +18,31 @@ val exposedVersion = "1.0.0-rc-3"
dependencies {
compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT")
compileOnly("org.jetbrains.kotlin:kotlin-stdlib")
compileOnly("net.hareworks:permits-lib:1.1")
}
tasks {
shadowJar {
archiveBaseName.set("economy")
minimize()
archiveBaseName.set("Kommand-Lib")
archiveClassifier.set("")
}
jar {
enabled = false
}
}
paper {
main = "net.hareworks.kommand_lib.App"
main = "net.hareworks.kommand_lib.plugin.Plugin"
name = "kommand-lib"
description = "Command library"
version = getVersion().toString()
apiVersion = "1.21.10"
authors =
listOf(
"Hare-K02"
)
authors = listOf(
"Hare-K02"
)
serverDependencies {
register("permits-lib") {
load = PaperPluginDescription.RelativeLoadOrder.BEFORE
}
}
}

View File

@ -5,3 +5,4 @@ org.gradle.configuration-cache=true
org.gradle.parallel=true
org.gradle.caching=true
kotlin.stdlib.default.dependency=false

View File

@ -1,9 +1,3 @@
/*
* This file was generated by the Gradle 'init' task.
*
* The settings file is used to specify which projects to include in your build.
* For more detailed information on multi-project builds, please refer to https://docs.gradle.org/8.14.3/userguide/multi_project_builds.html in the Gradle documentation.
* This project uses @Incubating APIs which are subject to change.
*/
rootProject.name = "kommand-lib"
includeBuild("../permits-lib")

View File

@ -1,14 +0,0 @@
package net.hareworks.kommand_lib;
import org.bukkit.plugin.java.JavaPlugin
public class App : JavaPlugin() {
companion object {
lateinit var instance: App
private set
}
override fun onEnable() {
instance = this
}
}

View File

@ -4,6 +4,8 @@ import net.hareworks.kommand_lib.context.KommandContext
import net.hareworks.kommand_lib.execution.CommandTree
import net.hareworks.kommand_lib.execution.ParseMode
import net.hareworks.kommand_lib.dsl.KommandRegistry
import net.hareworks.kommand_lib.permissions.PermissionOptions
import net.hareworks.kommand_lib.permissions.PermissionRuntime
import org.bukkit.Bukkit
import org.bukkit.command.CommandMap
import org.bukkit.command.CommandSender
@ -11,30 +13,6 @@ import org.bukkit.command.PluginCommand
import org.bukkit.command.TabCompleter
import org.bukkit.plugin.java.JavaPlugin
/**
* Entry-point for registering commands via the Kommand DSL.
*
* Example:
* ```kotlin
* kommand(plugin) {
* command("eco", "economy") {
* description = "Economy management"
* permission = "example.eco"
*
* literal("give") {
* string("player")
* integer("amount", min = 0) {
* executes {
* val targetName = string("player")
* val amount = int("amount")
* sender.sendMessage("Giving $amount to $targetName")
* }
* }
* }
* }
* }
* ```
*/
fun kommand(plugin: JavaPlugin, block: KommandRegistry.() -> Unit): KommandLib {
val registry = KommandRegistry(plugin)
registry.block()
@ -46,7 +24,8 @@ fun kommand(plugin: JavaPlugin, block: KommandRegistry.() -> Unit): KommandLib {
*/
class KommandLib internal constructor(
private val plugin: JavaPlugin,
private val definitions: List<CommandDefinition>
private val definitions: List<CommandDefinition>,
private val permissionRuntime: PermissionRuntime?
) {
private val commandMap: CommandMap by lazy {
val field = Bukkit.getServer().javaClass.getDeclaredField("commandMap")
@ -57,6 +36,9 @@ class KommandLib internal constructor(
init {
registerAll()
permissionRuntime?.let {
if (it.config.autoApply) it.apply()
}
}
private fun registerAll() {
@ -82,6 +64,7 @@ class KommandLib internal constructor(
fun unregister() {
registered.forEach { it.unregister(commandMap) }
registered.clear()
permissionRuntime?.clear()
}
private fun newPluginCommand(name: String): PluginCommand {
@ -96,10 +79,11 @@ internal data class CommandDefinition(
val aliases: List<String>,
val description: String?,
val usage: String?,
val permission: String?,
var permission: String?,
val rootCondition: (CommandSender) -> Boolean,
val rootExecutor: (KommandContext.() -> Unit)?,
val nodes: List<net.hareworks.kommand_lib.nodes.KommandNode>
val nodes: List<net.hareworks.kommand_lib.nodes.KommandNode>,
val permissionOptions: PermissionOptions
) {
private val tree = CommandTree(nodes)

View File

@ -0,0 +1,6 @@
package net.hareworks.kommand_lib.plugin;
import org.bukkit.plugin.java.JavaPlugin
@Suppress("unused")
public class Plugin : JavaPlugin() {}

View File

@ -2,6 +2,10 @@ package net.hareworks.kommand_lib.dsl
import net.hareworks.kommand_lib.CommandDefinition
import net.hareworks.kommand_lib.arguments.*
import net.hareworks.kommand_lib.permissions.PermissionConfigBuilder
import net.hareworks.kommand_lib.permissions.PermissionOptions
import net.hareworks.kommand_lib.permissions.PermissionPlanner
import net.hareworks.kommand_lib.permissions.PermissionRuntime
import net.hareworks.kommand_lib.nodes.Axis
import net.hareworks.kommand_lib.nodes.CoordinateAxisNode
import net.hareworks.kommand_lib.nodes.KommandNode
@ -15,6 +19,7 @@ import org.bukkit.plugin.java.JavaPlugin
@KommandDsl
class KommandRegistry internal constructor(private val plugin: JavaPlugin) {
private val definitions = mutableListOf<CommandDefinition>()
private var permissionConfigBuilder: PermissionConfigBuilder? = null
/**
* Declares a new command root.
@ -31,8 +36,20 @@ class KommandRegistry internal constructor(private val plugin: JavaPlugin) {
definitions += builder.build()
}
internal fun build(): net.hareworks.kommand_lib.KommandLib =
net.hareworks.kommand_lib.KommandLib(plugin, definitions.toList())
fun permissions(block: PermissionConfigBuilder.() -> Unit) {
val builder = permissionConfigBuilder ?: PermissionConfigBuilder(plugin).also { permissionConfigBuilder = it }
builder.block()
}
internal fun build(): net.hareworks.kommand_lib.KommandLib {
val snapshot = definitions.toList()
val config = permissionConfigBuilder?.build()
val runtime = config?.let {
val plan = PermissionPlanner(plugin, it, snapshot).plan()
if (plan.isEmpty()) null else PermissionRuntime(plugin, plan)
}
return net.hareworks.kommand_lib.KommandLib(plugin, snapshot, runtime)
}
}
@KommandDsl
@ -43,6 +60,13 @@ class CommandBuilder internal constructor(
var description: String? = null
var usage: String? = null
var permission: String? = null
set(value) {
field = value
if (!value.isNullOrBlank()) {
permissionOptions.id = value
}
}
val permissionOptions: PermissionOptions = PermissionOptions()
private var condition: (CommandSender) -> Boolean = { true }
private var rootExecutor: (net.hareworks.kommand_lib.context.KommandContext.() -> Unit)? = null
@ -61,6 +85,14 @@ class CommandBuilder internal constructor(
override val inheritedCondition: (CommandSender) -> Boolean
get() = condition
fun permission(block: PermissionOptions.() -> Unit) {
permissionOptions.block()
}
fun skipPermission() {
permissionOptions.skipPermission()
}
internal fun build(): CommandDefinition =
CommandDefinition(
name = name,
@ -70,7 +102,8 @@ class CommandBuilder internal constructor(
permission = permission,
rootCondition = condition,
rootExecutor = rootExecutor,
nodes = children.toList()
nodes = children.toList(),
permissionOptions = permissionOptions
)
}
@ -144,6 +177,9 @@ abstract class BranchScope internal constructor(
node.permission = inheritedPermission
node.condition = inheritedCondition
}
xNode.permissionOptions.skipPermission()
yNode.permissionOptions.skipPermission()
zNode.permissionOptions.path(name)
xNode.children += yNode
yNode.children += zNode
children += xNode
@ -163,6 +199,7 @@ abstract class NodeScope internal constructor(
fun requires(permission: String) {
node.permission = permission
node.permissionOptions.id = permission
}
fun condition(predicate: (CommandSender) -> Boolean) {
@ -172,6 +209,14 @@ abstract class NodeScope internal constructor(
fun executes(block: net.hareworks.kommand_lib.context.KommandContext.() -> Unit) {
node.executor = block
}
fun permission(block: PermissionOptions.() -> Unit) {
node.permissionOptions.block()
}
fun skipPermission() {
node.permissionOptions.skipPermission()
}
}
@KommandDsl

View File

@ -5,6 +5,7 @@ import net.hareworks.kommand_lib.arguments.Coordinates3
import net.hareworks.kommand_lib.arguments.KommandArgumentType
import net.hareworks.kommand_lib.context.KommandContext
import net.hareworks.kommand_lib.execution.ParseMode
import net.hareworks.kommand_lib.permissions.PermissionOptions
import org.bukkit.command.CommandSender
abstract class KommandNode internal constructor() {
@ -12,6 +13,7 @@ abstract class KommandNode internal constructor() {
var executor: (KommandContext.() -> Unit)? = null
var permission: String? = null
var condition: (CommandSender) -> Boolean = { true }
val permissionOptions: PermissionOptions = PermissionOptions()
fun isVisible(sender: CommandSender): Boolean {
val perm = permission
@ -22,6 +24,8 @@ abstract class KommandNode internal constructor() {
abstract fun consume(token: String, context: KommandContext, mode: ParseMode): Boolean
open fun undo(context: KommandContext) {}
abstract fun suggestions(prefix: String, context: KommandContext): List<String>
open fun segment(): String? = null
}
class LiteralNode internal constructor(private val literal: String) : KommandNode() {
@ -32,6 +36,8 @@ class LiteralNode internal constructor(private val literal: String) : KommandNod
override fun suggestions(prefix: String, context: KommandContext): List<String> {
return if (literal.startsWith(prefix, ignoreCase = true)) listOf(literal) else emptyList()
}
override fun segment(): String = literal
}
open class ValueNode<T> internal constructor(
@ -64,6 +70,8 @@ open class ValueNode<T> internal constructor(
if (custom != null) return custom
return type.suggestions(context, prefix)
}
override fun segment(): String = name
}
private fun coordinateAxisKey(base: String, axis: Axis): String = "$base::__${axis.name.lowercase()}"

View File

@ -0,0 +1,85 @@
package net.hareworks.kommand_lib.permissions
import net.hareworks.permits_lib.PermitsLib
import net.hareworks.permits_lib.bukkit.MutationSession
import org.bukkit.permissions.PermissionDefault
import org.bukkit.plugin.java.JavaPlugin
class PermissionConfig internal constructor(
val namespace: String,
val rootSegment: String,
val autoApply: Boolean,
val removeOnDisable: Boolean,
val includeRootNode: Boolean,
val argumentPrefix: String,
val defaultDescription: (PermissionContext) -> String?,
val defaultValue: PermissionDefault,
val defaultWildcard: Boolean,
val defaultTags: Set<String>,
private val sessionProvider: (JavaPlugin) -> MutationSession
) {
fun session(plugin: JavaPlugin): MutationSession = sessionProvider(plugin)
}
class PermissionConfigBuilder internal constructor(private val plugin: JavaPlugin) {
var namespace: String = plugin.name.lowercase()
var rootSegment: String = "command"
var autoApply: Boolean = true
var removeOnDisable: Boolean = true
var includeRootNode: Boolean = true
var argumentPrefix: String = "arg"
var defaultValue: PermissionDefault = PermissionDefault.OP
var wildcard: Boolean = true
private val tags: MutableSet<String> = linkedSetOf("kommand")
private var descriptionTemplate: (PermissionContext) -> String? = { ctx ->
when (ctx.kind) {
PermissionNodeKind.COMMAND -> "Allows /${ctx.commandName}"
PermissionNodeKind.LITERAL -> "Allows '${ctx.path.lastOrNull() ?: ctx.commandName}' sub-command"
PermissionNodeKind.ARGUMENT -> "Allows argument '${ctx.path.lastOrNull()}'"
}
}
private var sessionFactory: ((JavaPlugin) -> MutationSession)? = null
fun defaultDescription(block: (PermissionContext) -> String?) {
descriptionTemplate = block
}
fun tags(vararg values: String) {
values.filter { it.isNotBlank() }.forEach { tags += it.trim() }
}
fun session(factory: (JavaPlugin) -> MutationSession) {
sessionFactory = factory
}
fun session(instance: MutationSession) {
sessionFactory = { instance }
}
fun build(): PermissionConfig =
PermissionConfig(
namespace = namespace.trim().lowercase(),
rootSegment = rootSegment.trim().lowercase(),
autoApply = autoApply,
removeOnDisable = removeOnDisable,
includeRootNode = includeRootNode,
argumentPrefix = argumentPrefix.trim().lowercase(),
defaultDescription = descriptionTemplate,
defaultValue = defaultValue,
defaultWildcard = wildcard,
defaultTags = tags.toSet(),
sessionProvider = sessionFactory ?: { PermitsLib.session(it) }
)
}
data class PermissionContext(
val commandName: String,
val path: List<String>,
val kind: PermissionNodeKind
)
enum class PermissionNodeKind {
COMMAND,
LITERAL,
ARGUMENT
}

View File

@ -0,0 +1,37 @@
package net.hareworks.kommand_lib.permissions
import org.bukkit.permissions.PermissionDefault
class PermissionOptions {
var id: String? = null
var description: String? = null
var defaultValue: PermissionDefault? = null
var wildcard: Boolean? = null
val tags: MutableSet<String> = linkedSetOf()
var skip: Boolean = false
private var customPath: MutableList<String>? = null
internal var resolvedId: String? = null
private set
fun path(vararg segments: String) {
customPath = segments
.map { it.trim() }
.filter { it.isNotEmpty() }
.toMutableList()
}
internal fun pathOverride(): List<String>? = customPath?.toList()
internal fun resolve(id: String) {
resolvedId = id
}
fun tags(vararg values: String) {
values.filter { it.isNotBlank() }.forEach { tags += it.trim() }
}
fun skipPermission() {
skip = true
}
}

View File

@ -0,0 +1,21 @@
package net.hareworks.kommand_lib.permissions
import org.bukkit.permissions.PermissionDefault
data class PermissionPlan(
val config: PermissionConfig,
val entries: List<PlannedPermission>
) {
val namespace: String get() = config.namespace
fun isEmpty(): Boolean = entries.isEmpty()
}
data class PlannedPermission(
val id: String,
val relativePath: List<String>,
val parentPath: List<String>?,
val description: String?,
val defaultValue: PermissionDefault,
val wildcard: Boolean,
val tags: Set<String>
)

View File

@ -0,0 +1,139 @@
package net.hareworks.kommand_lib.permissions
import net.hareworks.kommand_lib.CommandDefinition
import net.hareworks.kommand_lib.nodes.KommandNode
import net.hareworks.kommand_lib.nodes.LiteralNode
import net.hareworks.kommand_lib.nodes.ValueNode
import org.bukkit.plugin.java.JavaPlugin
internal class PermissionPlanner(
private val plugin: JavaPlugin,
private val config: PermissionConfig,
private val definitions: List<CommandDefinition>
) {
fun plan(): PermissionPlan {
val entries = linkedMapOf<String, PlannedPermission>()
val rootPath = if (config.includeRootNode && config.rootSegment.isNotBlank()) {
val path = listOf(config.rootSegment)
val entry = createEntry(
options = PermissionOptions().apply { id = buildId(path) },
pathSegments = path,
context = PermissionContext(commandName = "", path = path, kind = PermissionNodeKind.LITERAL)
)
if (entry != null) entries[entry.id] = entry
path
} else {
emptyList()
}
definitions.forEach { definition ->
val overridePath = definition.permissionOptions.pathOverride()
val commandPath = if (overridePath != null) {
normalizeSegments(overridePath)
} else {
val sanitized = sanitize(definition.name)
val base = if (rootPath.isNotEmpty()) rootPath else emptyList()
base + sanitized
}
val commandEntry = createEntry(
options = definition.permissionOptions,
pathSegments = commandPath,
context = PermissionContext(definition.name, commandPath, PermissionNodeKind.COMMAND)
)
if (commandEntry != null) {
entries[commandEntry.id] = commandEntry
if (definition.permission.isNullOrBlank()) {
definition.permission = commandEntry.id
}
}
definition.nodes.forEach { node ->
planNode(node, commandPath, entries, definition.name)
}
}
return PermissionPlan(config, entries.values.toList())
}
private fun planNode(
node: KommandNode,
basePath: List<String>,
entries: MutableMap<String, PlannedPermission>,
commandName: String
) {
if (node.permissionOptions.skip) {
node.children.forEach { child ->
planNode(child, basePath, entries, commandName)
}
return
}
val segment = node.segment()?.let { sanitize(it) }
val pathAddition = node.permissionOptions.pathOverride()?.let { normalizeSegments(it) }
val path = when {
pathAddition != null -> basePath + pathAddition
segment != null -> basePath + segment
else -> basePath
}
val entry = createEntry(
options = node.permissionOptions,
pathSegments = path,
context = PermissionContext(commandName, path, node.toKind())
)
val currentBase = if (entry != null) {
entries[entry.id] = entry
if (node.permission.isNullOrBlank()) {
node.permission = entry.id
}
path
} else {
basePath
}
node.children.forEach { child ->
planNode(child, currentBase, entries, commandName)
}
}
private fun KommandNode.toKind(): PermissionNodeKind = when (this) {
is LiteralNode -> PermissionNodeKind.LITERAL
is ValueNode<*> -> PermissionNodeKind.ARGUMENT
else -> PermissionNodeKind.LITERAL
}
private fun createEntry(
options: PermissionOptions,
pathSegments: List<String>,
context: PermissionContext
): PlannedPermission? {
val finalId = (options.id?.takeIf { it.isNotBlank() } ?: buildId(pathSegments)).trim()
if (finalId.isEmpty()) return null
if (!finalId.startsWith(config.namespace)) {
plugin.logger.warning("Permission '$finalId' is outside namespace '${config.namespace}', skipping auto-registration.")
options.resolve(finalId)
return null
}
val relative = finalId.removePrefix(config.namespace).trimStart('.')
val relativePath = if (relative.isEmpty()) emptyList() else relative.split('.')
val description = options.description ?: config.defaultDescription(context)
val defaultValue = options.defaultValue ?: config.defaultValue
val wildcard = options.wildcard ?: config.defaultWildcard
val tags = (config.defaultTags + options.tags).filter { it.isNotBlank() }.toSet()
options.resolve(finalId)
val parentPath = if (relativePath.isNotEmpty()) relativePath.dropLast(1).takeIf { it.isNotEmpty() } else null
return PlannedPermission(
id = finalId,
relativePath = relativePath,
parentPath = parentPath,
description = description,
defaultValue = defaultValue,
wildcard = wildcard,
tags = tags
)
}
private fun buildId(pathSegments: List<String>): String =
(listOf(config.namespace) + pathSegments).filter { it.isNotBlank() }.joinToString(".")
private fun sanitize(segment: String): String =
segment.trim().lowercase().replace(Regex("[^a-z0-9._-]"), "-").trim('-')
private fun normalizeSegments(segments: List<String>): List<String> =
segments.map { sanitize(it) }.filter { it.isNotBlank() }
}

View File

@ -0,0 +1,41 @@
package net.hareworks.kommand_lib.permissions
import net.hareworks.permits_lib.bukkit.MutationSession
import net.hareworks.permits_lib.domain.MutablePermissionTree
import org.bukkit.plugin.java.JavaPlugin
internal class PermissionRuntime(
private val plugin: JavaPlugin,
private val plan: PermissionPlan
) {
private val session: MutationSession by lazy { plan.config.session(plugin) }
val config: PermissionConfig get() = plan.config
fun apply() {
if (plan.isEmpty()) return
val mutable = MutablePermissionTree.create(plan.config.namespace)
val sorted = plan.entries.sortedBy { it.relativePath.size }
sorted.forEach { entry ->
mutable.node(entry.relativePath.joinToString(".")) {
entry.description?.let { description = it }
defaultValue = entry.defaultValue
wildcard = entry.wildcard
entry.tags.forEach(::tag)
}
val parent = entry.parentPath
if (parent != null && parent.isNotEmpty()) {
mutable.node(parent.joinToString(".")) {
child(entry.relativePath.last())
}
}
}
session.applyTree(mutable.build())
}
fun clear() {
if (!plan.config.removeOnDisable) return
session.clearAll()
}
fun attachments() = session.attachments
}