commit b19d9cf17aad9fb079ec530379c7645cdf219d89 Author: Kariya Date: Sun Dec 7 17:21:56 2025 +0000 feat: implement initial hcu Items plugin with command and event handling for special items diff --git a/.envrc b/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/.envrc @@ -0,0 +1 @@ +use flake diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..097f9f9 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,9 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6cd6eb4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +# Ignore Gradle project-specific cache directory +.gradle +.vscode + +# Ignore Gradle build output directory +build + +.direnv diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..bf3577e --- /dev/null +++ b/.gitmodules @@ -0,0 +1,4 @@ + +[submodule "hcu-core"] + path = hcu-core + url = git@gitea.hareworks.net:hcu/hcu-core.git diff --git a/README.md b/README.md new file mode 100644 index 0000000..8a01222 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +Kotlin + Gradle kts + +# How to Build + +```./gradlew shadowJar```: \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..56ace49 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,66 @@ +import net.minecrell.pluginyml.paper.PaperPluginDescription + + +plugins { + kotlin("jvm") version "2.2.21" + id("de.eldoria.plugin-yml.paper") version "0.8.0" + id("com.gradleup.shadow") version "9.2.2" +} + +group = "com.github.kaaariyaaa" +version = "1.0" +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +val exposedVersion = "1.0.0-rc-4" + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") + implementation("org.jetbrains.kotlin:kotlin-stdlib") + + implementation("net.kyori:adventure-api:4.25.0") + implementation("net.kyori:adventure-text-minimessage:4.25.0") + + compileOnly("net.hareworks.hcu:hcu-core") + compileOnly("net.hareworks:kommand-lib") + compileOnly("net.hareworks:permits-lib") + + implementation("org.postgresql:postgresql:42.7.8") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposedVersion") + implementation("com.michael-bull.kotlin-result:kotlin-result:2.1.0") +} +tasks { + withType { + archiveBaseName.set("hcu-items") + } + shadowJar { + archiveClassifier.set("min") + minimize() + } +} + +paper { + main = "net.hareworks.hcu.items.App" + name = "hcu-items" + description = "hcu-items plugin" + version = getVersion().toString() + apiVersion = "1.21.10" + serverDependencies { + register("hcu-core") { + required = true + load = PaperPluginDescription.RelativeLoadOrder.BEFORE + } + } + authors = + listOf( + "Hare-K02", + "Kaariyaaa" + ) +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..dd5f187 --- /dev/null +++ b/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1763948260, + "narHash": "sha256-dY9qLD0H0zOUgU3vWacPY6Qc421BeQAfm8kBuBtPVE0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1c8ba8d3f7634acac4a2094eef7c32ad9106532c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..8f1efe0 --- /dev/null +++ b/flake.nix @@ -0,0 +1,45 @@ +{ + description = "Minecraft dev environment with JDK 21 and Gradle"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + jdk21 + gradle + git + unzip + ]; + + shellHook = '' + export JAVA_HOME=${pkgs.jdk21}/lib/openjdk + export PATH="$JAVA_HOME/bin:$PATH" + + export GRADLE_USER_HOME="$PWD/.gradle" + + echo "Loaded Minecraft dev env (JDK 21 + Gradle)" + java -version || true + gradle --version || true + ''; + }; + } + ); +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..ccebba7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..79a61d4 --- /dev/null +++ b/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hcu-core/.envrc b/hcu-core/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/hcu-core/.envrc @@ -0,0 +1 @@ +use flake diff --git a/hcu-core/.gitattributes b/hcu-core/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/hcu-core/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/hcu-core/.gitignore b/hcu-core/.gitignore new file mode 100644 index 0000000..5692c8a --- /dev/null +++ b/hcu-core/.gitignore @@ -0,0 +1,5 @@ +.direnv +.gradle +.kotlin +build +bin diff --git a/hcu-core/.gitmodules b/hcu-core/.gitmodules new file mode 100644 index 0000000..42b1c7e --- /dev/null +++ b/hcu-core/.gitmodules @@ -0,0 +1,4 @@ +[submodule "kommand-lib"] + path = kommand-lib + url = git@gitea.hareworks.net:Hare/kommand-lib.git + branch = master diff --git a/hcu-core/build.gradle.kts b/hcu-core/build.gradle.kts new file mode 100644 index 0000000..2b726dd --- /dev/null +++ b/hcu-core/build.gradle.kts @@ -0,0 +1,64 @@ +group = "net.hareworks.hcu" +version = "1.3" + +plugins { + kotlin("jvm") version "2.2.21" + id("de.eldoria.plugin-yml.paper") version "0.8.0" + id("com.gradleup.shadow") version "9.2.2" +} +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +val exposedVersion = "1.0.0-rc-4" + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") + + implementation("org.jetbrains.kotlin:kotlin-stdlib") + implementation("com.michael-bull.kotlin-result:kotlin-result:2.1.0") + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") + implementation("org.jetbrains.kotlinx:kotlinx-datetime:0.7.1") + + implementation("net.kyori:adventure-api:4.25.0") + implementation("net.kyori:adventure-text-minimessage:4.25.0") + implementation("net.hareworks:kommand-lib") + implementation("net.hareworks:permits-lib") + + implementation("org.postgresql:postgresql:42.7.8") + implementation("org.jetbrains.exposed:exposed-core:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-dao:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-jdbc:$exposedVersion") + implementation("org.jetbrains.exposed:exposed-kotlin-datetime:$exposedVersion") + implementation("com.zaxxer:HikariCP:6.2.1") + +} +tasks { + withType { + archiveBaseName.set("hcu-core") + } + shadowJar { + archiveClassifier.set("min") + minimize { + exclude("net.hareworks:kommand-lib") + exclude("net.hareworks:permits-lib") + exclude(dependency("org.jetbrains.exposed:exposed-core")) + exclude(dependency("org.jetbrains.exposed:exposed-dao")) + exclude(dependency("org.jetbrains.exposed:exposed-jdbc")) + exclude(dependency("org.jetbrains.exposed:exposed-kotlin-datetime")) + exclude(dependency("org.postgresql:postgresql")) + } + } +} + +paper { + main = "net.hareworks.hcu.core.Main" + name = "hcu-core" + description = "libraries and implementations for Hare's civilized universe" + version = getVersion().toString() + apiVersion = "1.21.10" + authors = listOf( + "Hare-K02", + ) +} diff --git a/hcu-core/flake.lock b/hcu-core/flake.lock new file mode 100644 index 0000000..dd5f187 --- /dev/null +++ b/hcu-core/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1763948260, + "narHash": "sha256-dY9qLD0H0zOUgU3vWacPY6Qc421BeQAfm8kBuBtPVE0=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1c8ba8d3f7634acac4a2094eef7c32ad9106532c", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/hcu-core/flake.nix b/hcu-core/flake.nix new file mode 100644 index 0000000..8f1efe0 --- /dev/null +++ b/hcu-core/flake.nix @@ -0,0 +1,45 @@ +{ + description = "Minecraft dev environment with JDK 21 and Gradle"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.05"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + jdk21 + gradle + git + unzip + ]; + + shellHook = '' + export JAVA_HOME=${pkgs.jdk21}/lib/openjdk + export PATH="$JAVA_HOME/bin:$PATH" + + export GRADLE_USER_HOME="$PWD/.gradle" + + echo "Loaded Minecraft dev env (JDK 21 + Gradle)" + java -version || true + gradle --version || true + ''; + }; + } + ); +} diff --git a/hcu-core/gradle.properties b/hcu-core/gradle.properties new file mode 100644 index 0000000..ba9ecbe --- /dev/null +++ b/hcu-core/gradle.properties @@ -0,0 +1,7 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true +org.gradle.daemon=false + +org.gradle.configuration-cache=true + diff --git a/hcu-core/gradle/libs.versions.toml b/hcu-core/gradle/libs.versions.toml new file mode 100644 index 0000000..4ac3234 --- /dev/null +++ b/hcu-core/gradle/libs.versions.toml @@ -0,0 +1,2 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format diff --git a/hcu-core/gradle/wrapper/gradle-wrapper.jar b/hcu-core/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/hcu-core/gradle/wrapper/gradle-wrapper.jar differ diff --git a/hcu-core/gradle/wrapper/gradle-wrapper.properties b/hcu-core/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/hcu-core/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/hcu-core/gradlew b/hcu-core/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/hcu-core/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/hcu-core/gradlew.bat b/hcu-core/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/hcu-core/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hcu-core/kommand-lib/.envrc b/hcu-core/kommand-lib/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/hcu-core/kommand-lib/.envrc @@ -0,0 +1 @@ +use flake diff --git a/hcu-core/kommand-lib/.gitattributes b/hcu-core/kommand-lib/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/hcu-core/kommand-lib/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/hcu-core/kommand-lib/.gitignore b/hcu-core/kommand-lib/.gitignore new file mode 100644 index 0000000..baa960e --- /dev/null +++ b/hcu-core/kommand-lib/.gitignore @@ -0,0 +1,5 @@ +.direnv +.kotlin +.gradle +build +bin diff --git a/hcu-core/kommand-lib/.gitmodules b/hcu-core/kommand-lib/.gitmodules new file mode 100644 index 0000000..de450b6 --- /dev/null +++ b/hcu-core/kommand-lib/.gitmodules @@ -0,0 +1,4 @@ +[submodule "permits-lib"] + path = permits-lib + url = git@gitea.hareworks.net:Hare/permits-lib.git + branch = master diff --git a/hcu-core/kommand-lib/MIGRATION_GUIDE.md b/hcu-core/kommand-lib/MIGRATION_GUIDE.md new file mode 100644 index 0000000..464a376 --- /dev/null +++ b/hcu-core/kommand-lib/MIGRATION_GUIDE.md @@ -0,0 +1,118 @@ +# kommand-lib マイグレーションガイド + +旧バージョンから最新の Brigadier ネイティブ対応バージョンへの移行方法。 + +--- + +## 変更の概要 + +### 主な変更点 + +1. **`coordinates()` の型変更** (破壊的変更) + - `Coordinates3` → `io.papermc.paper.math.Position` + - `coords.resolve(base)` → `position.toLocation(world)` + +2. **内部処理の改善** + - Player/Entity セレクターの安定性向上 + - Bukkit CommandMap → Brigadier Lifecycle API + +--- + +## マイグレーション手順 + +### 1. 依存関係の確認 + +Paper API 1.21 以降が必要です。 + +```kotlin +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") +} +``` + +### 2. coordinates の修正 + +#### Before +```kotlin +coordinates("point") { + executes { + val coords = argument("point") + val location = coords.resolve(player.location) + } +} +``` + +#### After +```kotlin +import io.papermc.paper.math.Position + +coordinates("point") { + executes { + val position = argument("point") + val location = position.toLocation(player.world) + } +} +``` + +### 3. ビルド確認 + +```bash +./gradlew build +``` + +--- + +## Position API + +```kotlin +val position = argument("pos") + +// 座標取得 +val x = position.x() +val y = position.y() +val z = position.z() + +// Location 変換 +val location = position.toLocation(world) +``` + +--- + +## トラブルシューティング + +### `Coordinates3` が見つからない + +`Coordinates3` は存在しません。`Position` を使用してください。 + +```kotlin +// ❌ 間違い +argument("pos") + +// ✅ 正しい +argument("pos") +``` + +### `resolve()` メソッドが見つからない + +`Position` には `resolve()` はありません。`toLocation(world)` を使用してください。 + +```kotlin +// ❌ 間違い +position.resolve(baseLocation) + +// ✅ 正しい +position.toLocation(world) +``` + +--- + +## FAQ + +**Q: 相対座標 (`~`) は使えますか?** +A: はい、`Position` は相対座標を完全にサポートしています。 + +**Q: 旧バージョンとの互換性は?** +A: `coordinates()` の型が変更されているため互換性はありません。他の引数(`player()`, `players()` など)は互換性があります。 + +**Q: 段階的な移行は可能?** +A: `coordinates()` を使用している場合は一度にすべて移行する必要があります。 diff --git a/hcu-core/kommand-lib/README.md b/hcu-core/kommand-lib/README.md new file mode 100644 index 0000000..437a334 --- /dev/null +++ b/hcu-core/kommand-lib/README.md @@ -0,0 +1,201 @@ +# Kommand Lib + +Paper/Bukkit サーバー向けのコマンド定義を DSL で記述するためのライブラリです。ルート定義から引数の型、補完、パーミッション伝播までを宣言的に表現でき、手続き的な `CommandExecutor` 実装を大きく簡略化します。 + +## 特徴 + +- Kotlin DSL で `command { literal { argument { ... } } }` のようにネストを表現 +- 型付き引数 (`string`, `integer`, `float`, `player`, `selector`, `coordinates` など) と検証ロジックを組み込み +- 1 つの定義から実行とタブ補完の両方を生成 +- パーミッションや条件をノード単位で宣言し、子ノードへ自動伝播 +- `suggests {}` で引数ごとの補完候補を柔軟に制御 +- Brigadier (Paper 1.21 Lifecycle API) 対応により、クライアント側で ` ` のような構文ヒントや、数値範囲の検証エラー(赤文字)が表示されます +- `permits-lib` との連携により、コマンドツリーから Bukkit パーミッションを自動生成し、`compileOnly` 依存として参照可能 + +## バージョン情報 + +**現在のバージョン**: 1.1 (Brigadier ネイティブ対応) + +### 🔄 旧バージョンからの移行 + +旧バージョン (Brigadier 対応前) から移行する場合は、[マイグレーションガイド](./MIGRATION_GUIDE.md) を参照してください。 + +**主な変更点**: +- `coordinates()` の返り値が `Coordinates3` から `io.papermc.paper.math.Position` に変更 +- `position.toLocation(world)` で `Location` に変換する方式に変更 +- Player/Entity セレクターの内部処理が改善され、より安定した動作を実現 + +## 依存関係 + +`build.gradle.kts` では Paper API と Kotlin 標準ライブラリのみを `compileOnly` に追加しています。Paper 1.21.10 対応の API を利用しています。 + +```kotlin +plugins { + kotlin("jvm") version "2.2.21" + id("de.eldoria.plugin-yml.paper") version "0.8.0" + id("com.gradleup.shadow") version "9.2.2" +} + +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") +} +``` + +## 使い方 + +1. プラグインの `onEnable` などで `kommand(plugin) { ... }` DSL を呼び出します。 +2. `command("root", "alias") { ... }` でコマンドを宣言し、`literal` や `string`/`integer` 引数を追加します。 +3. `executes { ... }` は必ず対象のノード (`literal`, `player`, `integer` など) の中にネストします。これにより、そのノードまでに宣言した引数を `argument("player")` や `argument("amount")` のように取得できます。 + +### サンプル: 経済コマンド + +```kotlin +class EconomyPlugin : JavaPlugin() { + private lateinit var commands: KommandLib + + override fun onEnable() { + commands = kommand(this) { + command("eco", "economy") { + description = "Economy management" + permission = "example.eco" + + literal("give") { + player("target") { // プレイヤー名 or セレクター (@p 等) + integer("amount", min = 1) { + executes { + val target: Player = argument("target") + val amount = argument("amount") + sender.sendMessage("Giving $amount to ${target.name}") + } + } + } + } + + literal("speed") { + players("targets") { // @a, プレイヤー名, などをまとめて取得 + float("value", min = 0.1, max = 5.0) { + executes { + val targets: List = argument("targets") + val speed = argument("value") + targets.forEach { it.walkSpeed = speed.toFloat() / 5.0f } + sender.sendMessage("Updated ${targets.size} players") + } + } + } + } + + literal("setspawn") { + coordinates("point") { // "~ ~1 ~-2" のような入力を受け付ける + executes { + val player = sender as? Player ?: return@executes + val position = argument("point") + val location = position.toLocation(player.world) + player.world.setSpawnLocation(location) + sender.sendMessage("Spawn set to ${location.x}, ${location.y}, ${location.z}") + } + } + } + + literal("inspect") { + selector("entities") { + executes { + val entities: List = argument("entities") + sender.sendMessage("Selector resolved ${entities.size} entities") + } + } + } + } + } + } + + override fun onDisable() { + commands.unregister() + } +} +``` + +### DSL 構文のポイント + +- `literal("sub") { ... }` は固定語句を表すノードです。`requires("permission.node")` でその枝のみにパーミッションを設定できます。 +- `string("name")` や `integer("value", min = 0)` は値をパースし、成功すると `KommandContext` に記憶されます。取得時は `argument("name")` や `argument("value")` を呼び出してください。 +- `float("speed")` や `player("target")`/`players("targets")`/`selector("entities")` は Minecraft の標準セレクター (`@p`, `@a`, `@s` など) やプレイヤー名を型付きで扱えます。実行時は `argument("speed")`、`argument("target")`、`argument>("targets")` のように取得できます。 +- `suggests { prefix -> ... }` を指定すると、タブ補完時に任意の候補リストを返せます。 +- `coordinates("pos")` は `x y z` をまとめて 1 つの引数として受け取り、`argument("pos")` で取得できます。`position.toLocation(world)` で `Location` に変換できます (`~` を使用した相対座標に対応)。 +- `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" + } + + literal("give") { + permission { + description = "Allows /eco give" + } + } + } +} +``` + +- `permissions { ... }` で名前空間やルートセグメント (`rootSegment = "command"`) を定義すると、`hareworks.command.eco`, `hareworks.command.eco.give` のような ID が自動生成され、`permits-lib` の `MutationSession` を使って Bukkit に適用されます。 +- `literal` や `command` ノードはデフォルトで Bukkit パーミッションとして登録されます。`string` や `integer` などの引数ノードは、`requires("permission.id")` もしくは `permission { id = ... }` を記述した場合のみ登録され、それ以外は構造上のノードに留まります。これにより `/money give ` では `...money.give` だけ付与すれば実行できます。 +- 各ブロック内の `permission { ... }` で説明文・デフォルト値 (`PermissionDefault.TRUE/FALSE/OP/NOT_OP`)・ワイルドカード・パスを細かく制御できます。`requires(...)` を使うと DSL での検証と permits 側の登録が同じ ID になります。 +- 引数ノードを明示的に登録したい場合は `permission { id = "example.money.give.amount" }` や `requires("example.money.give.amount")` を設定してください。逆にリテラルでも不要なら `skipPermission()` で除外できます。 +- `requires("custom.id")` を指定した場合も、同じ ID が DSL の実行と `permits-lib` への登録の両方で利用されます (名前空間外の ID は登録対象外になります)。 +- `KommandLib` のライフサイクルに合わせて `MutationSession` が適用/解除されるため、プラグインの有効化・無効化に伴い Bukkit のパーミッションリストも最新状態に保たれます。 + +## 組み込み引数の一覧 + +| DSL | 返り値 | 補足 | +| --- | --- | --- | +| `string("name")` | `String` | 任意のトークン。`suggests {}` で補完可 | +| `integer("value", min, max)` | `Int` | 範囲チェック付き | +| `float("speed", min, max)` | `Double` | 小数/指数表記に対応 | +| `player("target", allowSelectors = true)` | `Player` | `@p` などのセレクターまたはプレイヤー名を 1 人に解決 | +| `players("targets")` | `List` | `@a`/`@r` など複数指定、プレイヤー名入力も可 | +| `selector("entities")` | `List` | エンティティセレクター (`@e` など) | +| `coordinates("pos")` | `io.papermc.paper.math.Position` | `~` 相対座標を含む 3 軸をまとめて扱う | + +`Position` は `coordinates("pos") { ... }` 直後のコンテキストで `argument("pos")` として取得でき、`position.toLocation(world)` で `Location` に変換できます。 + +## クライアント側構文ヒント (Brigadier) + +Paper 1.21 以降の環境では、`LifecycleEventManager` を通じてコマンドが登録されるため、クライアントにコマンドの構造が送信されます。これにより以下のメリットがあります: + +- **構文の可視化**: 入力中に ` ` のような引数名が表示されます。 +- **クライアント側検証**: `integer("val", min=1, max=10)` などの範囲指定がクライアント側でも判定され、範囲外の値を入力すると赤字になります。 +- **互換性**: 内部的には `Brigadier` のノードに変換されますが、実際のコマンド実行は `kommand-lib` の既存ロジック(`KommandContext`)を使用するため、古いコードの修正は不要です。 + +## ビルドとテスト + +```bash +./gradlew build +``` + +ShadowJar タスクが実行され、`build/libs` に出力されます。Paper サーバーに配置して動作確認してください。 + +## ドキュメント + +- **[MIGRATION_GUIDE](./MIGRATION_GUIDE.md)** - 旧バージョンからの移行方法 + +## ライセンス + +このプロジェクトは MIT ライセンスの下で公開されています。 diff --git a/hcu-core/kommand-lib/build.gradle.kts b/hcu-core/kommand-lib/build.gradle.kts new file mode 100644 index 0000000..6e0de7f --- /dev/null +++ b/hcu-core/kommand-lib/build.gradle.kts @@ -0,0 +1,45 @@ +import net.minecrell.pluginyml.paper.PaperPluginDescription + +group = "net.hareworks" +version = "1.1" + +plugins { + kotlin("jvm") version "2.2.21" + id("de.eldoria.plugin-yml.paper") version "0.8.0" + id("com.gradleup.shadow") version "9.2.2" +} +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") + implementation("org.jetbrains.kotlin:kotlin-stdlib") + implementation("net.hareworks:permits-lib:1.1") +} +tasks { + withType { + archiveBaseName.set("Kommand-Lib") + } + shadowJar { + minimize() + archiveClassifier.set("min") + } +} + +paper { + main = "net.hareworks.kommand_lib.plugin.Plugin" + name = "kommand-lib" + description = "Command library" + version = getVersion().toString() + apiVersion = "1.21.10" + authors = listOf( + "Hare-K02" + ) + serverDependencies { + register("permits-lib") { + load = PaperPluginDescription.RelativeLoadOrder.BEFORE + } + } +} diff --git a/hcu-core/kommand-lib/flake.lock b/hcu-core/kommand-lib/flake.lock new file mode 100644 index 0000000..742cdd6 --- /dev/null +++ b/hcu-core/kommand-lib/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764020296, + "narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/hcu-core/kommand-lib/flake.nix b/hcu-core/kommand-lib/flake.nix new file mode 100644 index 0000000..0103621 --- /dev/null +++ b/hcu-core/kommand-lib/flake.nix @@ -0,0 +1,43 @@ +{ + description = "Minecraft dev environment with JDK 21 and Gradle"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + jdk21 + gradle + kotlin + git + unzip + ]; + + # 必要に応じて環境変数を設定 + shellHook = '' + export JAVA_HOME=${pkgs.jdk21}/lib/openjdk + export PATH="$JAVA_HOME/bin:$PATH" + + export GRADLE_USER_HOME="$PWD/.gradle" + ''; + }; + } + ); +} diff --git a/hcu-core/kommand-lib/gradle.properties b/hcu-core/kommand-lib/gradle.properties new file mode 100644 index 0000000..d38384f --- /dev/null +++ b/hcu-core/kommand-lib/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + +kotlin.stdlib.default.dependency=false diff --git a/hcu-core/kommand-lib/gradle/libs.versions.toml b/hcu-core/kommand-lib/gradle/libs.versions.toml new file mode 100644 index 0000000..4ac3234 --- /dev/null +++ b/hcu-core/kommand-lib/gradle/libs.versions.toml @@ -0,0 +1,2 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format diff --git a/hcu-core/kommand-lib/gradle/wrapper/gradle-wrapper.jar b/hcu-core/kommand-lib/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/hcu-core/kommand-lib/gradle/wrapper/gradle-wrapper.jar differ diff --git a/hcu-core/kommand-lib/gradle/wrapper/gradle-wrapper.properties b/hcu-core/kommand-lib/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/hcu-core/kommand-lib/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/hcu-core/kommand-lib/gradlew b/hcu-core/kommand-lib/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/hcu-core/kommand-lib/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/hcu-core/kommand-lib/gradlew.bat b/hcu-core/kommand-lib/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/hcu-core/kommand-lib/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hcu-core/kommand-lib/permits-lib/.envrc b/hcu-core/kommand-lib/permits-lib/.envrc new file mode 100644 index 0000000..3550a30 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/.envrc @@ -0,0 +1 @@ +use flake diff --git a/hcu-core/kommand-lib/permits-lib/.gitattributes b/hcu-core/kommand-lib/permits-lib/.gitattributes new file mode 100644 index 0000000..f91f646 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/.gitattributes @@ -0,0 +1,12 @@ +# +# https://help.github.com/articles/dealing-with-line-endings/ +# +# Linux start script should use lf +/gradlew text eol=lf + +# These are Windows script files and should use crlf +*.bat text eol=crlf + +# Binary files should be left untouched +*.jar binary + diff --git a/hcu-core/kommand-lib/permits-lib/.gitignore b/hcu-core/kommand-lib/permits-lib/.gitignore new file mode 100644 index 0000000..f915454 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/.gitignore @@ -0,0 +1,4 @@ +.direnv +.gradle +bin +build diff --git a/hcu-core/kommand-lib/permits-lib/AGENT.md b/hcu-core/kommand-lib/permits-lib/AGENT.md new file mode 100644 index 0000000..0b8f305 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/AGENT.md @@ -0,0 +1,136 @@ +# Paper / Bukkit Permission システム解説ドキュメント + +## 1. 基本概念 + +### Permission(権限ノード) +- プレイヤーが「何を実行できるか」を判定するための文字列。 +- 例: `myplugin.fly`, `myplugin.home.set` +- 権限ノードには以下の情報を持てる。 + - 説明文(description) + - デフォルト値(default: OP / non-OP / true / false) + - 子権限(children) + +Paper/Bukkit は `plugin.yml` またはコードによる動的生成で権限を登録できる。 + +--- + +## 2. 権限の定義方法 + +### (1) `plugin.yml` で定義 + +```yaml +permissions: + myplugin.fly: + description: "Allow /fly" + default: op +```` + +### (2) コードで動的に定義 + +```java +Permission perm = new Permission("myplugin.fly", "Allow fly", PermissionDefault.OP); +getServer().getPluginManager().addPermission(perm); +``` + +--- + +## 3. 動的付与(PermissionAttachment) + +### PermissionAttachment の役割 + +* 特定プレイヤーに対して一時的に権限を上書きする仕組み。 +* **未定義の権限ノードでも自由に付与できる**。 + +```java +PermissionAttachment attachment = player.addAttachment(plugin); +attachment.setPermission("myplugin.fly", true); +``` + +### 特徴 + +* 未定義ノードでも `player.hasPermission("xxx")` が true になる。 +* default や children などのメタ情報は存在しない。 +* サーバー再起動やログアウトで消える一時的な扱い。 + +--- + +## 4. Children(子権限) + +### 概要 + +* 「ある権限を true にした時、自動的に他の権限も true にする仕組み」。 + +```yaml +permissions: + myplugin.admin: + children: + myplugin.fly: true + myplugin.kick: true +``` + +### 挙動 + +* `myplugin.admin` を持つプレイヤーは + → `myplugin.fly` と `myplugin.kick` も自動的に所持。 +* 再帰的に適用され、深い階層の権限にも影響する。 +* false を設定した場合は「明示的に無効」にできる。 + +### 動的 Permission に対しても有効 + +```java +perm.getChildren().put("myplugin.fly", true); +perm.recalculatePermissibles(); +``` + +--- + +## 5. Permission 判定の仕組み + +`player.hasPermission("xxx")` の評価順序: + +1. **PermissionAttachment のフラグ** +2. 登録済 Permission の children +3. Permission の default 値(OP / non-OP など) +4. 上記に該当しなければ false + +attachment が最優先で反映される。 + +--- + +## 6. 未定義権限ノードの扱い + +### 特徴 + +* 未定義でも `attachment.setPermission("xxx", true)` で有効化可能。 +* ただし: + + * children なし + * default なし + * description なし + * 権限管理プラグインで見づらい + +### 必要に応じて動的登録を使う + +体系的な管理が必要なら PluginManager で正式な Permission を登録することを推奨。 + +--- + +## 7. 推奨される運用方法 + +### (A) 権限の種類を柔軟に変更したい + +→ **動的 Permission 定義** + +* config.yml → 起動/リロード時に生成 + +### (B) プレイヤーごとの一時的な権限管理 + +→ **PermissionAttachment** + +* 一時バフ的な権限付与に向く + +### (C) 大規模な権限構造 + +→ **children の階層化** + +* `myplugin.admin` → 配下の権限をまとめて管理できる diff --git a/hcu-core/kommand-lib/permits-lib/README.md b/hcu-core/kommand-lib/permits-lib/README.md new file mode 100644 index 0000000..cf12d0e --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/README.md @@ -0,0 +1,150 @@ +# permits-lib + +Permits Lib provides a declarative way to describe Bukkit/Paper permission hierarchies using a Kotlin +DSL. Once a tree is built you can hand it to `PermissionRegistry` (or the higher-level +`MutationSession`) and the library will register/unregister the relevant `org.bukkit.permissions.Permission` +instances and keep `PermissionAttachment`s in sync. + +## Usage + +```kotlin +import net.hareworks.permits_lib.domain.NodeRegistration + +class ExamplePlugin : JavaPlugin() { + private val permits = PermitsLib.session(this) + + override fun onEnable() { + val tree = permissionTree("example") { + node("command", NodeRegistration.STRUCTURAL) { + description = "Access to all example commands" + defaultValue = PermissionDefault.OP + wildcard { + exclude("cooldown") // example.command.* will skip cooldown + } + + node("reload", NodeRegistration.PERMISSION) { + description = "Allows /example reload (permission example.command.reload)" + } + + // Link to a helper node defined elsewhere under the command branch: + child("helper") + + // Link to a permission outside the current branch (must be fully-qualified): + childAbsolute("example.tools.repair") + + node("cooldown", NodeRegistration.PERMISSION) { + description = "Allows /example cooldown tweaks" + } + } + + node("command.helper", NodeRegistration.PERMISSION) { + description = "Allows /example helper (referenced via child(\"helper\"))" + } + + node("tools.repair", NodeRegistration.PERMISSION) { + description = "Allows /example tools repair (linked with childAbsolute(\"example.tools.repair\"))" + } + } + + permits.applyTree(tree) + + // The tree above materializes as permissions such as: + // example.command, example.command.reload, example.command.helper, example.command.cooldown, + // example.tools.repair, + // plus the auto-generated example.command.* wildcard (command opted in, cooldown was excluded). + // export to plugin.yml or inspect Bukkit's /permissions output). + + configureRuntimePermissions() + } + + fun grantHelper(player: Player) { + permits.attachments.grant(player, PermissionId.of("example.command.reload")) + } + + private fun configureRuntimePermissions() { + // Later in runtime you can mutate the previously applied structure without rebuilding it: + permits.edit("example") { + // Update an existing node and link it to new children + node("command", NodeRegistration.STRUCTURAL) { + description = "Admins for every command path" + wildcard = true + node("debug", NodeRegistration.PERMISSION) { + description = "Allows /example debug" + defaultValue = PermissionDefault.OP + wildcard = true + } + } + // Remove deprecated permissions entirely + removeNode("command.cooldown") + } + } +} +``` + +### Procedural Edits with `MutablePermissionTree` + +If you prefer an imperative style before handing the structure to Bukkit, you can clone any existing tree, +mutate it procedurally, and then apply the result: + +```kotlin +val baseTree = permissionTree("example") { + node("command", NodeRegistration.STRUCTURAL) { + wildcard = true + node("reload", NodeRegistration.PERMISSION) + } +} + +val mutable = MutablePermissionTree.from(baseTree) +mutable.node("command", NodeRegistration.STRUCTURAL) { + wildcard = true + excludeWildcardChild("helper") // keep helper out of command.* + node("debug", NodeRegistration.PERMISSION) { + description = "Allows /example debug" + defaultValue = PermissionDefault.OP + wildcard = true + } +} +mutable.removeNode("command.legacy") + +permits.applyTree(mutable.build()) +``` + +The mutable API mirrors the DSL (`node`, `child`, `childAbsolute`, `removeNode`, `renameNode`, etc.) so you can +stage edits procedurally before ever touching `MutationSession`. + +### Concepts + +- **Permission tree** – immutable graph of `PermissionNode`s. Nodes specify description, default value, + boolean children map, and the `wildcard` flag (disabled by default) that, when enabled per node, keeps + `namespace.path.*` aggregate permissions in sync automatically. +- **DSL** – `permissionTree("namespace") { ... }` ensures consistent prefixes and validation (no cycles). Every `node("command", NodeRegistration.PERMISSION)` (or `.STRUCTURAL`) is relative to that namespace, so you never include the namespace manually at the root. +- **Nested nodes** – `node("command", NodeRegistration.STRUCTURAL) { node("reload", NodeRegistration.PERMISSION) { ... } }` automatically produces + `namespace.command` and `namespace.command.reload` plus wires the parent/child relationship so you don't + have to repeat the full id. +- **Flexible references** – `child("reload")`, `node("command", NodeRegistration.STRUCTURAL) { node("reload", NodeRegistration.PERMISSION) { ... } }`, or + even `node("command.reload", NodeRegistration.PERMISSION)` inside `edit` all resolve to the same node; children are auto-created on + first reference but you can demand explicit nodes by adding a `node` block later, and you can unlink + specific children via `node("command", NodeRegistration.STRUCTURAL) { removeNode("cooldown") }` and the entire subtree disappears. +- **Node registration** – `NodeRegistration.PERMISSION` materializes the node as a Bukkit permission, while `NodeRegistration.STRUCTURAL` keeps it purely for grouping (still participates in wildcard aggregation) so you can avoid ambiguous intermediate permissions like `hoge.command`. + Nested `child(...)` calls are relative to the current node by default, while `childAbsolute(...)` now + expects a fully-qualified permission ID (e.g., `example.tools.repair`) so you can also point at nodes in + other namespaces. +- **PermissionRegistry** – calculates a diff between snapshots and performs the minimum additions, + removals, or updates via Bukkit's `PluginManager`. +- **Wildcards** – disabled by default; opt in via `wildcard = true` or the richer `wildcard { ... }` block. + The block automatically enables the wildcard and lets you `exclude("sub.path")` so only selected DSL + children end up under `namespace.command.*`. Enabled nodes automatically add their wildcard descendants + (e.g., `example.command.debug.*`) so granting the wildcard cascades to the remaining children. + +### Selective wildcards + +- **DSL** – call `wildcard { exclude("cooldown") }` to enable the `*. *` permission while skipping specific + literal/argument branches. You can chain `exclude` calls and pass multi-segment paths (`exclude("debug.logs")`). +- **Mutable tree** – after `wildcard = true`, invoke `excludeWildcardChild("helper")` (relative) or + `excludeWildcardChildAbsolute("example.command.helper.extras")` to trim wildcard membership imperatively. +- **Mutable edits** – `permits.edit { ... }` clones the currently registered tree, lets you mutate nodes + imperatively, re-validates, and only pushes the structural diff to Bukkit. +- **AttachmentSynchronizer** – manages identity-based `PermissionAttachment`s and exposes high-level + helpers (`grant`, `revoke`, `applyPatch`). +- **MutationSession** – ties everything together for plugins that just want to push new trees and manage + attachments without worrying about the lower-level services. diff --git a/hcu-core/kommand-lib/permits-lib/build.gradle.kts b/hcu-core/kommand-lib/permits-lib/build.gradle.kts new file mode 100644 index 0000000..32a0545 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/build.gradle.kts @@ -0,0 +1,39 @@ +import net.minecrell.pluginyml.paper.PaperPluginDescription + +group = "net.hareworks" +version = "1.1" + +plugins { + kotlin("jvm") version "2.2.21" + id("de.eldoria.plugin-yml.paper") version "0.8.0" + id("com.gradleup.shadow") version "9.2.2" +} +repositories { + mavenCentral() + maven("https://repo.papermc.io/repository/maven-public/") +} + +dependencies { + compileOnly("io.papermc.paper:paper-api:1.21.10-R0.1-SNAPSHOT") + compileOnly("org.jetbrains.kotlin:kotlin-stdlib") +} +tasks { + withType { + archiveBaseName.set("Permits-Lib") + } + shadowJar { + minimize() + archiveClassifier.set("min") + } +} + +paper { + main = "net.hareworks.permits_lib.plugin.Plugin" + name = "permits-lib" + description = "Permission Library" + version = getVersion().toString() + apiVersion = "1.21.10" + authors = listOf( + "Hare-K02" + ) +} diff --git a/hcu-core/kommand-lib/permits-lib/flake.lock b/hcu-core/kommand-lib/permits-lib/flake.lock new file mode 100644 index 0000000..742cdd6 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/flake.lock @@ -0,0 +1,61 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1764020296, + "narHash": "sha256-6zddwDs2n+n01l+1TG6PlyokDdXzu/oBmEejcH5L5+A=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "a320ce8e6e2cc6b4397eef214d202a50a4583829", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/hcu-core/kommand-lib/permits-lib/flake.nix b/hcu-core/kommand-lib/permits-lib/flake.nix new file mode 100644 index 0000000..0103621 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/flake.nix @@ -0,0 +1,43 @@ +{ + description = "Minecraft dev environment with JDK 21 and Gradle"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = + { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem ( + system: + let + pkgs = import nixpkgs { + inherit system; + }; + in + { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + jdk21 + gradle + kotlin + git + unzip + ]; + + # 必要に応じて環境変数を設定 + shellHook = '' + export JAVA_HOME=${pkgs.jdk21}/lib/openjdk + export PATH="$JAVA_HOME/bin:$PATH" + + export GRADLE_USER_HOME="$PWD/.gradle" + ''; + }; + } + ); +} diff --git a/hcu-core/kommand-lib/permits-lib/gradle.properties b/hcu-core/kommand-lib/permits-lib/gradle.properties new file mode 100644 index 0000000..5154008 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/gradle.properties @@ -0,0 +1,7 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/build_environment.html#sec:gradle_configuration_properties + +org.gradle.configuration-cache=true +org.gradle.parallel=true +org.gradle.caching=true + diff --git a/hcu-core/kommand-lib/permits-lib/gradle/libs.versions.toml b/hcu-core/kommand-lib/permits-lib/gradle/libs.versions.toml new file mode 100644 index 0000000..4ac3234 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/gradle/libs.versions.toml @@ -0,0 +1,2 @@ +# This file was generated by the Gradle 'init' task. +# https://docs.gradle.org/current/userguide/platforms.html#sub::toml-dependencies-format diff --git a/hcu-core/kommand-lib/permits-lib/gradle/wrapper/gradle-wrapper.jar b/hcu-core/kommand-lib/permits-lib/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..1b33c55 Binary files /dev/null and b/hcu-core/kommand-lib/permits-lib/gradle/wrapper/gradle-wrapper.jar differ diff --git a/hcu-core/kommand-lib/permits-lib/gradle/wrapper/gradle-wrapper.properties b/hcu-core/kommand-lib/permits-lib/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..d4081da --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/hcu-core/kommand-lib/permits-lib/gradlew b/hcu-core/kommand-lib/permits-lib/gradlew new file mode 100755 index 0000000..23d15a9 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH="\\\"\\\"" + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/hcu-core/kommand-lib/permits-lib/gradlew.bat b/hcu-core/kommand-lib/permits-lib/gradlew.bat new file mode 100644 index 0000000..db3a6ac --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH= + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/hcu-core/kommand-lib/permits-lib/settings.gradle.kts b/hcu-core/kommand-lib/permits-lib/settings.gradle.kts new file mode 100644 index 0000000..2d0ac8e --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/settings.gradle.kts @@ -0,0 +1 @@ +rootProject.name = "permits-lib" diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/PermitsLib.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/PermitsLib.kt new file mode 100644 index 0000000..3929885 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/PermitsLib.kt @@ -0,0 +1,8 @@ +package net.hareworks.permits_lib + +import net.hareworks.permits_lib.bukkit.MutationSession +import org.bukkit.plugin.java.JavaPlugin + +object PermitsLib { + fun session(plugin: JavaPlugin): MutationSession = MutationSession.create(plugin) +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/Plugin.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/Plugin.kt new file mode 100644 index 0000000..413bca3 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/Plugin.kt @@ -0,0 +1,6 @@ +package net.hareworks.permits_lib.plugin + +import org.bukkit.plugin.java.JavaPlugin + +@Suppress("unused") +class Plugin : JavaPlugin() {} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentPatch.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentPatch.kt new file mode 100644 index 0000000..9f9048a --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentPatch.kt @@ -0,0 +1,15 @@ +package net.hareworks.permits_lib.bukkit + +import net.hareworks.permits_lib.domain.PermissionId + +/** + * Describes a set of attachment changes to be applied to a [Permissible]. + * `true`/`false` represent forced grant/deny, while `null` removes the override. + */ +data class AttachmentPatch( + val changes: Map +) { + companion object { + val EMPTY = AttachmentPatch(emptyMap()) + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentSynchronizer.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentSynchronizer.kt new file mode 100644 index 0000000..16954ef --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/AttachmentSynchronizer.kt @@ -0,0 +1,69 @@ +package net.hareworks.permits_lib.bukkit + +import java.util.IdentityHashMap +import net.hareworks.permits_lib.domain.PermissionId +import net.hareworks.permits_lib.util.ThreadChecks +import org.bukkit.permissions.PermissionAttachment +import org.bukkit.permissions.Permissible +import org.bukkit.plugin.java.JavaPlugin + +/** + * Manages [PermissionAttachment] instances per [Permissible], applying patches and cleaning up once no + * overrides remain. + */ +class AttachmentSynchronizer( + private val plugin: JavaPlugin +) { + private data class AttachmentHandle( + val attachment: PermissionAttachment, + val overrides: MutableMap = linkedMapOf() + ) + + private val handles = IdentityHashMap() + + fun applyPatch(permissible: Permissible, patch: AttachmentPatch) { + ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.applyPatch") + if (patch.changes.isEmpty()) return + val handle = ensureHandle(permissible) + patch.changes.forEach { (id, value) -> + if (value == null) { + handle.overrides.remove(id) + handle.attachment.unsetPermission(id.value) + } else { + handle.overrides[id] = value + handle.attachment.setPermission(id.value, value) + } + } + if (handle.overrides.isEmpty()) { + release(permissible) + } + } + + fun grant(permissible: Permissible, permission: PermissionId, value: Boolean = true) { + applyPatch(permissible, AttachmentPatch(mapOf(permission to value))) + } + + fun revoke(permissible: Permissible, permission: PermissionId) { + applyPatch(permissible, AttachmentPatch(mapOf(permission to null))) + } + + fun clear(permissible: Permissible) { + ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.clear") + handles.remove(permissible)?.attachment?.remove() + } + + fun clearAll() { + ThreadChecks.ensurePrimaryThread("AttachmentSynchronizer.clearAll") + handles.values.forEach { it.attachment.remove() } + handles.clear() + } + + private fun ensureHandle(permissible: Permissible): AttachmentHandle = + handles[permissible] ?: AttachmentHandle(permissible.addAttachment(plugin)).also { + handles[permissible] = it + } + + private fun release(permissible: Permissible) { + handles.remove(permissible)?.attachment?.remove() + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/MutationSession.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/MutationSession.kt new file mode 100644 index 0000000..ffb21ef --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/MutationSession.kt @@ -0,0 +1,73 @@ +package net.hareworks.permits_lib.bukkit + +import net.hareworks.permits_lib.domain.MutablePermissionTree +import net.hareworks.permits_lib.domain.PermissionTree +import net.hareworks.permits_lib.domain.TreeDiff + +/** + * High-level façade that ties the registry and attachment synchronizer together. + */ +class MutationSession( + private val registry: PermissionRegistry, + val attachments: AttachmentSynchronizer +) { + private var tree: PermissionTree? = null + private var diff: TreeDiff? = null + + fun applyTree(next: PermissionTree): TreeDiff { + val computed = registry.applyTree(next) + tree = next + diff = computed + return computed + } + + /** + * Mutates the currently applied tree (must exist) and immediately applies the resulting diff to the + * Bukkit registry. + */ + fun edit(block: MutablePermissionTree.() -> Unit): TreeDiff { + val base = tree ?: error("No permission tree applied yet. Call applyTree or edit(namespace) first.") + return editInternal(MutablePermissionTree.from(base), block) + } + + /** + * Mutates the existing tree or creates a fresh one for the provided [namespace] when none was applied + * before. + */ + fun edit(namespace: String, block: MutablePermissionTree.() -> Unit): TreeDiff { + val mutable = tree?.let { + require(it.namespace == namespace) { + "Existing tree namespace '${it.namespace}' differs from requested '$namespace'." + } + MutablePermissionTree.from(it) + } ?: MutablePermissionTree.create(namespace) + return editInternal(mutable, block) + } + + private fun editInternal( + mutable: MutablePermissionTree, + block: MutablePermissionTree.() -> Unit + ): TreeDiff { + mutable.block() + val next = mutable.build() + return applyTree(next) + } + + fun clearAll() { + registry.clear() + attachments.clearAll() + tree = null + diff = null + } + + fun currentTree(): PermissionTree? = tree + fun lastDiff(): TreeDiff? = diff + + companion object { + fun create(plugin: org.bukkit.plugin.java.JavaPlugin): MutationSession = + MutationSession( + registry = PermissionRegistry(plugin), + attachments = AttachmentSynchronizer(plugin) + ) + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/PermissionRegistry.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/PermissionRegistry.kt new file mode 100644 index 0000000..b6da936 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/bukkit/PermissionRegistry.kt @@ -0,0 +1,65 @@ +package net.hareworks.permits_lib.bukkit + +import net.hareworks.permits_lib.domain.PermissionNode +import net.hareworks.permits_lib.domain.PermissionTree +import net.hareworks.permits_lib.domain.TreeDiff +import net.hareworks.permits_lib.domain.TreeDiffer +import net.hareworks.permits_lib.domain.TreeSnapshot +import net.hareworks.permits_lib.util.ThreadChecks +import org.bukkit.permissions.Permission +import org.bukkit.plugin.PluginManager +import org.bukkit.plugin.java.JavaPlugin + +/** + * Registers permission nodes against Bukkit's [PluginManager], keeping track of previous state so that + * only diffs are applied back to the server. + */ +class PermissionRegistry( + private val plugin: JavaPlugin, + private val pluginManager: PluginManager = plugin.server.pluginManager +) { + private var snapshot: TreeSnapshot? = null + + fun applyTree(tree: PermissionTree): TreeDiff { + ThreadChecks.ensurePrimaryThread("PermissionRegistry.applyTree") + + val nextSnapshot = tree.toSnapshot() + val diff = TreeDiffer.diff(snapshot, nextSnapshot) + if (!diff.hasChanges) { + return diff + } + + diff.removed.forEach { removeNode(it) } + diff.added.forEach { registerNode(it) } + diff.updated.forEach { updateNode(it) } + + snapshot = nextSnapshot + return diff + } + + fun clear() { + ThreadChecks.ensurePrimaryThread("PermissionRegistry.clear") + snapshot?.nodes?.values?.forEach { removeNode(it) } + snapshot = null + } + + private fun removeNode(node: PermissionNode) { + pluginManager.getPermission(node.id.value)?.let { permission -> + pluginManager.removePermission(permission) + permission.recalculatePermissibles() + } + } + + private fun registerNode(node: PermissionNode) { + val permission = Permission(node.id.value, node.description, node.defaultValue) + permission.children.clear() + permission.children.putAll(node.children.mapKeys { it.key.value }) + pluginManager.addPermission(permission) + permission.recalculatePermissibles() + } + + private fun updateNode(updated: TreeDiff.UpdatedNode) { + removeNode(updated.before) + registerNode(updated.after) + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/MutablePermissionTree.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/MutablePermissionTree.kt new file mode 100644 index 0000000..4405ddd --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/MutablePermissionTree.kt @@ -0,0 +1,196 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +/** + * Imperative view over a permission tree that lets callers mutate nodes directly. Once the desired + * modifications are complete, call [build] to obtain an immutable [PermissionTree]. + */ +class MutablePermissionTree internal constructor( + private val namespace: String, + private val drafts: MutableMap +) { + fun node(id: String, registration: NodeRegistration, block: MutableNode.() -> Unit = {}): MutableNode { + require(id.isNotBlank()) { "Node id must not be blank." } + val permissionId = PermissionId.of("$namespace.${id.lowercase()}") + val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) } + draft.registration = registration + return MutableNode(permissionId, draft).apply(block) + } + + fun removeNode(id: String) { + require(id.isNotBlank()) { "Node id must not be blank." } + val permissionId = PermissionId.of("$namespace.${id.lowercase()}") + removeSubtree(permissionId) + } + + fun renameNode(oldId: String, newId: String) { + require(oldId.isNotBlank()) { "Old node id must not be blank." } + require(newId.isNotBlank()) { "New node id must not be blank." } + val oldPermissionId = PermissionId.of("$namespace.${oldId.lowercase()}") + val newPermissionId = PermissionId.of("$namespace.${newId.lowercase()}") + renameSubtree(oldPermissionId, newPermissionId) + } + + fun contains(id: String): Boolean { + require(id.isNotBlank()) { "Node id must not be blank." } + return drafts.containsKey(PermissionId.of("$namespace.${id.lowercase()}")) + } + + fun build(): PermissionTree { + val nodes = drafts.mapValues { it.value.toNode() } + return PermissionTree.from(namespace, nodes) + } + + inner class MutableNode internal constructor( + val id: PermissionId, + private val draft: PermissionNodeDraft + ) { + var description: String? + get() = draft.description + set(value) { + draft.description = value?.trim() + } + + var defaultValue: PermissionDefault + get() = draft.defaultValue + set(value) { + draft.defaultValue = value + } + + var wildcard: Boolean + get() = draft.wildcard + set(value) { + draft.wildcard = value + } + + var registration: NodeRegistration + get() = draft.registration + set(value) { + draft.registration = value + } + + fun child(id: String, value: Boolean = true) { + require(id.isNotBlank()) { "Child id must not be blank." } + val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}") + draft.children[permissionId] = value + } + + fun childAbsolute(id: String, value: Boolean = true) { + val permissionId = PermissionId.of(id.lowercase()) + draft.children[permissionId] = value + } + + fun node(id: String, registration: NodeRegistration, block: MutableNode.() -> Unit = {}) { + require(id.isNotBlank()) { "Node id must not be blank." } + val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}") + draft.children[permissionId] = true + val childDraft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) } + childDraft.registration = registration + MutableNode(permissionId, childDraft).apply(block) + } + + fun removeNode(id: String) { + require(id.isNotBlank()) { "Node id must not be blank." } + val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}") + removeSubtree(permissionId) + } + + fun renameNode(oldId: String, newId: String) { + require(oldId.isNotBlank()) { "Old node id must not be blank." } + require(newId.isNotBlank()) { "New node id must not be blank." } + val oldPermissionId = PermissionId.of("${this.id.value}.${oldId.lowercase()}") + val newPermissionId = PermissionId.of("${this.id.value}.${newId.lowercase()}") + renameSubtree(oldPermissionId, newPermissionId) + } + + fun excludeWildcardChild(id: String) { + require(id.isNotBlank()) { "Wildcard exclusion id must not be blank." } + val permissionId = PermissionId.of("${this.id.value}.${id.lowercase()}") + draft.wildcardExclusions.add(permissionId) + } + + fun excludeWildcardChildAbsolute(id: String) { + require(id.isNotBlank()) { "Wildcard exclusion id must not be blank." } + val permissionId = PermissionId.of(id.lowercase()) + draft.wildcardExclusions.add(permissionId) + } + } + + private fun removeSubtree(rootId: PermissionId) { + val prefix = "${rootId.value}." + val targets = drafts.keys.filter { key -> + key.value == rootId.value || key.value.startsWith(prefix) + }.toSet() + if (targets.isEmpty()) return + targets.forEach { drafts.remove(it) } + drafts.values.forEach { draft -> + val iterator = draft.children.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + if (entry.key in targets) { + iterator.remove() + } + } + } + } + + private fun renameSubtree(oldRoot: PermissionId, newRoot: PermissionId) { + if (oldRoot == newRoot) return + val prefix = "${oldRoot.value}." + val affected = drafts.keys.filter { key -> + key.value == oldRoot.value || key.value.startsWith(prefix) + } + if (affected.isEmpty()) return + val affectedSet = affected.toSet() + val mapping = linkedMapOf() + affected.forEach { oldId -> + val suffix = oldId.value.removePrefix(oldRoot.value) + val newValue = newRoot.value + suffix + val newId = PermissionId.of(newValue) + if (!affectedSet.contains(newId) && drafts.containsKey(newId)) { + error("Cannot rename '${oldRoot.value}' to '${newRoot.value}' because '$newValue' already exists.") + } + mapping[oldId] = newId + } + + mapping.forEach { (oldId, newId) -> + val draft = drafts.remove(oldId) ?: return@forEach + val newDraft = PermissionNodeDraft( + id = newId, + description = draft.description, + defaultValue = draft.defaultValue, + children = draft.children.toMutableMap(), + wildcard = draft.wildcard, + registration = draft.registration, + wildcardExclusions = draft.wildcardExclusions.toMutableSet() + ) + drafts[newId] = newDraft + } + + drafts.values.forEach { draft -> + val pending = mutableListOf>() + val iterator = draft.children.entries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + val replacement = mapping[entry.key] + if (replacement != null) { + iterator.remove() + pending += replacement to entry.value + } + } + pending.forEach { (id, value) -> draft.children[id] = value } + } + } + + companion object { + fun create(namespace: String): MutablePermissionTree = + MutablePermissionTree(namespace.trim().lowercase(), linkedMapOf()) + + fun from(tree: PermissionTree): MutablePermissionTree = + MutablePermissionTree( + namespace = tree.namespace, + drafts = tree.nodes.mapValues { PermissionNodeDraft.from(it.value) }.toMutableMap() + ) + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/NodeRegistration.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/NodeRegistration.kt new file mode 100644 index 0000000..20c6f8d --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/NodeRegistration.kt @@ -0,0 +1,10 @@ +package net.hareworks.permits_lib.domain + +/** + * Declares whether a DSL node should materialize as an actual Bukkit permission or behave as a + * purely structural placeholder (still participates in relationships/wildcards). + */ +enum class NodeRegistration(val registersPermission: Boolean) { + PERMISSION(true), + STRUCTURAL(false) +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionId.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionId.kt new file mode 100644 index 0000000..9cd8c89 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionId.kt @@ -0,0 +1,26 @@ +package net.hareworks.permits_lib.domain + +/** + * Value object that represents a normalized Bukkit permission node identifier. + * + * The constructor is private to ensure every instance passes through [of] where we enforce the + * naming constraints (lowercase alphanumeric with dots/dashes/underscores) and drop leading/trailing + * whitespace. + */ +@JvmInline +value class PermissionId private constructor(val value: String) { + override fun toString(): String = value + + companion object { + private val VALID_PATTERN = Regex("""^[a-z0-9_.-]+(\.\*)?$""") + + fun of(raw: String): PermissionId { + val normalized = raw.trim().lowercase() + require(normalized.isNotEmpty()) { "Permission id must not be blank." } + require(VALID_PATTERN.matches(normalized)) { + "Permission id '$raw' must match ${VALID_PATTERN.pattern}" + } + return PermissionId(normalized) + } + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNode.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNode.kt new file mode 100644 index 0000000..f20df75 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNode.kt @@ -0,0 +1,23 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +/** + * Immutable description of a permission node in the tree. + * + * The [children] boolean flag behaves like Bukkit's `Permission.children` where `true` propagates a + * grant while `false` explicitly revokes. + */ +data class PermissionNode( + val id: PermissionId, + val description: String? = null, + val defaultValue: PermissionDefault = PermissionDefault.FALSE, + val children: Map = emptyMap(), + val wildcard: Boolean = false, + val registration: NodeRegistration = NodeRegistration.PERMISSION, + val wildcardExclusions: Set = emptySet() +) { + init { + require(children.keys.none { it == id }) { "Permission node cannot be a child of itself." } + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNodeDraft.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNodeDraft.kt new file mode 100644 index 0000000..f098558 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionNodeDraft.kt @@ -0,0 +1,37 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +internal data class PermissionNodeDraft( + val id: PermissionId, + var description: String? = null, + var defaultValue: PermissionDefault = PermissionDefault.FALSE, + val children: MutableMap = linkedMapOf(), + var wildcard: Boolean = false, + var registration: NodeRegistration = NodeRegistration.PERMISSION, + val wildcardExclusions: MutableSet = linkedSetOf() +) { + fun toNode(): PermissionNode = + PermissionNode( + id = id, + description = description, + defaultValue = defaultValue, + children = children.toMap(), + wildcard = wildcard, + registration = registration, + wildcardExclusions = wildcardExclusions.toSet() + ) + + companion object { + fun from(node: PermissionNode): PermissionNodeDraft = + PermissionNodeDraft( + id = node.id, + description = node.description, + defaultValue = node.defaultValue, + children = node.children.toMutableMap(), + wildcard = node.wildcard, + registration = node.registration, + wildcardExclusions = node.wildcardExclusions.toMutableSet() + ) + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTree.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTree.kt new file mode 100644 index 0000000..2759876 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTree.kt @@ -0,0 +1,30 @@ +package net.hareworks.permits_lib.domain + +/** + * Immutable aggregate of permission nodes. + */ +class PermissionTree internal constructor( + val namespace: String, + internal val nodes: Map +) { + init { + require(namespace.isNotBlank()) { "Permission namespace must not be blank." } + } + + val size: Int get() = nodes.size + + operator fun get(id: PermissionId): PermissionNode? = nodes[id] + + fun toSnapshot(): TreeSnapshot = + TreeSnapshot(nodes.filterValues { it.registration.registersPermission }) + + companion object { + fun empty(namespace: String): PermissionTree = PermissionTree(namespace, emptyMap()) + + fun from(namespace: String, rawNodes: Map): PermissionTree { + val augmented = WildcardAugmentor.apply(rawNodes) + PermissionTreeValidator.validate(augmented) + return PermissionTree(namespace, augmented) + } + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTreeValidator.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTreeValidator.kt new file mode 100644 index 0000000..134393b --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/PermissionTreeValidator.kt @@ -0,0 +1,32 @@ +package net.hareworks.permits_lib.domain + +internal object PermissionTreeValidator { + fun validate(nodes: Map) { + checkForCycles(nodes) + } + + private fun checkForCycles(nodes: Map) { + val visiting = mutableSetOf() + val visited = mutableSetOf() + + fun dfs(id: PermissionId) { + if (!visiting.add(id)) { + error("Detected cycle that includes permission '${id.value}'") + } + val node = nodes[id] ?: return + for (child in node.children.keys) { + if (child !in visited) { + dfs(child) + } + } + visiting.remove(id) + visited.add(id) + } + + nodes.keys.forEach { id -> + if (id !in visited) { + dfs(id) + } + } + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt new file mode 100644 index 0000000..8a49b28 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiff.kt @@ -0,0 +1,12 @@ +package net.hareworks.permits_lib.domain + +data class TreeDiff( + val added: List, + val removed: List, + val updated: List +) { + val hasChanges: Boolean + get() = added.isNotEmpty() || removed.isNotEmpty() || updated.isNotEmpty() + + data class UpdatedNode(val before: PermissionNode, val after: PermissionNode) +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiffer.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiffer.kt new file mode 100644 index 0000000..4eb7259 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeDiffer.kt @@ -0,0 +1,29 @@ +package net.hareworks.permits_lib.domain + +object TreeDiffer { + fun diff(previous: TreeSnapshot?, next: TreeSnapshot): TreeDiff { + val prevNodes = previous?.nodes.orEmpty() + val nextNodes = next.nodes + + val added = mutableListOf() + val removed = mutableListOf() + val updated = mutableListOf() + + val allKeys = (prevNodes.keys + nextNodes.keys).toSet() + for (key in allKeys) { + val before = prevNodes[key] + val after = nextNodes[key] + when { + before == null && after != null -> added += after + before != null && after == null -> removed += before + before != null && after != null && before != after -> updated += TreeDiff.UpdatedNode(before, after) + } + } + + return TreeDiff( + added = added.sortedBy { it.id.value }, + removed = removed.sortedBy { it.id.value }, + updated = updated.sortedBy { it.after.id.value } + ) + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeSnapshot.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeSnapshot.kt new file mode 100644 index 0000000..0171cfd --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/TreeSnapshot.kt @@ -0,0 +1,34 @@ +package net.hareworks.permits_lib.domain + +import java.security.MessageDigest + +/** + * Snapshot of a tree at a specific point in time. Holds a deterministic digest useful for caching. + */ +class TreeSnapshot internal constructor( + internal val nodes: Map +) { + val digest: String = computeDigest(nodes) + + companion object { + val EMPTY = TreeSnapshot(emptyMap()) + + private fun computeDigest(nodes: Map): String { + val digest = MessageDigest.getInstance("SHA-256") + nodes.entries + .sortedBy { it.key.value } + .forEach { (id, node) -> + digest.update(id.value.toByteArray()) + digest.update(node.description.orEmpty().toByteArray()) + digest.update(node.defaultValue.name.toByteArray()) + node.children.toSortedMap(compareBy { it.value }).forEach { (childId, flag) -> + digest.update(childId.value.toByteArray()) + digest.update(if (flag) 1 else 0) + } + digest.update(if (node.wildcard) 1 else 0) + digest.update(node.registration.name.toByteArray()) + } + return digest.digest().joinToString("") { "%02x".format(it) } + } + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/WildcardAugmentor.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/WildcardAugmentor.kt new file mode 100644 index 0000000..71cfa42 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/domain/WildcardAugmentor.kt @@ -0,0 +1,43 @@ +package net.hareworks.permits_lib.domain + +import org.bukkit.permissions.PermissionDefault + +internal object WildcardAugmentor { + fun apply(nodes: Map): Map { + if (nodes.isEmpty()) return nodes + val result = nodes.toMutableMap() + + nodes.values.forEach { node -> + if (!node.wildcard) return@forEach + if (node.id.value.endsWith(".*")) return@forEach + + val wildcardId = PermissionId.of("${node.id.value}.*") + val updatedChildren = node.children + .filterKeys { childId -> childId !in node.wildcardExclusions } + .toMutableMap() + + val existing = result[wildcardId] + if (existing == null) { + result[wildcardId] = PermissionNode( + id = wildcardId, + description = "Wildcard for ${node.id.value}", + defaultValue = node.defaultValue, + children = updatedChildren, + wildcard = false + ) + } else { + result[wildcardId] = existing.copy(children = updatedChildren) + } + } + + return result + } + + private fun parentWildcardId(id: PermissionId): PermissionId? { + val value = id.value + val lastDot = value.lastIndexOf('.') + if (lastDot <= 0) return null + val parent = value.substring(0, lastDot) + return PermissionId.of("$parent.*") + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionDsl.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionDsl.kt new file mode 100644 index 0000000..d83cc95 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionDsl.kt @@ -0,0 +1,4 @@ +package net.hareworks.permits_lib.dsl + +@DslMarker +annotation class PermissionDsl diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionNodeBuilder.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionNodeBuilder.kt new file mode 100644 index 0000000..ecd2727 --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionNodeBuilder.kt @@ -0,0 +1,88 @@ +package net.hareworks.permits_lib.dsl + +import net.hareworks.permits_lib.domain.NodeRegistration +import net.hareworks.permits_lib.domain.PermissionId +import net.hareworks.permits_lib.domain.PermissionNodeDraft +import org.bukkit.permissions.PermissionDefault + +@PermissionDsl +class PermissionNodeBuilder internal constructor( + private val treeBuilder: PermissionTreeBuilder, + private val draft: PermissionNodeDraft +) { + var description: String? + get() = draft.description + set(value) { + draft.description = value?.trim() + } + + var defaultValue: PermissionDefault + get() = draft.defaultValue + set(value) { + draft.defaultValue = value + } + + var wildcard: Boolean + get() = draft.wildcard + set(value) { + draft.wildcard = value + } + + fun wildcard(block: WildcardDsl.() -> Unit) { + wildcard = true + WildcardDsl(draft).apply(block) + } + + var registration: NodeRegistration + get() = draft.registration + set(value) { + draft.registration = value + } + + fun child(id: String, value: Boolean = true) { + treeBuilder.childRelative(draft, id, value) + } + + fun child(id: PermissionId, value: Boolean = true) { + treeBuilder.childAbsolute(draft, id.value, value) + } + + /** + * Links to a fully-qualified permission id. The provided [id] must already include its namespace. + */ + fun childAbsolute(id: String, value: Boolean = true) { + treeBuilder.childAbsolute(draft, id, value) + } + + /** + * Declares a nested node whose id is derived from the current node: + * + * ``` + * node("command", NodeRegistration.STRUCTURAL) { + * node("reload", NodeRegistration.PERMISSION) { ... } // -> namespace.command.reload + * } + * ``` + */ + fun node( + id: String, + registration: NodeRegistration, + block: PermissionNodeBuilder.() -> Unit = {} + ) { + treeBuilder.nestedNode(draft, id, registration, block) + } +} + +class WildcardDsl internal constructor( + private val draft: PermissionNodeDraft +) { + fun exclude(vararg segments: String) { + val normalized = segments + .flatMap { it.split('.') } + .map { it.trim().lowercase() } + .filter { it.isNotEmpty() } + if (normalized.isEmpty()) return + val suffix = normalized.joinToString(".") + val permissionId = PermissionId.of("${draft.id.value}.$suffix") + draft.wildcardExclusions.add(permissionId) + } +} diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionTreeBuilder.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionTreeBuilder.kt new file mode 100644 index 0000000..1698c8c --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/dsl/PermissionTreeBuilder.kt @@ -0,0 +1,78 @@ +package net.hareworks.permits_lib.dsl + +import net.hareworks.permits_lib.domain.NodeRegistration +import net.hareworks.permits_lib.domain.PermissionId +import net.hareworks.permits_lib.domain.PermissionNodeDraft +import net.hareworks.permits_lib.domain.PermissionTree + +@PermissionDsl +class PermissionTreeBuilder internal constructor( + private val namespace: String +) { + private val drafts = linkedMapOf() + + fun node( + id: String, + registration: NodeRegistration, + block: PermissionNodeBuilder.() -> Unit = {} + ) { + require(id.isNotBlank()) { "Node id must not be blank." } + val permissionId = PermissionId.of("$namespace.${id.lowercase()}") + val draft = drafts.getOrPut(permissionId) { PermissionNodeDraft(permissionId) } + draft.registration = registration + PermissionNodeBuilder(this, draft).apply(block) + } + + internal fun child( + parent: PermissionNodeDraft, + id: String, + value: Boolean, + relative: Boolean + ) { + val target = if (relative) { + require(id.isNotBlank()) { "Child id must not be blank." } + "${parent.id.value}.${id.lowercase()}" + } else { + normalizeAbsolute(id) + } + val permissionId = PermissionId.of(target) + parent.children[permissionId] = value + } + + internal fun childRelative( + parent: PermissionNodeDraft, + id: String, + value: Boolean + ) = child(parent, id, value, relative = true) + + internal fun childAbsolute( + parent: PermissionNodeDraft, + id: String, + value: Boolean + ) = child(parent, id, value, relative = false) + + internal fun nestedNode( + parent: PermissionNodeDraft, + id: String, + registration: NodeRegistration, + block: PermissionNodeBuilder.() -> Unit + ) { + require(id.isNotBlank()) { "Nested node id must not be blank." } + val composedId = PermissionId.of("${parent.id.value}.${id.lowercase()}") + parent.children[composedId] = true + val draft = drafts.getOrPut(composedId) { PermissionNodeDraft(composedId) } + draft.registration = registration + PermissionNodeBuilder(this, draft).apply(block) + } + + fun build(): PermissionTree = + PermissionTree.from(namespace, drafts.mapValues { it.value.toNode() }) + + private fun normalizeAbsolute(id: String): String { + require(id.isNotBlank()) { "Absolute permission id must not be blank." } + return id.lowercase() + } +} + +fun permissionTree(namespace: String, block: PermissionTreeBuilder.() -> Unit): PermissionTree = + PermissionTreeBuilder(namespace.trim().lowercase()).apply(block).build() diff --git a/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/util/ThreadChecks.kt b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/util/ThreadChecks.kt new file mode 100644 index 0000000..b2f380a --- /dev/null +++ b/hcu-core/kommand-lib/permits-lib/src/main/kotlin/net/hareworks/permits_lib/util/ThreadChecks.kt @@ -0,0 +1,11 @@ +package net.hareworks.permits_lib.util + +import org.bukkit.Bukkit + +internal object ThreadChecks { + fun ensurePrimaryThread(action: String) { + check(Bukkit.isPrimaryThread()) { + "$action must be invoked from the primary server thread." + } + } +} diff --git a/hcu-core/kommand-lib/settings.gradle.kts b/hcu-core/kommand-lib/settings.gradle.kts new file mode 100644 index 0000000..141f75c --- /dev/null +++ b/hcu-core/kommand-lib/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "kommand-lib" + +includeBuild("permits-lib") diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/Kommand.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/Kommand.kt new file mode 100644 index 0000000..548b858 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/Kommand.kt @@ -0,0 +1,62 @@ +package net.hareworks.kommand_lib + +import net.hareworks.kommand_lib.context.KommandContext +import net.hareworks.kommand_lib.dsl.KommandRegistry +import net.hareworks.kommand_lib.permissions.PermissionOptions +import net.hareworks.kommand_lib.permissions.PermissionRuntime +import org.bukkit.command.CommandSender +import org.bukkit.plugin.java.JavaPlugin +import org.bukkit.plugin.Plugin + +fun kommand(plugin: JavaPlugin, block: KommandRegistry.() -> Unit): KommandLib { + val registry = KommandRegistry(plugin) + registry.block() + return registry.build() +} + +/** + * Manages the lifecycle of the commands registered through the DSL. + */ +class KommandLib internal constructor( + private val plugin: JavaPlugin, + private val definitions: List, + private val permissionRuntime: PermissionRuntime? +) { + init { + registerAll() + } + + private fun registerAll() { + val manager = plugin.lifecycleManager + @Suppress("UnstableApiUsage") + manager.registerEventHandler(io.papermc.paper.plugin.lifecycle.event.types.LifecycleEvents.COMMANDS) { event -> + val registrar = event.registrar() + for (definition in definitions) { + // Compile the definition to a Brigadier LiteralArgumentBuilder + val node = TreeCompiler.compile(plugin, definition) + registrar.register(node.build(), definition.description, definition.aliases) + } + } + } + + fun unregister() { + // Lifecycle API handles unregistration automatically on disable usually? + // Or we might need to verify if manual unregistration is needed. + // For now, clearing local state. + // Note: Paper Lifecycle API doesn't expose easy unregister for static commands registered in 'COMMANDS' event usually, + // it rebuilds the dispatcher on reload. + permissionRuntime?.clear() + } +} + +internal data class CommandDefinition( + val name: String, + val aliases: List, + val description: String?, + val usage: String?, + var permission: String?, + val rootCondition: (CommandSender) -> Boolean, + val rootExecutor: (KommandContext.() -> Unit)?, + val nodes: List, + val permissionOptions: PermissionOptions +) diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/Plugin.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/Plugin.kt new file mode 100644 index 0000000..d1b0290 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/Plugin.kt @@ -0,0 +1,6 @@ +package net.hareworks.kommand_lib.plugin; + +import org.bukkit.plugin.java.JavaPlugin + +@Suppress("unused") +public class Plugin : JavaPlugin() {} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/TreeCompiler.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/TreeCompiler.kt new file mode 100644 index 0000000..a3877c8 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/TreeCompiler.kt @@ -0,0 +1,88 @@ +package net.hareworks.kommand_lib + +import com.mojang.brigadier.builder.ArgumentBuilder +import com.mojang.brigadier.builder.LiteralArgumentBuilder +import com.mojang.brigadier.builder.RequiredArgumentBuilder +import com.mojang.brigadier.context.CommandContext +import com.mojang.brigadier.suggestion.SuggestionsBuilder +import com.mojang.brigadier.tree.CommandNode +import io.papermc.paper.command.brigadier.CommandSourceStack +import io.papermc.paper.command.brigadier.Commands +import net.hareworks.kommand_lib.context.KommandContext +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 + +@Suppress("UnstableApiUsage") +internal object TreeCompiler { + + fun compile( + plugin: JavaPlugin, + definition: CommandDefinition + ): LiteralArgumentBuilder { + val root = Commands.literal(definition.name) + .requires { source -> definition.rootCondition(source.sender) } + + // Root execution + definition.rootExecutor?.let { executor -> + root.executes { ctx -> + val context = KommandContext(plugin, ctx) + executor(context) + 1 + } + } + + // Children + definition.nodes.forEach { child -> + compileNode(plugin, child)?.let { root.then(it) } + } + + return root + } + + private fun compileNode( + plugin: JavaPlugin, + node: KommandNode + ): ArgumentBuilder? { + val builder = when (node) { + is LiteralNode -> { + Commands.literal(node.literal) + } + is ValueNode<*> -> { + val argType = node.argument.build() + Commands.argument(node.name, argType) + } + else -> return null + } + + builder.requires { source -> node.isVisible(source.sender) } + + // Execution + node.executor?.let { executor -> + builder.executes { ctx -> + val context = KommandContext(plugin, ctx) + executor(context) + 1 + } + } + + // Custom Suggestions (if any) + if (node is ValueNode<*> && node.suggestionProvider != null && builder is RequiredArgumentBuilder<*, *>) { + @Suppress("UNCHECKED_CAST") + (builder as RequiredArgumentBuilder).suggests { ctx: CommandContext, suggestionsBuilder: SuggestionsBuilder -> + val context = KommandContext(plugin, ctx) + val suggestions = node.suggestionProvider!!.invoke(context, suggestionsBuilder.remaining) + suggestions.forEach { suggestionsBuilder.suggest(it) } + suggestionsBuilder.buildFuture() + } + } + + // Recursion + node.children.forEach { child -> + compileNode(plugin, child)?.let { builder.then(it) } + } + + return builder + } +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/arguments/ArgumentTypes.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/arguments/ArgumentTypes.kt new file mode 100644 index 0000000..5e8d905 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/arguments/ArgumentTypes.kt @@ -0,0 +1,86 @@ +package net.hareworks.kommand_lib.arguments + +import com.mojang.brigadier.arguments.ArgumentType +import com.mojang.brigadier.arguments.BoolArgumentType +import com.mojang.brigadier.arguments.DoubleArgumentType +import com.mojang.brigadier.arguments.IntegerArgumentType +import com.mojang.brigadier.arguments.StringArgumentType +import io.papermc.paper.command.brigadier.argument.ArgumentTypes +import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver +import io.papermc.paper.command.brigadier.argument.resolvers.selector.EntitySelectorArgumentResolver +import org.bukkit.entity.Entity +import org.bukkit.entity.Player +import org.bukkit.util.Vector +import org.bukkit.Location +import org.bukkit.command.CommandSender + +/** + * A holder for the Brigadier ArgumentType and any metadata needed for the DSL. + * + * Note: T represents the final type that users will receive in KommandContext.argument(), + * not necessarily the raw Brigadier return type. For example, PlayerArgument has T=Player, + * but Brigadier returns PlayerSelectorArgumentResolver which is resolved to Player by ArgumentResolver. + */ +interface KommandArgument { + fun build(): ArgumentType<*> +} + +class WordArgument : KommandArgument { + override fun build(): ArgumentType = StringArgumentType.word() +} + +class GreedyStringArgument : KommandArgument { + override fun build(): ArgumentType = StringArgumentType.greedyString() +} + + +class IntegerArgument( + private val min: Int = Int.MIN_VALUE, + private val max: Int = Int.MAX_VALUE +) : KommandArgument { + override fun build(): ArgumentType = IntegerArgumentType.integer(min, max) +} + +class FloatArgument( + private val min: Double = -Double.MAX_VALUE, + private val max: Double = Double.MAX_VALUE +) : KommandArgument { + override fun build(): ArgumentType = DoubleArgumentType.doubleArg(min, max) +} + +class BooleanArgument : KommandArgument { + override fun build(): ArgumentType = BoolArgumentType.bool() +} + +/** + * Single player argument. Returns a Player object after resolving the selector. + * Supports player names and selectors like @p, @s, @r[limit=1]. + */ +class PlayerArgument : KommandArgument { + override fun build(): ArgumentType = ArgumentTypes.player() +} + +/** + * Multiple players argument. Returns a List after resolving the selector. + * Supports player names and selectors like @a, @r. + */ +class PlayersArgument : KommandArgument> { + override fun build(): ArgumentType = ArgumentTypes.players() +} + +/** + * Entity selector argument. Returns a List after resolving the selector. + * Supports all entity selectors like @e, @e[type=minecraft:zombie]. + */ +class EntityArgument : KommandArgument> { + override fun build(): ArgumentType = ArgumentTypes.entities() +} + +/** + * Fine position argument for coordinates with decimal precision. + * Supports relative coordinates like ~ ~1 ~-2. + * Returns a Position (io.papermc.paper.math.Position) after resolving. + */ +class CoordinatesArgument : KommandArgument { + override fun build(): ArgumentType<*> = ArgumentTypes.finePosition() +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/context/ArgumentResolver.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/context/ArgumentResolver.kt new file mode 100644 index 0000000..4e44878 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/context/ArgumentResolver.kt @@ -0,0 +1,76 @@ +package net.hareworks.kommand_lib.context + +import com.mojang.brigadier.context.CommandContext +import io.papermc.paper.command.brigadier.CommandSourceStack +import io.papermc.paper.command.brigadier.argument.resolvers.selector.PlayerSelectorArgumentResolver +import io.papermc.paper.command.brigadier.argument.resolvers.selector.EntitySelectorArgumentResolver +import io.papermc.paper.command.brigadier.argument.resolvers.FinePositionResolver +import io.papermc.paper.command.brigadier.argument.resolvers.BlockPositionResolver +import io.papermc.paper.math.Position +import org.bukkit.entity.Entity +import org.bukkit.entity.Player + +/** + * Internal helper to resolve Brigadier argument types to their actual values. + * This handles the conversion from Paper's resolver types to concrete Bukkit types. + * + * Note: This is public because it's called from inline functions in KommandContext, + * but it's not intended for direct use by library consumers. + */ +object ArgumentResolver { + + /** + * Resolves an argument from the command context. + * Handles special cases for Paper's selector resolvers and position resolvers. + */ + inline fun resolve(context: CommandContext, name: String): T { + val rawValue = context.getArgument(name, Any::class.java) + + @Suppress("UNCHECKED_CAST") + return when { + // Single player selector + T::class.java == Player::class.java && rawValue is PlayerSelectorArgumentResolver -> { + rawValue.resolve(context.source).firstOrNull() as T + ?: throw IllegalStateException("Player selector '$name' did not resolve to any player") + } + + // Multiple players selector + T::class.java == List::class.java && rawValue is PlayerSelectorArgumentResolver -> { + rawValue.resolve(context.source) as T + } + + // Entity selector + T::class.java == List::class.java && rawValue is EntitySelectorArgumentResolver -> { + rawValue.resolve(context.source) as T + } + + // Fine position (coordinates with decimals) + rawValue is FinePositionResolver -> { + rawValue.resolve(context.source) as T + } + + // Block position (integer coordinates) + rawValue is BlockPositionResolver -> { + rawValue.resolve(context.source) as T + } + + // All other types (primitives, strings, etc.) + else -> { + context.getArgument(name, T::class.java) + } + } + } + + /** + * Resolves an argument or returns null if not found. + */ + inline fun resolveOrNull(context: CommandContext, name: String): T? { + return try { + resolve(context, name) + } catch (e: IllegalArgumentException) { + null + } catch (e: IllegalStateException) { + null + } + } +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/context/KommandContext.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/context/KommandContext.kt new file mode 100644 index 0000000..cb8f907 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/context/KommandContext.kt @@ -0,0 +1,25 @@ +package net.hareworks.kommand_lib.context + +import com.mojang.brigadier.context.CommandContext +import io.papermc.paper.command.brigadier.CommandSourceStack +import org.bukkit.command.CommandSender +import org.bukkit.plugin.java.JavaPlugin + +class KommandContext internal constructor( + val plugin: JavaPlugin, + val internal: CommandContext +) { + val sender: CommandSender + get() = internal.source.sender + + val commandSource: CommandSourceStack + get() = internal.source + + inline fun argument(name: String): T { + return ArgumentResolver.resolve(internal, name) + } + + inline fun argumentOrNull(name: String): T? { + return ArgumentResolver.resolveOrNull(internal, name) + } +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/dsl/RegistryBuilders.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/dsl/RegistryBuilders.kt new file mode 100644 index 0000000..87d110f --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/dsl/RegistryBuilders.kt @@ -0,0 +1,228 @@ +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.KommandNode +import net.hareworks.kommand_lib.nodes.LiteralNode +import net.hareworks.kommand_lib.nodes.ValueNode +import org.bukkit.command.CommandSender +import org.bukkit.entity.Entity +import org.bukkit.entity.Player +import org.bukkit.plugin.java.JavaPlugin + +@KommandDsl +class KommandRegistry internal constructor(private val plugin: JavaPlugin) { + private val definitions = mutableListOf() + private var permissionConfigBuilder: PermissionConfigBuilder? = null + + fun command(name: String, vararg aliases: String, block: CommandBuilder.() -> Unit) { + val builder = CommandBuilder(name, aliases.toList()) + builder.block() + definitions += builder.build() + } + + fun command(name: String, aliases: Iterable, block: CommandBuilder.() -> Unit) { + val builder = CommandBuilder(name, aliases.toList()) + builder.block() + definitions += builder.build() + } + + 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 +class CommandBuilder internal constructor( + val name: String, + val aliases: List +) : BranchScope(mutableListOf()) { + 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 + + fun condition(predicate: (CommandSender) -> Boolean) { + condition = predicate + } + + fun executes(block: net.hareworks.kommand_lib.context.KommandContext.() -> Unit) { + rootExecutor = block + } + + override val inheritedPermission: String? + get() = permission + + 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, + aliases = aliases, + description = description, + usage = usage, + permission = permission, + rootCondition = condition, + rootExecutor = rootExecutor, + nodes = children.toList(), + permissionOptions = permissionOptions + ) +} + +@KommandDsl +abstract class BranchScope internal constructor( + protected val children: MutableList +) { + protected abstract val inheritedPermission: String? + protected abstract val inheritedCondition: (CommandSender) -> Boolean + + fun literal(name: String, block: LiteralBuilder.() -> Unit = {}) { + val node = LiteralNode(name) + node.permission = inheritedPermission + node.condition = inheritedCondition + children += node + LiteralBuilder(node).apply(block) + } + + fun argument(name: String, type: KommandArgument, block: ValueBuilder.() -> Unit = {}) { + val node = ValueNode(name, type) + node.permission = inheritedPermission + node.condition = inheritedCondition + node.permissionOptions.preferSkipByDefault = true + children += node + ValueBuilder(node).apply(block) + } + + fun string(name: String, block: ValueBuilder.() -> Unit = {}) = argument(name, WordArgument(), block) + + fun greedyString(name: String, block: ValueBuilder.() -> Unit = {}) = argument(name, GreedyStringArgument(), block) + + + fun integer( + name: String, + min: Int = Int.MIN_VALUE, + max: Int = Int.MAX_VALUE, + block: ValueBuilder.() -> Unit = {} + ) = argument(name, IntegerArgument(min, max), block) + + fun float( + name: String, + min: Double = -Double.MAX_VALUE, + max: Double = Double.MAX_VALUE, + block: ValueBuilder.() -> Unit = {} + ) = argument(name, FloatArgument(min, max), block) + + fun bool( + name: String, + block: ValueBuilder.() -> Unit = {} + ) = argument(name, BooleanArgument(), block) + + fun player( + name: String, + allowSelectors: Boolean = true, // Ignored logic-wise if using native, assuming it handles selectors + block: ValueBuilder.() -> Unit = {} + ) = argument(name, PlayerArgument(), block) + + fun players( + name: String, + allowDirectNames: Boolean = true, + block: ValueBuilder>.() -> Unit = {} + ) = argument(name, PlayersArgument(), block) + + fun selector( + name: String, + requireMatch: Boolean = true, + block: ValueBuilder>.() -> Unit = {} + ) = argument(name, EntityArgument(), block) + + fun coordinates( + name: String, + allowRelative: Boolean = true, + block: ValueBuilder.() -> Unit = {} + ) = argument(name, CoordinatesArgument(), block) +} + +@KommandDsl +abstract class NodeScope internal constructor( + protected val node: KommandNode +) : BranchScope(node.children) { + override val inheritedPermission: String? + get() = node.permission + + override val inheritedCondition: (CommandSender) -> Boolean + get() = node.condition + + fun requires(permission: String) { + node.permission = permission + node.permissionOptions.id = permission + } + + fun condition(predicate: (CommandSender) -> Boolean) { + node.condition = predicate + } + + 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 +class LiteralBuilder internal constructor( + private val literalNode: LiteralNode +) : NodeScope(literalNode) + +@KommandDsl +class ValueBuilder internal constructor( + private val valueNode: ValueNode +) : NodeScope(valueNode) { + /** + * Overrides the default suggestion provider (wrapper around Brigadier logic) + */ + fun suggests(block: net.hareworks.kommand_lib.context.KommandContext.(prefix: String) -> List) { + valueNode.suggestionProvider = { ctx, prefix -> block(ctx, prefix) } + } +} + +@DslMarker +annotation class KommandDsl diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/nodes/Nodes.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/nodes/Nodes.kt new file mode 100644 index 0000000..d58d782 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/nodes/Nodes.kt @@ -0,0 +1,35 @@ +package net.hareworks.kommand_lib.nodes + +import net.hareworks.kommand_lib.arguments.KommandArgument +import net.hareworks.kommand_lib.context.KommandContext +import net.hareworks.kommand_lib.permissions.PermissionOptions +import org.bukkit.command.CommandSender + +abstract class KommandNode internal constructor() { + val children: MutableList = mutableListOf() + 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 + if (!perm.isNullOrBlank() && !sender.hasPermission(perm)) return false + return condition(sender) + } + + open fun segment(): String? = null +} + +class LiteralNode internal constructor(val literal: String) : KommandNode() { + override fun segment(): String = literal +} + +class ValueNode internal constructor( + val name: String, + val argument: KommandArgument +) : KommandNode() { + var suggestionProvider: ((KommandContext, String) -> List)? = null + + override fun segment(): String = name +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionConfig.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionConfig.kt new file mode 100644 index 0000000..28b4cf8 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionConfig.kt @@ -0,0 +1,78 @@ +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, + 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.FALSE + var wildcard: Boolean = false + 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 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, + sessionProvider = sessionFactory ?: { PermitsLib.session(it) } + ) +} + +data class PermissionContext( + val commandName: String, + val path: List, + val kind: PermissionNodeKind +) + +enum class PermissionNodeKind { + COMMAND, + LITERAL, + ARGUMENT +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionOptions.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionOptions.kt new file mode 100644 index 0000000..f0444b4 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionOptions.kt @@ -0,0 +1,56 @@ +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 + var skip: Boolean = false + private var customPath: MutableList? = null + private val wildcardExclusionSpecs: MutableList> = mutableListOf() + internal var preferSkipByDefault: Boolean = false + + internal var resolvedId: String? = null + private set + + fun rename(vararg segments: String) { + customPath = segments + .map { it.trim() } + .filter { it.isNotEmpty() } + .toMutableList() + } + + internal fun renameOverride(): List? = customPath?.toList() + + internal fun resolve(id: String) { + resolvedId = id + } + + fun skipPermission() { + skip = true + } + + val wildcardExclusions: List> + get() = wildcardExclusionSpecs.map { it.toList() } + + fun wildcard(block: WildcardOptions.() -> Unit) { + wildcard = true + WildcardOptions(wildcardExclusionSpecs).apply(block) + } +} + +class WildcardOptions internal constructor( + private val sink: MutableList> +) { + fun exclude(vararg segments: String) { + val normalized = segments + .flatMap { it.split('.') } + .map { it.trim().lowercase() } + .filter { it.isNotEmpty() } + if (normalized.isNotEmpty()) { + sink += normalized + } + } +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionPlan.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionPlan.kt new file mode 100644 index 0000000..b750e1d --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionPlan.kt @@ -0,0 +1,24 @@ +package net.hareworks.kommand_lib.permissions + +import net.hareworks.permits_lib.domain.NodeRegistration +import org.bukkit.permissions.PermissionDefault + +data class PermissionPlan( + val config: PermissionConfig, + val entries: List +) { + val namespace: String get() = config.namespace + fun isEmpty(): Boolean = entries.isEmpty() +} + +data class PlannedPermission( + val id: String, + val relativePath: List, + val parentPath: List?, + val description: String?, + val defaultValue: PermissionDefault, + val wildcardExclusions: List>, + val inheritsParentDefault: Boolean, + val wildcard: Boolean, + val registration: NodeRegistration +) diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionPlanner.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionPlanner.kt new file mode 100644 index 0000000..3cef63d --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionPlanner.kt @@ -0,0 +1,161 @@ +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 net.hareworks.permits_lib.domain.NodeRegistration +import org.bukkit.permissions.PermissionDefault +import org.bukkit.plugin.java.JavaPlugin + +internal class PermissionPlanner( + private val plugin: JavaPlugin, + private val config: PermissionConfig, + private val definitions: List +) { + fun plan(): PermissionPlan { + val entries = linkedMapOf() + val (rootPath, rootDefault) = 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), + parentDefault = config.defaultValue, + registration = NodeRegistration.STRUCTURAL + ) + if (entry != null) entries[entry.id] = entry + path to (entry?.defaultValue ?: config.defaultValue) + } else { + emptyList() to config.defaultValue + } + + definitions.forEach { definition -> + val overridePath = definition.permissionOptions.renameOverride() + 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), + parentDefault = rootDefault + ) + if (commandEntry != null) { + entries[commandEntry.id] = commandEntry + if (definition.permission.isNullOrBlank()) { + definition.permission = commandEntry.id + } + } + val childDefault = commandEntry?.defaultValue ?: rootDefault + definition.nodes.forEach { node -> + planNode(node, commandPath, entries, definition.name, childDefault) + } + } + return PermissionPlan(config, entries.values.toList()) + } + + private fun planNode( + node: KommandNode, + basePath: List, + entries: MutableMap, + commandName: String, + parentDefault: PermissionDefault + ) { + val rawOverride = node.permissionOptions.renameOverride() + val shouldSkip = + node.permissionOptions.skip || + (node.permissionOptions.preferSkipByDefault && + node.permissionOptions.id.isNullOrBlank() && + rawOverride == null) + if (shouldSkip) { + node.children.forEach { child -> + planNode(child, basePath, entries, commandName, parentDefault) + } + return + } + val segment = node.segment()?.let { sanitize(it) } + val pathAddition = rawOverride?.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()), + parentDefault = parentDefault + ) + val currentBase = if (entry != null) { + entries[entry.id] = entry + if (node.permission.isNullOrBlank()) { + node.permission = entry.id + } + path + } else { + basePath + } + val nextDefault = entry?.defaultValue ?: parentDefault + node.children.forEach { child -> + planNode(child, currentBase, entries, commandName, nextDefault) + } + } + + 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, + context: PermissionContext, + parentDefault: PermissionDefault, + registration: NodeRegistration = NodeRegistration.PERMISSION + ): 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 explicitDefault = options.defaultValue + val defaultValue = explicitDefault ?: parentDefault + val wildcard = options.wildcard ?: config.defaultWildcard + val wildcardExclusions = options.wildcardExclusions + .map { normalizeSegments(it) } + .filter { it.isNotEmpty() } + 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, + wildcardExclusions = wildcardExclusions, + inheritsParentDefault = explicitDefault == null, + wildcard = wildcard, + registration = registration + ) + } + + private fun buildId(pathSegments: List): 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): List = + segments.map { sanitize(it) }.filter { it.isNotBlank() } +} diff --git a/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionRuntime.kt b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionRuntime.kt new file mode 100644 index 0000000..d301a57 --- /dev/null +++ b/hcu-core/kommand-lib/src/main/kotlin/net/hareworks/kommand-lib/permissions/PermissionRuntime.kt @@ -0,0 +1,71 @@ +package net.hareworks.kommand_lib.permissions + +import net.hareworks.permits_lib.bukkit.MutationSession +import net.hareworks.permits_lib.domain.MutablePermissionTree +import net.hareworks.permits_lib.domain.NodeRegistration +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 } + val registrations = sorted + .mapNotNull { entry -> + entry.relativePath.takeIf { it.isNotEmpty() }?.joinToString(".")?.let { it to entry.registration } + } + .toMap() + val entriesByPath = sorted + .filter { it.relativePath.isNotEmpty() } + .associateBy { it.relativePath.joinToString(".") } + sorted.forEach { entry -> + if (entry.relativePath.isEmpty()) { + plugin.logger.warning("Skipping permission '${entry.id}' because it resolved to the namespace root.") + return@forEach + } + val nodeId = entry.relativePath.joinToString(".") + val currentNode = mutable.node(nodeId, entry.registration) { + entry.description?.let { description = it } + defaultValue = entry.defaultValue + wildcard = entry.wildcard + } + if (entry.wildcard && entry.wildcardExclusions.isNotEmpty()) { + entry.wildcardExclusions.forEach { exclusion -> + val absolutePath = entry.relativePath + exclusion + if (absolutePath.isNotEmpty()) { + currentNode.excludeWildcardChildAbsolute(buildId(absolutePath)) + } + } + } + val parent = entry.parentPath + if (parent != null && parent.isNotEmpty()) { + val parentId = parent.joinToString(".") + val parentRegistration = registrations[parentId] ?: NodeRegistration.STRUCTURAL + val parentEntry = entriesByPath[parentId] + val shouldLinkChildren = parentEntry?.registration == NodeRegistration.STRUCTURAL || parentEntry?.wildcard == true + mutable.node(parentId, parentRegistration) { + if (shouldLinkChildren) { + child(entry.relativePath.last()) + } + } + } + } + session.applyTree(mutable.build()) + } + + fun clear() { + if (!plan.config.removeOnDisable) return + session.clearAll() + } + + fun attachments() = session.attachments + + private fun buildId(pathSegments: List): String = + (listOf(plan.config.namespace) + pathSegments).filter { it.isNotBlank() }.joinToString(".") +} diff --git a/hcu-core/settings.gradle.kts b/hcu-core/settings.gradle.kts new file mode 100644 index 0000000..70e986b --- /dev/null +++ b/hcu-core/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "hcu-core" + +includeBuild("kommand-lib") diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/Main.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/Main.kt new file mode 100644 index 0000000..9fa6032 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/Main.kt @@ -0,0 +1,198 @@ +package net.hareworks.hcu.core + +import java.util.logging.Level +import net.hareworks.hcu.core.actor.ActorIdentityService +import net.hareworks.hcu.core.actor.ActorIdentityServiceImpl +import net.hareworks.hcu.core.command.CommandRegistrar +import net.hareworks.hcu.core.config.ConfigManager +import net.hareworks.hcu.core.database.DatabaseSessionManager +import net.hareworks.hcu.core.database.DatabaseSettings +import net.hareworks.hcu.core.listeners.AdminAlertListener +import net.hareworks.hcu.core.listeners.PlayerRegistrationListener +import net.hareworks.hcu.core.player.PlayerIdService +import net.hareworks.hcu.core.player.PlayerIdServiceImpl +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.permits_lib.PermitsLib +import net.hareworks.permits_lib.bukkit.MutationSession +import net.hareworks.permits_lib.domain.NodeRegistration +import org.bukkit.permissions.PermissionDefault +import org.bukkit.plugin.ServicePriority +import org.bukkit.plugin.java.JavaPlugin +import org.bukkit.scheduler.BukkitTask +import org.jetbrains.exposed.v1.jdbc.Database + +class Main : JavaPlugin() { + private var commands: KommandLib? = null + private var activeSettings: DatabaseSettings? = null + private var initialConnectTask: BukkitTask? = null + private var permissionSession: MutationSession? = null + + override fun onEnable() { + instance = this + val settings = loadConfiguration(disableOnFailure = true) ?: return + setupPermissionSession() + commands = CommandRegistrar.register(this, permissionSession) + ensureAdminPermissionBridge() + server.pluginManager.registerEvents(AdminAlertListener(this), this) + server.pluginManager.registerEvents(PlayerRegistrationListener(this), this) + scheduleInitialConnectionAttempt() + } + + override fun onDisable() { + cancelInitialConnectionAttempt() + commands?.unregister() + commands = null + server.servicesManager.unregisterAll(this) + DatabaseSessionManager.disconnect(logger) + permissionSession?.clearAll() + permissionSession = null + } + + fun reloadConfiguration(): Boolean { + val settings = loadConfiguration(disableOnFailure = false) ?: return false + logger.info("hcu-core configuration reloaded (${describeTarget()})") + return true + } + + fun reconnectDatabase(): Boolean { + val settings = activeSettings ?: loadConfiguration(disableOnFailure = false) ?: return false + val success = DatabaseSessionManager.reload(settings, logger) + if (success) { + val schemaReady = ensureDatabaseSchema() + if (!schemaReady) { + logger.severe("Database schema initialization failed during reconnect; actor tracking remains offline.") + } + registerDatabaseService() + logger.info("hcu-core database session re-established (${describeTarget()})") + } + return success + } + + fun connectionStatus(): ConnectionStatus = + ConnectionStatus( + connected = DatabaseSessionManager.isConnected(), + pingSucceeded = DatabaseSessionManager.ping(), + target = describeTarget() + ) + + fun describeTarget(): String? = + activeSettings?.let { "${it.dialect.name.lowercase()}:${it.host}:${it.port}/${it.database}" } + + fun exposedDatabase(): Database? = DatabaseSessionManager.database + + fun requireDatabase(): Database = DatabaseSessionManager.requireDatabase() + + fun currentSettings(): DatabaseSettings? = activeSettings + + private fun loadConfiguration(disableOnFailure: Boolean): DatabaseSettings? { + val settings = runCatching { ConfigManager.load(this) } + .onFailure { + logger.log(Level.SEVERE, "Failed to load hcu-core configuration", it) + if (disableOnFailure) disableSelf() + } + .getOrNull() + settings?.let { activeSettings = it } + return settings + } + + private fun setupPermissionSession() { + permissionSession = runCatching { PermitsLib.session(this) } + .onFailure { logger.log(Level.WARNING, "Failed to acquire permits session", it) } + .getOrNull() + } + + private fun ensureAdminPermissionBridge() { + val session = permissionSession ?: return + runCatching { + session.edit("hcu-core") { + node("admin", NodeRegistration.PERMISSION) { + description = "Allows receiving hcu-core administrative warnings" + defaultValue = PermissionDefault.OP + wildcard = false + childAbsolute("hcu-core.command") + childAbsolute("hcu-core.command.*") + } + } + }.onFailure { + logger.log(Level.WARNING, "Failed to register admin permission hierarchy", it) + } + } + + private fun scheduleInitialConnectionAttempt() { + cancelInitialConnectionAttempt() + initialConnectTask = + server.scheduler.runTaskLaterAsynchronously( + this, + Runnable { + initialConnectTask = null + attemptInitialConnection() + }, + 40L + ) + } + + private fun cancelInitialConnectionAttempt() { + initialConnectTask?.cancel() + initialConnectTask = null + } + + private fun attemptInitialConnection() { + val target = describeTarget() ?: "unknown target" + val settings = activeSettings + if (settings == null) { + logger.warning("Initial database connection skipped because no configuration is loaded") + return + } + if (DatabaseSessionManager.connect(settings, logger)) { + val schemaReady = ensureDatabaseSchema() + if (!schemaReady) { + logger.severe("Connected to $target but failed to initialize schema; player registration will be unavailable.") + } + server.scheduler.runTask( + this, + Runnable { + registerDatabaseService() + logger.info("hcu-core ready; database session online ($target)") + } + ) + } else { + logger.warning( + "hcu-core failed to establish database session at startup ($target). Update config and run /hcu db reconnect." + ) + } + } + + private fun registerDatabaseService() { + server.servicesManager.unregisterAll(this) + DatabaseSessionManager.database?.let { database -> + server.servicesManager.register(Database::class.java, database, this, ServicePriority.Normal) + val actorService = ActorIdentityServiceImpl(this, logger) + val playerService = PlayerIdServiceImpl(logger) + server.servicesManager.register( + ActorIdentityService::class.java, + actorService, + this, + ServicePriority.Normal + ) + server.servicesManager.register(PlayerIdService::class.java, playerService, this, ServicePriority.Normal) + } + } + + private fun disableSelf(reason: String? = null) { + reason?.let { logger.severe(it) } + server.pluginManager.disablePlugin(this) + } + + private fun ensureDatabaseSchema(): Boolean = DatabaseSessionManager.ensureSchema(logger) + + data class ConnectionStatus( + val connected: Boolean, + val pingSucceeded: Boolean, + val target: String? + ) + + companion object { + lateinit var instance: Main + private set + } +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorIdentityService.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorIdentityService.kt new file mode 100644 index 0000000..6d3be3f --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorIdentityService.kt @@ -0,0 +1,18 @@ +package net.hareworks.hcu.core.actor + +/** + * Bukkit service for issuing sequential actor IDs backed by the actors table. + */ +interface ActorIdentityService { + /** + * Issues a brand new actor_id by inserting a row into the actors table. + * Throws if the database layer is unavailable or the insert fails. + */ + fun issueActorId(type: String): Int + + /** + * Deletes the actor row for the provided id. + * @return true if a row was deleted, false if the actor was missing. + */ + fun deleteActor(actorId: Int): Boolean +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorIdentityServiceImpl.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorIdentityServiceImpl.kt new file mode 100644 index 0000000..deafdde --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorIdentityServiceImpl.kt @@ -0,0 +1,125 @@ +package net.hareworks.hcu.core.actor + +import java.util.logging.Level +import java.util.logging.Logger +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.todayIn +import net.hareworks.hcu.core.actor.events.ActorCreatedEvent +import net.hareworks.hcu.core.actor.events.ActorDeletedEvent +import net.hareworks.hcu.core.database.DatabaseSessionManager +import net.hareworks.hcu.core.database.schema.ActorsTable +import net.hareworks.hcu.core.database.schema.PlayersTable +import net.hareworks.hcu.core.database.schema.createActorRecord +import org.bukkit.Bukkit +import org.bukkit.event.Event +import org.bukkit.plugin.java.JavaPlugin +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.andWhere +import org.jetbrains.exposed.v1.jdbc.selectAll +import org.jetbrains.exposed.v1.jdbc.update +import kotlin.time.Clock +import kotlin.time.ExperimentalTime + +@OptIn(ExperimentalTime::class) +class ActorIdentityServiceImpl( + private val plugin: JavaPlugin, + private val logger: Logger +) : ActorIdentityService { + override fun issueActorId(type: String): Int = + runOperation { + ensureConnected() + val normalizedType = type.trim() + require(normalizedType.isNotEmpty()) { "Actor type must not be blank" } + val registeredAt = Clock.System.todayIn(TimeZone.UTC) + val actorId = DatabaseSessionManager.transaction { + createActorRecord(registeredAt, normalizedType) + } + dispatchActorCreated(actorId, registeredAt, normalizedType) + actorId + } + + override fun deleteActor(actorId: Int): Boolean = + runOperation { + ensureConnected() + when (val result = DatabaseSessionManager.transaction { markActorRemoved(actorId) }) { + is RemovalResult.Removed -> { + dispatchActorDeleted(actorId, result.type) + true + } + is RemovalResult.Blocked -> { + logger.fine("Skipping removal for actor_id=$actorId (${result.reason})") + false + } + is RemovalResult.AlreadyRemoved -> false + RemovalResult.Missing -> false + is RemovalResult.Failed -> false + } + } + + private fun markActorRemoved(actorId: Int): RemovalResult { + val row = ActorsTable + .selectAll() + .andWhere { ActorsTable.actorId eq actorId } + .firstOrNull() + ?: return RemovalResult.Missing + val type = row[ActorsTable.type] + if (row[ActorsTable.removed]) { + return RemovalResult.AlreadyRemoved(type) + } + if (type == ActorTypes.PLAYER) { + return RemovalResult.Blocked(type, "player actors cannot be removed") + } + val linkedToPlayer = PlayersTable + .selectAll() + .andWhere { PlayersTable.actorId eq actorId } + .firstOrNull() != null + if (linkedToPlayer) { + return RemovalResult.Blocked(type, "actor is linked to a player entry") + } + val updated = ActorsTable.update({ ActorsTable.actorId eq actorId }) { + it[ActorsTable.removed] = true + } + return if (updated > 0) { + RemovalResult.Removed(type) + } else { + RemovalResult.Failed(type) + } + } + + private fun dispatchActorCreated(actorId: Int, registeredAt: LocalDate, type: String) { + callEvent(ActorCreatedEvent(actorId, registeredAt, type)) + } + + private fun dispatchActorDeleted(actorId: Int, type: String) { + callEvent(ActorDeletedEvent(actorId, type)) + } + + private fun callEvent(event: Event) { + val server = plugin.server + if (Bukkit.isPrimaryThread()) { + server.pluginManager.callEvent(event) + } else { + server.scheduler.runTask(plugin, Runnable { server.pluginManager.callEvent(event) }) + } + } + + private fun ensureConnected() { + if (!DatabaseSessionManager.isConnected()) { + throw IllegalStateException("Database session is not connected") + } + } + + private fun runOperation(block: () -> T): T = + runCatching(block) + .onFailure { logger.log(Level.SEVERE, "Actor identity operation failed", it) } + .getOrThrow() + + private sealed interface RemovalResult { + data object Missing : RemovalResult + data class AlreadyRemoved(val type: String) : RemovalResult + data class Blocked(val type: String, val reason: String) : RemovalResult + data class Removed(val type: String) : RemovalResult + data class Failed(val type: String) : RemovalResult + } +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorTypes.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorTypes.kt new file mode 100644 index 0000000..ba5a20a --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/ActorTypes.kt @@ -0,0 +1,6 @@ +package net.hareworks.hcu.core.actor + +/** Well-known actor classifications stored in actors.type. */ +object ActorTypes { + const val PLAYER: String = "player" +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/events/ActorCreatedEvent.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/events/ActorCreatedEvent.kt new file mode 100644 index 0000000..efb6792 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/events/ActorCreatedEvent.kt @@ -0,0 +1,22 @@ +package net.hareworks.hcu.core.actor.events + +import kotlinx.datetime.LocalDate +import org.bukkit.event.Event +import org.bukkit.event.HandlerList + +/** Fired whenever a new actor row is inserted. */ +class ActorCreatedEvent( + val actorId: Int, + val registeredAt: LocalDate, + val type: String +) : Event() { + override fun getHandlers(): HandlerList = handlerList + + companion object { + @JvmStatic + private val handlerList = HandlerList() + + @JvmStatic + fun getHandlerList(): HandlerList = handlerList + } +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/events/ActorDeletedEvent.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/events/ActorDeletedEvent.kt new file mode 100644 index 0000000..3f44aca --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/actor/events/ActorDeletedEvent.kt @@ -0,0 +1,20 @@ +package net.hareworks.hcu.core.actor.events + +import org.bukkit.event.Event +import org.bukkit.event.HandlerList + +/** Fired whenever an actor row is marked as removed. */ +class ActorDeletedEvent( + val actorId: Int, + val type: String +) : Event() { + override fun getHandlers(): HandlerList = handlerList + + companion object { + @JvmStatic + private val handlerList = HandlerList() + + @JvmStatic + fun getHandlerList(): HandlerList = handlerList + } +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/command/CommandRegistrar.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/command/CommandRegistrar.kt new file mode 100644 index 0000000..f9645e3 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/command/CommandRegistrar.kt @@ -0,0 +1,168 @@ +package net.hareworks.hcu.core.command + +import net.hareworks.hcu.core.Main +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.kommand_lib.kommand +import net.hareworks.permits_lib.bukkit.MutationSession +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextDecoration +import org.bukkit.command.CommandSender +import org.bukkit.permissions.PermissionDefault + +object CommandRegistrar { + fun register(plugin: Main, permissionSession: MutationSession?): KommandLib = + kommand(plugin) { + permissions { + namespace = "hcu-core" + defaultValue = PermissionDefault.OP + permissionSession?.let { session(it) } + } + command("hcu") { + description = "Manage hcu-core configuration and database sessions" + + executes { sender.showUsage() } + + literal("help") { + executes { sender.showUsage() } + } + + literal("config") { + executes { + sender.error("Usage: /hcu config reload") + } + literal("reload") { + executes { + if (plugin.reloadConfiguration()) { + sender.success("Configuration reloaded: ${plugin.describeTarget() ?: "unknown target"}") + } else { + sender.error("Failed to reload configuration. See console for details.") + } + } + } + } + + literal("db") { + executes { + sender.error("Usage: /hcu db ") + } + + literal("status") { + executes { + val status = plugin.connectionStatus() + val target = status.target ?: "unknown" + + sender.sendMessage( + Component.text() + .append(Component.text("[hcu-core] ", NamedTextColor.GRAY)) + .append(Component.text("Database Status", NamedTextColor.GOLD, TextDecoration.BOLD)) + .build() + ) + + sender.sendMessage( + Component.text() + .append(Component.text(" Connection: ", NamedTextColor.GRAY)) + .append( + Component.text( + if (status.connected) "Connected" else "Disconnected", + if (status.connected) NamedTextColor.GREEN else NamedTextColor.RED + ) + ) + .build() + ) + + sender.sendMessage( + Component.text() + .append(Component.text(" Ping: ", NamedTextColor.GRAY)) + .append( + Component.text( + if (status.pingSucceeded) "Reachable" else "Unreachable", + if (status.pingSucceeded) NamedTextColor.GREEN else NamedTextColor.RED + ) + ) + .build() + ) + + sender.sendMessage( + Component.text() + .append(Component.text(" Target: ", NamedTextColor.GRAY)) + .append(Component.text(target, NamedTextColor.AQUA)) + .build() + ) + } + } + + literal("reconnect") { + executes { + sender.info("Attempting to reconnect to database...") + if (plugin.reconnectDatabase()) { + sender.success("Database reconnection succeeded.") + } else { + sender.error("Database reconnection failed. Check server logs.") + } + } + } + } + } + } +} + +private fun CommandSender.showUsage() { + sendMessage( + Component.text() + .append(Component.text("[hcu-core] ", NamedTextColor.GRAY)) + .append(Component.text("Available Commands", NamedTextColor.GOLD, TextDecoration.BOLD)) + .build() + ) + sendMessage( + Component.text() + .append(Component.text(" /hcu config reload", NamedTextColor.YELLOW)) + .append(Component.text(" - ", NamedTextColor.DARK_GRAY)) + .append(Component.text("Reload hcu-core configuration", NamedTextColor.GRAY)) + .build() + ) + sendMessage( + Component.text() + .append(Component.text(" /hcu db status", NamedTextColor.YELLOW)) + .append(Component.text(" - ", NamedTextColor.DARK_GRAY)) + .append(Component.text("Show current database status", NamedTextColor.GRAY)) + .build() + ) + sendMessage( + Component.text() + .append(Component.text(" /hcu db reconnect", NamedTextColor.YELLOW)) + .append(Component.text(" - ", NamedTextColor.DARK_GRAY)) + .append(Component.text("Reconnect using current configuration", NamedTextColor.GRAY)) + .build() + ) +} + +private fun CommandSender.success(message: String) { + sendMessage( + Component.text() + .append(Component.text("[hcu-core] ", NamedTextColor.GRAY)) + .append(Component.text("✓ ", NamedTextColor.GREEN)) + .append(Component.text(message, NamedTextColor.WHITE)) + .build() + ) +} + +private fun CommandSender.error(message: String) { + sendMessage( + Component.text() + .append(Component.text("[hcu-core] ", NamedTextColor.GRAY)) + .append(Component.text("✗ ", NamedTextColor.RED)) + .append(Component.text(message, NamedTextColor.WHITE)) + .build() + ) +} + +private fun CommandSender.info(message: String) { + sendMessage( + Component.text() + .append(Component.text("[hcu-core] ", NamedTextColor.GRAY)) + .append(Component.text("ℹ ", NamedTextColor.AQUA)) + .append(Component.text(message, NamedTextColor.WHITE)) + .build() + ) +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/config/ConfigManager.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/config/ConfigManager.kt new file mode 100644 index 0000000..17e8eca --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/config/ConfigManager.kt @@ -0,0 +1,17 @@ +package net.hareworks.hcu.core.config + +import net.hareworks.hcu.core.database.DatabaseSettings +import org.bukkit.plugin.java.JavaPlugin + +object ConfigManager { + @Volatile + private var cachedSettings: DatabaseSettings? = null + + fun load(plugin: JavaPlugin): DatabaseSettings { + plugin.saveDefaultConfig() + plugin.reloadConfig() + return DatabaseSettings.fromConfig(plugin.config).also { cachedSettings = it } + } + + fun current(): DatabaseSettings? = cachedSettings +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/Database.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/Database.kt new file mode 100644 index 0000000..4ee94e2 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/Database.kt @@ -0,0 +1,191 @@ +package net.hareworks.hcu.core.database + +import com.zaxxer.hikari.HikariConfig +import com.zaxxer.hikari.HikariDataSource +import net.hareworks.hcu.core.database.schema.ActorIdSequence +import net.hareworks.hcu.core.database.schema.ActorsTable +import net.hareworks.hcu.core.database.schema.PlayersTable +import org.bukkit.configuration.file.FileConfiguration +import org.jetbrains.exposed.v1.jdbc.Database +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import org.jetbrains.exposed.v1.jdbc.SchemaUtils +import org.jetbrains.exposed.v1.jdbc.transactions.TransactionManager +import org.jetbrains.exposed.v1.jdbc.transactions.transaction +import java.util.logging.Level +import java.util.logging.Logger + +enum class DatabaseDialect( + val driverClass: String, + private val jdbcPrefix: String, + val defaultPort: Int +) { + POSTGRESQL( + driverClass = "org.postgresql.Driver", + jdbcPrefix = "jdbc:postgresql://", + defaultPort = 5432 + ), + MYSQL( + driverClass = "com.mysql.cj.jdbc.Driver", + jdbcPrefix = "jdbc:mysql://", + defaultPort = 3306 + ); + + fun jdbcUrl(host: String, port: Int, database: String): String = "$jdbcPrefix$host:$port/$database" + + companion object { + fun from(raw: String?): DatabaseDialect = when (raw?.lowercase()) { + "postgresql", "postgres", "pg" -> POSTGRESQL + "mysql", "mariadb" -> MYSQL + else -> throw IllegalArgumentException("Unsupported database dialect: $raw") + } + } +} + +data class DatabaseSettings( + val dialect: DatabaseDialect, + val host: String, + val port: Int, + val database: String, + val username: String, + val password: String, + val pool: PoolSettings +) { + val jdbcUrl: String = dialect.jdbcUrl(host, port, database) + + data class PoolSettings( + val maxPoolSize: Int, + val minIdle: Int, + val maxLifetimeMillis: Long, + val connectionTimeoutMillis: Long + ) + + companion object { + fun fromConfig(config: FileConfiguration): DatabaseSettings { + val section = config.getConfigurationSection("database") + ?: error("Missing 'database' section in config.yml") + val dialect = DatabaseDialect.from(section.getString("dialect")) + val host = section.getString("host")?.takeUnless { it.isBlank() } + ?: error("database.host must be provided") + val port = section.getInt("port", dialect.defaultPort) + val database = section.getString("name")?.takeUnless { it.isBlank() } + ?: error("database.name must be provided") + val username = section.getString("user")?.takeUnless { it.isBlank() } + ?: error("database.user must be provided") + val password = section.getString("password")?.takeUnless { it.isBlank() } + ?: error("database.password must be provided") + val poolSection = section.getConfigurationSection("pool") + val maxPoolSize = poolSection?.getInt("maxPoolSize") ?: 8 + val minIdle = poolSection?.getInt("minIdle") ?: 2 + val maxLifetimeSeconds = poolSection?.getLong("maxLifetimeSeconds") ?: 1800L + val connectionTimeoutSeconds = poolSection?.getLong("connectionTimeoutSeconds") ?: 30L + + return DatabaseSettings( + dialect = dialect, + host = host, + port = port, + database = database, + username = username, + password = password, + pool = PoolSettings( + maxPoolSize = maxPoolSize, + minIdle = minIdle, + maxLifetimeMillis = maxLifetimeSeconds * 1000, + connectionTimeoutMillis = connectionTimeoutSeconds * 1000 + ) + ) + } + } +} + +object DatabaseSessionManager { + @Volatile + private var dataSource: HikariDataSource? = null + @Volatile + private var _database: Database? = null + + val database: Database? + get() = _database + + fun requireDatabase(): Database = + _database ?: throw IllegalStateException("Database session is not available") + + fun isConnected(): Boolean = _database != null + + fun connect(settings: DatabaseSettings, logger: Logger): Boolean { + synchronized(this) { + disconnectInternal(logger) + return try { + val hikariConfig = HikariConfig().apply { + jdbcUrl = settings.jdbcUrl + driverClassName = settings.dialect.driverClass + username = settings.username + password = settings.password + maximumPoolSize = settings.pool.maxPoolSize + minimumIdle = settings.pool.minIdle + maxLifetime = settings.pool.maxLifetimeMillis + connectionTimeout = settings.pool.connectionTimeoutMillis + validate() + } + val dataSource = HikariDataSource(hikariConfig) + val database = Database.connect(dataSource) + dataSource.also { this.dataSource = it } + this._database = database + TransactionManager.defaultDatabase = database + logger.info("Connected to database ${settings.jdbcUrl}") + true + } catch (ex: Exception) { + logger.log(Level.SEVERE, "Failed to initialize database session", ex) + false + } + } + } + + fun reload(settings: DatabaseSettings, logger: Logger): Boolean = connect(settings, logger) + + fun disconnect(logger: Logger) { + synchronized(this) { + disconnectInternal(logger) + } + } + + private fun disconnectInternal(logger: Logger) { + _database = null + dataSource?.close() + if (dataSource != null) { + logger.info("Database connection pool shut down") + } + dataSource = null + } + + @JvmStatic + fun transaction(block: JdbcTransaction.() -> T): T { + val db = _database ?: throw IllegalStateException("Database is not connected") + return transaction(db) { block() } + } + + fun ping(): Boolean { + val db = _database ?: return false + return runCatching { + transaction(db) { + exec("SELECT 1") { } + } + }.isSuccess + } + + fun ensureSchema(logger: Logger): Boolean { + if (!isConnected()) { + logger.severe("Cannot ensure schema because database is not connected") + return false + } + return runCatching { + transaction { + SchemaUtils.createSequence(ActorIdSequence) + SchemaUtils.createMissingTablesAndColumns(ActorsTable, PlayersTable) + } + logger.fine("Ensured actors/players schema") + true + }.onFailure { + logger.log(Level.SEVERE, "Failed to ensure actors/players schema", it) + }.getOrDefault(false) + } +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/schema/ActorSchema.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/schema/ActorSchema.kt new file mode 100644 index 0000000..8a87f60 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/schema/ActorSchema.kt @@ -0,0 +1,30 @@ +package net.hareworks.hcu.core.database.schema + +import kotlinx.datetime.LocalDate +import net.hareworks.hcu.core.actor.ActorTypes +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.Sequence +import org.jetbrains.exposed.v1.core.Table +import org.jetbrains.exposed.v1.datetime.date +import org.jetbrains.exposed.v1.jdbc.JdbcTransaction +import org.jetbrains.exposed.v1.jdbc.insert + +val ActorIdSequence = Sequence("actor_id_seq") + +object ActorsTable : Table("actors") { + val actorId: Column = integer("actor_id").autoIncrement(ActorIdSequence) + val type: Column = varchar("type", length = 64).default(ActorTypes.PLAYER) + val registeredAt = date("registerd_at") + val removed = bool("removed").default(false) + + override val primaryKey: PrimaryKey = PrimaryKey(actorId) +} + +fun JdbcTransaction.createActorRecord(registeredAt: LocalDate, type: String): Int { + val insertStatement = ActorsTable.insert { + it[ActorsTable.registeredAt] = registeredAt + it[ActorsTable.type] = type + it[ActorsTable.removed] = false + } + return insertStatement[ActorsTable.actorId] ?: error("Failed to retrieve generated actor_id") +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/schema/PlayerSchema.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/schema/PlayerSchema.kt new file mode 100644 index 0000000..8c89ad5 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/database/schema/PlayerSchema.kt @@ -0,0 +1,15 @@ +package net.hareworks.hcu.core.database.schema + +import java.util.UUID +import org.jetbrains.exposed.v1.core.Column +import org.jetbrains.exposed.v1.core.ReferenceOption +import org.jetbrains.exposed.v1.core.Table + +object PlayersTable : Table("players") { + val uuid: Column = uuid("uuid") + val username: Column = varchar("username", length = 64) + val actorId: Column = integer("actor_id") + .references(ActorsTable.actorId, onDelete = ReferenceOption.RESTRICT, onUpdate = ReferenceOption.RESTRICT) + + override val primaryKey: PrimaryKey = PrimaryKey(uuid) +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/listeners/AdminAlertListener.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/listeners/AdminAlertListener.kt new file mode 100644 index 0000000..40976c7 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/listeners/AdminAlertListener.kt @@ -0,0 +1,32 @@ +package net.hareworks.hcu.core.listeners + +import net.hareworks.hcu.core.Main +import org.bukkit.event.EventHandler +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent + +/** + * Notifies administrators about database connectivity issues when they join the server. + */ +class AdminAlertListener(private val plugin: Main) : Listener { + @EventHandler + fun onPlayerJoin(event: PlayerJoinEvent) { + val player = event.player + if (!player.hasPermission("hcu-core.admin")) { + return + } + val status = plugin.connectionStatus() + if (status.connected && status.pingSucceeded) { + return + } + val target = status.target ?: "unknown target" + val reason = if (!status.connected) { + "database session is not connected" + } else { + "database ping failed" + } + player.sendMessage( + "§c[hcu-core] Database is offline: $reason ($target). Update config or run /hcu db reconnect." + ) + } +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/listeners/PlayerRegistrationListener.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/listeners/PlayerRegistrationListener.kt new file mode 100644 index 0000000..8354b4a --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/listeners/PlayerRegistrationListener.kt @@ -0,0 +1,79 @@ +package net.hareworks.hcu.core.listeners + +import java.util.logging.Level +import java.util.UUID +import net.hareworks.hcu.core.Main +import net.hareworks.hcu.core.actor.ActorIdentityService +import net.hareworks.hcu.core.actor.ActorTypes +import net.hareworks.hcu.core.database.DatabaseSessionManager +import net.hareworks.hcu.core.database.schema.PlayersTable +import net.hareworks.hcu.core.player.PlayerIdService +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.player.PlayerJoinEvent +import org.jetbrains.exposed.v1.core.eq +import org.jetbrains.exposed.v1.jdbc.insert +import org.jetbrains.exposed.v1.jdbc.update + +class PlayerRegistrationListener(private val plugin: Main) : Listener { + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + fun onPlayerJoin(event: PlayerJoinEvent) { + val player = event.player + plugin.server.scheduler.runTaskAsynchronously( + plugin, + Runnable { registerPlayer(player.uniqueId, player.name) } + ) + } + + private fun registerPlayer(uuid: UUID, username: String) { + if (!plugin.isEnabled) return + + val services = plugin.server.servicesManager + val playerService = services.load(PlayerIdService::class.java) + val actorService = services.load(ActorIdentityService::class.java) + + if (playerService == null || actorService == null) { + plugin.logger.fine("Player/Actor services unavailable; skipping registration for $uuid") + return + } + + val lookupResult = runCatching { playerService.find(uuid) } + .onFailure { plugin.logger.log(Level.SEVERE, "Failed to lookup player $uuid", it) } + if (lookupResult.isFailure) { + return + } + val existing = lookupResult.getOrNull() + if (existing != null) { + if (existing.username != username) { + runCatching { + DatabaseSessionManager.transaction { + PlayersTable.update({ PlayersTable.uuid eq uuid }) { + it[PlayersTable.username] = username + } + } + }.onFailure { + plugin.logger.log(Level.WARNING, "Failed to update username for player $uuid", it) + } + } + return + } + + val actorId = runCatching { actorService.issueActorId(ActorTypes.PLAYER) } + .onFailure { plugin.logger.log(Level.SEVERE, "Failed to issue actor_id for $uuid", it) } + .getOrNull() ?: return + + runCatching { + DatabaseSessionManager.transaction { + PlayersTable.insert { + it[PlayersTable.uuid] = uuid + it[PlayersTable.username] = username + it[PlayersTable.actorId] = actorId + } + } + }.onFailure { + plugin.logger.log(Level.SEVERE, "Failed to insert player entry for $uuid", it) + } + } +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/player/PlayerIdService.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/player/PlayerIdService.kt new file mode 100644 index 0000000..b8a6c06 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/player/PlayerIdService.kt @@ -0,0 +1,20 @@ +package net.hareworks.hcu.core.player + +import java.util.UUID +import org.bukkit.OfflinePlayer + +interface PlayerIdService { + data class PlayerEntry( + val uuid: UUID, + val username: String, + val actorId: Int + ) + + fun find(player: OfflinePlayer): PlayerEntry? = find(player.uniqueId) + + fun find(uuid: UUID): PlayerEntry? + + fun find(username: String): PlayerEntry? + + fun find(actorId: Int): PlayerEntry? +} diff --git a/hcu-core/src/main/kotlin/net/hareworks/hcu/core/player/PlayerIdServiceImpl.kt b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/player/PlayerIdServiceImpl.kt new file mode 100644 index 0000000..02108b4 --- /dev/null +++ b/hcu-core/src/main/kotlin/net/hareworks/hcu/core/player/PlayerIdServiceImpl.kt @@ -0,0 +1,46 @@ +package net.hareworks.hcu.core.player + +import java.util.UUID +import java.util.logging.Level +import java.util.logging.Logger +import net.hareworks.hcu.core.database.DatabaseSessionManager +import net.hareworks.hcu.core.database.schema.PlayersTable +import org.jetbrains.exposed.v1.core.ResultRow +import org.jetbrains.exposed.v1.jdbc.selectAll + +class PlayerIdServiceImpl(private val logger: Logger) : PlayerIdService { + override fun find(uuid: UUID): PlayerIdService.PlayerEntry? = + query { it[PlayersTable.uuid] == uuid } + + override fun find(username: String): PlayerIdService.PlayerEntry? = + query { it[PlayersTable.username] == username } + + override fun find(actorId: Int): PlayerIdService.PlayerEntry? = + query { it[PlayersTable.actorId] == actorId } + + private fun query(filter: (ResultRow) -> Boolean): PlayerIdService.PlayerEntry? { + ensureConnected() + return runCatching { + DatabaseSessionManager.transaction { + PlayersTable.selectAll() + .firstOrNull { filter(it) } + ?.toEntry() + } + }.onFailure { + logger.log(Level.SEVERE, "Failed to query player entry", it) + }.getOrThrow() + } + + private fun ResultRow.toEntry(): PlayerIdService.PlayerEntry = + PlayerIdService.PlayerEntry( + uuid = this[PlayersTable.uuid], + username = this[PlayersTable.username], + actorId = this[PlayersTable.actorId] + ) + + private fun ensureConnected() { + if (!DatabaseSessionManager.isConnected()) { + throw IllegalStateException("Database session is not connected") + } + } +} diff --git a/hcu-core/src/main/resources/config.yml b/hcu-core/src/main/resources/config.yml new file mode 100644 index 0000000..4af1810 --- /dev/null +++ b/hcu-core/src/main/resources/config.yml @@ -0,0 +1,14 @@ +# Database connection managed by hcu-core. +database: + # Supported values: postgresql, mysql + dialect: postgresql + host: localhost + port: 5432 + name: minecraft + user: minecraft + password: change-me + pool: + maxPoolSize: 8 + minIdle: 2 + maxLifetimeSeconds: 1800 + connectionTimeoutSeconds: 30 diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..1dfedda --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,3 @@ +rootProject.name = "hcu-items" + +includeBuild("hcu-core") \ No newline at end of file diff --git a/src/main/kotlin/net/hareworks/hcu/items/App.kt b/src/main/kotlin/net/hareworks/hcu/items/App.kt new file mode 100644 index 0000000..05deba8 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/items/App.kt @@ -0,0 +1,34 @@ +package net.hareworks.hcu.items; + +import net.hareworks.hcu.items.command.CommandRegistrar +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.permits_lib.PermitsLib +import net.hareworks.permits_lib.domain.NodeRegistration +import net.hareworks.hcu.items.domain.ItemRegistry +import net.hareworks.hcu.items.domain.impl.TestItem + +import org.bukkit.permissions.PermissionDefault +import org.bukkit.plugin.java.JavaPlugin + +public class App : JavaPlugin() { + + private var commands: KommandLib? = null + private val permits = PermitsLib.session(this) + + companion object { + lateinit var instance: App + private set + } + + override fun onEnable() { + instance = this + saveDefaultConfig() + server.pluginManager.registerEvents(net.hareworks.hcu.items.listeners.EventListener(this), this) + logger.info("Items plugin enabled!") + + commands = CommandRegistrar.register(this, permits) + + // Register items + ItemRegistry.register(TestItem()) + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/items/command/CommandRegistrar.kt b/src/main/kotlin/net/hareworks/hcu/items/command/CommandRegistrar.kt new file mode 100644 index 0000000..92c9e41 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/items/command/CommandRegistrar.kt @@ -0,0 +1,89 @@ +package net.hareworks.hcu.items.command + +import net.hareworks.hcu.items.App +import net.hareworks.kommand_lib.KommandLib +import net.hareworks.kommand_lib.kommand +import net.hareworks.permits_lib.bukkit.MutationSession +import org.bukkit.permissions.PermissionDefault +import org.bukkit.entity.Player +import net.hareworks.hcu.items.domain.ItemRegistry +import net.hareworks.hcu.items.domain.SpecialItem + +object CommandRegistrar { + fun register(plugin: App, permits: MutationSession): KommandLib { + return kommand(plugin) { + // Permission configuration + permissions { + namespace = "hcu-items" + defaultValue = PermissionDefault.OP + session(permits) + } + + // Main command structure + command("hcu-items") { + description = "Items plugin main command" + + // Default execution if no arguments + executes { + sender.sendMessage("Items plugin v${plugin.description.version}") + } + + // Subcommand: /hcu-items reload + literal("reload") { + permission { + description = "Allows reloading the plugin" + defaultValue = PermissionDefault.OP + } + executes { + plugin.reloadConfig() + sender.sendMessage("Configuration reloaded.") + } + } + // Subcommand: /hcu-items give [player] + literal("give") { + permission { + description = "Gives a special item to a player" + defaultValue = PermissionDefault.OP + } + string("itemId") { + suggests { ItemRegistry.getAll().map { it.id } } + + executes { + val sender = sender + if (sender !is Player) { + sender.sendMessage("Only players can use this command without a target.") + return@executes + } + val itemId = argument("itemId") + val item = ItemRegistry.get(itemId) // Nullable check needed? + + if (item == null) { + sender.sendMessage("Unknown item: $itemId") + return@executes + } + + sender.inventory.addItem(item.createItemStack()) + sender.sendMessage("Given $itemId to yourself.") + } + + player("target") { + executes { + val target = argument("target") + val itemId = argument("itemId") + val item = ItemRegistry.get(itemId) + + if (item == null) { + sender.sendMessage("Unknown item: $itemId") + return@executes + } + + target.inventory.addItem(item.createItemStack()) + sender.sendMessage("Given $itemId to ${target.name}.") + } + } + } + } + } + } + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/items/domain/ItemRegistry.kt b/src/main/kotlin/net/hareworks/hcu/items/domain/ItemRegistry.kt new file mode 100644 index 0000000..a313ac1 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/items/domain/ItemRegistry.kt @@ -0,0 +1,30 @@ +package net.hareworks.hcu.items.domain + +object ItemRegistry { + private val items = mutableMapOf() + + /** + * Registers a special item. + * @throws IllegalArgumentException if an item with the same ID is already registered. + */ + fun register(item: SpecialItem) { + if (items.containsKey(item.id)) { + throw IllegalArgumentException("Item with id ${item.id} is already registered") + } + items[item.id] = item + } + + /** + * Gets a special item by its ID. + */ + fun get(id: String): SpecialItem? { + return items[id] + } + + /** + * Gets all registered special items. + */ + fun getAll(): Collection { + return items.values + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/items/domain/SpecialItem.kt b/src/main/kotlin/net/hareworks/hcu/items/domain/SpecialItem.kt new file mode 100644 index 0000000..9696eee --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/items/domain/SpecialItem.kt @@ -0,0 +1,60 @@ +package net.hareworks.hcu.items.domain + +import net.kyori.adventure.text.Component +import org.bukkit.NamespacedKey +import org.bukkit.inventory.ItemStack +import org.bukkit.persistence.PersistentDataType + +/** + * Base class for all special HCU items. + * + * @property id The unique identifier for this special item. + */ +abstract class SpecialItem(val id: String) { + + /** + * Creates a new ItemStack for this special item. + * Subclasses should override [buildItem] to define the item's properties. + */ + fun createItemStack(): ItemStack { + val item = buildItem() + val meta = item.itemMeta ?: return item + + // Tag the item as a special HCU item + meta.persistentDataContainer.set(KEY_HCU_ITEM_ID, PersistentDataType.STRING, id) + + item.itemMeta = meta + return item + } + + /** + * Builds the base ItemStack. + * This method should configure the material, name, lore, etc. + */ + protected abstract fun buildItem(): ItemStack + + /** + * Called when a player interacts with this item. + */ + open fun onInteract(event: org.bukkit.event.player.PlayerInteractEvent) {} + + companion object { + val KEY_HCU_ITEM_ID = NamespacedKey("hcu_items", "id") + + /** + * Checks if the given item is a special HCU item. + */ + fun isSpecialItem(item: ItemStack?): Boolean { + if (item == null || item.type.isAir) return false + return item.itemMeta?.persistentDataContainer?.has(KEY_HCU_ITEM_ID, PersistentDataType.STRING) == true + } + + /** + * Gets the HCU item ID from the given item, or null if it's not a special item. + */ + fun getId(item: ItemStack?): String? { + if (item == null || item.type.isAir) return null + return item.itemMeta?.persistentDataContainer?.get(KEY_HCU_ITEM_ID, PersistentDataType.STRING) + } + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/items/domain/impl/TestItem.kt b/src/main/kotlin/net/hareworks/hcu/items/domain/impl/TestItem.kt new file mode 100644 index 0000000..cedbfca --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/items/domain/impl/TestItem.kt @@ -0,0 +1,26 @@ +package net.hareworks.hcu.items.domain.impl + +import net.hareworks.hcu.items.domain.SpecialItem +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import org.bukkit.Material +import org.bukkit.inventory.ItemStack + +class TestItem : SpecialItem("test_sword") { + override fun buildItem(): ItemStack { + val item = ItemStack(Material.DIAMOND_SWORD) + val meta = item.itemMeta ?: return item + + meta.displayName(Component.text("Test Sword", NamedTextColor.GOLD)) + meta.lore(listOf( + Component.text("A special sword for testing purposes.", NamedTextColor.GRAY) + )) + + item.itemMeta = meta + return item + } + + override fun onInteract(event: org.bukkit.event.player.PlayerInteractEvent) { + event.player.sendMessage(Component.text("You used the Test Sword!", NamedTextColor.GREEN)) + } +} diff --git a/src/main/kotlin/net/hareworks/hcu/items/listeners/EventListener.kt b/src/main/kotlin/net/hareworks/hcu/items/listeners/EventListener.kt new file mode 100644 index 0000000..3237c81 --- /dev/null +++ b/src/main/kotlin/net/hareworks/hcu/items/listeners/EventListener.kt @@ -0,0 +1,28 @@ +package net.hareworks.hcu.items.listeners + +import org.bukkit.event.Listener +import org.bukkit.event.EventHandler +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.inventory.EquipmentSlot + +import net.hareworks.hcu.items.App +import net.hareworks.hcu.items.domain.ItemRegistry +import net.hareworks.hcu.items.domain.SpecialItem + +class EventListener(private val plugin: App) : Listener { + + @EventHandler + fun onInteract(event: PlayerInteractEvent) { + // Only handle main hand interactions or when there's an item involved + if (event.hand == EquipmentSlot.OFF_HAND) return + + val item = event.item ?: return + + if (SpecialItem.isSpecialItem(item)) { + val id = SpecialItem.getId(item) ?: return + val specialItem = ItemRegistry.get(id) + + specialItem?.onInteract(event) + } + } +} diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml new file mode 100644 index 0000000..83cf02f --- /dev/null +++ b/src/main/resources/config.yml @@ -0,0 +1,15 @@ +# Elevator Plugin Configuration + +# The maximum distance (in blocks) the elevator will search for a valid destination. +maxHeight: 64 + +# Blocks that function as elevators +elevatorBlocks: + - minecraft:iron_block + - minecraft:gold_block + - minecraft:diamond_block + - minecraft:lapis_block + - minecraft:redstone_block + - minecraft:emerald_block + - minecraft:netherite_block + - minecraft:coal_block