Initial commit: Essentials and ServerStatus mods
This commit is contained in:
commit
8f2c2c7941
71 changed files with 5292 additions and 0 deletions
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
# Gradle
|
||||||
|
.gradle/
|
||||||
|
build/
|
||||||
|
bin/
|
||||||
|
run/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.eclipse/
|
||||||
|
.vscode/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.kotlin/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
66
Essentials/README.md
Normal file
66
Essentials/README.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# ⚙️ Essentials
|
||||||
|
|
||||||
|
마인크래프트 서버 필수 기능을 제공하는 NeoForge 모드입니다.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 주요 기능
|
||||||
|
|
||||||
|
- 📍 **좌표 관리** - GUI 기반 좌표 저장 및 텔레포트
|
||||||
|
- 🏷️ **닉네임 시스템** - 플레이어 닉네임 설정
|
||||||
|
- 🔧 **서버 유틸리티** - 다양한 관리 명령어
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎮 명령어
|
||||||
|
|
||||||
|
| 명령어 | 설명 |
|
||||||
|
| ------------------ | -------------------- |
|
||||||
|
| `/좌표` | 저장된 좌표 목록 GUI |
|
||||||
|
| `/좌표추가 <이름>` | 현재 위치 저장 |
|
||||||
|
| `/좌표삭제 <이름>` | 저장된 좌표 삭제 |
|
||||||
|
| `/닉네임 <이름>` | 닉네임 설정 |
|
||||||
|
| `/닉네임초기화` | 닉네임 초기화 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
|
| 기술 | 설명 |
|
||||||
|
| -------------------- | --------------------- |
|
||||||
|
| **NeoForge** | Minecraft 모딩 플랫폼 |
|
||||||
|
| **Kotlin** | 주 개발 언어 |
|
||||||
|
| **Kotlin for Forge** | NeoForge Kotlin 지원 |
|
||||||
|
| **Java 21** | JVM 버전 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
Essentials/
|
||||||
|
├── src/main/
|
||||||
|
│ ├── kotlin/com/beemer/essentials/
|
||||||
|
│ │ ├── command/ # 커맨드 핸들러
|
||||||
|
│ │ ├── config/ # 설정 관리
|
||||||
|
│ │ ├── data/ # 데이터 저장
|
||||||
|
│ │ ├── gui/ # GUI 메뉴
|
||||||
|
│ │ └── util/ # 유틸리티
|
||||||
|
│ └── resources/
|
||||||
|
│ └── META-INF/ # 모드 메타데이터
|
||||||
|
└── build.gradle
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드된 JAR: `build/libs/essentials-1.0.0.jar`
|
||||||
176
Essentials/build.gradle
Normal file
176
Essentials/build.gradle
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
plugins {
|
||||||
|
id 'java-library'
|
||||||
|
id 'maven-publish'
|
||||||
|
id 'idea'
|
||||||
|
id 'net.neoforged.moddev' version '2.0.80'
|
||||||
|
id 'org.jetbrains.kotlin.jvm' version '2.0.0'
|
||||||
|
}
|
||||||
|
|
||||||
|
version = mod_version
|
||||||
|
group = mod_group_id
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenLocal()
|
||||||
|
maven {
|
||||||
|
name = 'Kotlin for Forge'
|
||||||
|
url = 'https://thedarkcolour.github.io/KotlinForForge/'
|
||||||
|
content { includeGroup "thedarkcolour" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base {
|
||||||
|
archivesName = mod_id
|
||||||
|
}
|
||||||
|
|
||||||
|
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
|
||||||
|
kotlin.jvmToolchain(21)
|
||||||
|
|
||||||
|
neoForge {
|
||||||
|
// Specify the version of NeoForge to use.
|
||||||
|
version = project.neo_version
|
||||||
|
|
||||||
|
parchment {
|
||||||
|
mappingsVersion = project.parchment_mappings_version
|
||||||
|
minecraftVersion = project.parchment_minecraft_version
|
||||||
|
}
|
||||||
|
|
||||||
|
// This line is optional. Access Transformers are automatically detected
|
||||||
|
// accessTransformers.add('src/main/resources/META-INF/accesstransformer.cfg')
|
||||||
|
|
||||||
|
// Default run configurations.
|
||||||
|
// These can be tweaked, removed, or duplicated as needed.
|
||||||
|
runs {
|
||||||
|
client {
|
||||||
|
client()
|
||||||
|
|
||||||
|
// Comma-separated list of namespaces to load gametests from. Empty = all namespaces.
|
||||||
|
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
server()
|
||||||
|
programArgument '--nogui'
|
||||||
|
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||||
|
}
|
||||||
|
|
||||||
|
// This run config launches GameTestServer and runs all registered gametests, then exits.
|
||||||
|
// By default, the server will crash when no gametests are provided.
|
||||||
|
// The gametest system is also enabled by default for other run configs under the /test command.
|
||||||
|
gameTestServer {
|
||||||
|
type = "gameTestServer"
|
||||||
|
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||||
|
}
|
||||||
|
|
||||||
|
data {
|
||||||
|
data()
|
||||||
|
|
||||||
|
// example of overriding the workingDirectory set in configureEach above, uncomment if you want to use it
|
||||||
|
// gameDirectory = project.file('run-data')
|
||||||
|
|
||||||
|
// Specify the modid for data generation, where to output the resulting resource, and where to look for existing resources.
|
||||||
|
programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath()
|
||||||
|
}
|
||||||
|
|
||||||
|
// applies to all the run configs above
|
||||||
|
configureEach {
|
||||||
|
// Recommended logging data for a userdev environment
|
||||||
|
// The markers can be added/remove as needed separated by commas.
|
||||||
|
// "SCAN": For mods scan.
|
||||||
|
// "REGISTRIES": For firing of registry events.
|
||||||
|
// "REGISTRYDUMP": For getting the contents of all registries.
|
||||||
|
systemProperty 'forge.logging.markers', 'REGISTRIES'
|
||||||
|
|
||||||
|
// Recommended logging level for the console
|
||||||
|
// You can set various levels here.
|
||||||
|
// Please read: https://stackoverflow.com/questions/2031163/when-to-use-the-different-log-levels
|
||||||
|
logLevel = org.slf4j.event.Level.DEBUG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mods {
|
||||||
|
// define mod <-> source bindings
|
||||||
|
// these are used to tell the game which sources are for which mod
|
||||||
|
// mostly optional in a single mod project
|
||||||
|
// but multi mod projects should define one per mod
|
||||||
|
"${mod_id}" {
|
||||||
|
sourceSet(sourceSets.main)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include resources generated by data generators.
|
||||||
|
sourceSets.main.resources { srcDir 'src/generated/resources' }
|
||||||
|
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
implementation 'thedarkcolour:kotlinforforge-neoforge:5.3.0'
|
||||||
|
|
||||||
|
// Example mod dependency with JEI
|
||||||
|
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
|
||||||
|
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
|
||||||
|
// compileOnly "mezz.jei:jei-${mc_version}-forge-api:${jei_version}"
|
||||||
|
// runtimeOnly "mezz.jei:jei-${mc_version}-forge:${jei_version}"
|
||||||
|
|
||||||
|
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||||
|
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||||
|
// The group id is ignored when searching -- in this case, it is "blank"
|
||||||
|
// implementation "blank:coolmod-${mc_version}:${coolmod_version}"
|
||||||
|
|
||||||
|
// Example mod dependency using a file as dependency
|
||||||
|
// implementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar")
|
||||||
|
|
||||||
|
// Example project dependency using a sister or child project:
|
||||||
|
// implementation project(":myproject")
|
||||||
|
|
||||||
|
// For more info:
|
||||||
|
// http://www.gradle.org/docs/current/userguide/artifact_dependencies_tutorial.html
|
||||||
|
// http://www.gradle.org/docs/current/userguide/dependency_management.html
|
||||||
|
}
|
||||||
|
|
||||||
|
// This block of code expands all declared replace properties in the specified resource targets.
|
||||||
|
// A missing property will result in an error. Properties are expanded using ${} Groovy notation.
|
||||||
|
var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
|
||||||
|
var replaceProperties = [minecraft_version : minecraft_version,
|
||||||
|
minecraft_version_range: minecraft_version_range,
|
||||||
|
neo_version : neo_version,
|
||||||
|
neo_version_range : neo_version_range,
|
||||||
|
loader_version_range : loader_version_range,
|
||||||
|
mod_id : mod_id,
|
||||||
|
mod_name : mod_name,
|
||||||
|
mod_license : mod_license,
|
||||||
|
mod_version : mod_version,
|
||||||
|
mod_authors : mod_authors,
|
||||||
|
mod_description : mod_description]
|
||||||
|
inputs.properties replaceProperties
|
||||||
|
expand replaceProperties
|
||||||
|
from "src/main/templates"
|
||||||
|
into "build/generated/sources/modMetadata"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include the output of "generateModMetadata" as an input directory for the build
|
||||||
|
// this works with both building through Gradle and the IDE.
|
||||||
|
sourceSets.main.resources.srcDir generateModMetadata
|
||||||
|
// To avoid having to run "generateModMetadata" manually, make it run on every project reload
|
||||||
|
neoForge.ideSyncTask generateModMetadata
|
||||||
|
|
||||||
|
// Example configuration to allow publishing using the maven-publish plugin
|
||||||
|
publishing {
|
||||||
|
publications {
|
||||||
|
register('mavenJava', MavenPublication) {
|
||||||
|
from components.java
|
||||||
|
}
|
||||||
|
}
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url "file://${project.projectDir}/repo"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IDEA no longer automatically downloads sources/javadoc jars for dependencies, so we need to explicitly enable the behavior.
|
||||||
|
idea {
|
||||||
|
module {
|
||||||
|
downloadSources = true
|
||||||
|
downloadJavadoc = true
|
||||||
|
}
|
||||||
|
}
|
||||||
40
Essentials/gradle.properties
Normal file
40
Essentials/gradle.properties
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
# Sets default memory used for gradle commands. Can be overridden by user or command line properties.
|
||||||
|
org.gradle.jvmargs=-Xmx2G
|
||||||
|
org.gradle.daemon=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
|
org.gradle.configuration-cache=true
|
||||||
|
## Environment Properties
|
||||||
|
# You can find the latest versions here: https://projects.neoforged.net/neoforged/neoforge
|
||||||
|
# The Minecraft version must agree with the Neo version to get a valid artifact
|
||||||
|
minecraft_version=1.21.1
|
||||||
|
# The Minecraft version range can use any release version of Minecraft as bounds.
|
||||||
|
# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly
|
||||||
|
# as they do not follow standard versioning conventions.
|
||||||
|
minecraft_version_range=[1.21.1,1.22)
|
||||||
|
# The Neo version must agree with the Minecraft version to get a valid artifact
|
||||||
|
neo_version=21.1.194
|
||||||
|
# The Neo version range can use any version of Neo as bounds
|
||||||
|
neo_version_range=[21,)
|
||||||
|
# The loader version range can only use the major version of FML as bounds
|
||||||
|
loader_version_range=[5.3,)
|
||||||
|
parchment_minecraft_version=1.21.1
|
||||||
|
parchment_mappings_version=2024.11.17
|
||||||
|
## Mod Properties
|
||||||
|
# The unique mod identifier for the mod. Must be lowercase in English locale. Must fit the regex [a-z][a-z0-9_]{1,63}
|
||||||
|
# Must match the String constant located in the main mod class annotated with @Mod.
|
||||||
|
mod_id=essentials
|
||||||
|
# The human-readable display name for the mod.
|
||||||
|
mod_name=Essentials
|
||||||
|
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||||
|
mod_license=MIT
|
||||||
|
# The mod version. See https://semver.org/
|
||||||
|
mod_version=1.0.0
|
||||||
|
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||||
|
# This should match the base package used for the mod sources.
|
||||||
|
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
||||||
|
mod_group_id=com.beemer
|
||||||
|
# The authors of the mod. This is a simple text string that is used for display purposes in the mod list.
|
||||||
|
mod_authors=
|
||||||
|
# The description of the mod. This is a simple multiline text string that is used for display purposes in the mod list.
|
||||||
|
mod_description=
|
||||||
BIN
Essentials/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
Essentials/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
Essentials/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
Essentials/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.8-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
249
Essentials/gradlew
vendored
Executable file
249
Essentials/gradlew
vendored
Executable file
|
|
@ -0,0 +1,249 @@
|
||||||
|
#!/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/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 "${APP_HOME:-./}" > /dev/null && pwd -P ) || 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=$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
|
||||||
|
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, 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" \
|
||||||
|
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" "$@"
|
||||||
92
Essentials/gradlew.bat
vendored
Normal file
92
Essentials/gradlew.bat
vendored
Normal file
|
|
@ -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. 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=%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
|
||||||
11
Essentials/settings.gradle
Normal file
11
Essentials/settings.gradle
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
mavenLocal()
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven { url = 'https://maven.neoforged.net/releases' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.AntimobConfig;
|
||||||
|
import net.minecraft.world.entity.EntityType;
|
||||||
|
import net.minecraft.world.entity.PowerableMob;
|
||||||
|
import net.minecraft.world.entity.monster.Creeper;
|
||||||
|
import net.minecraft.world.entity.monster.Monster;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||||
|
|
||||||
|
@Mixin(Creeper.class)
|
||||||
|
public abstract class CreeperMixin extends Monster implements PowerableMob {
|
||||||
|
|
||||||
|
protected CreeperMixin(EntityType<? extends Monster> entityType, Level level) {
|
||||||
|
super(entityType, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModifyArg(
|
||||||
|
method = "explodeCreeper",
|
||||||
|
at = @At(
|
||||||
|
value = "INVOKE",
|
||||||
|
target = "Lnet/minecraft/world/level/Level;explode(Lnet/minecraft/world/entity/Entity;DDDFLnet/minecraft/world/level/Level$ExplosionInteraction;)Lnet/minecraft/world/level/Explosion;"
|
||||||
|
),
|
||||||
|
index = 5
|
||||||
|
)
|
||||||
|
private Level.ExplosionInteraction replaceInteraction(Level.ExplosionInteraction original) {
|
||||||
|
if (AntimobConfig.get("크리퍼")) {
|
||||||
|
return Level.ExplosionInteraction.NONE;
|
||||||
|
}
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.AntimobConfig;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
@Mixin(targets = "net.minecraft.world.entity.monster.EnderMan$EndermanTakeBlockGoal")
|
||||||
|
public abstract class EndermanTakeBlockGoalMixin {
|
||||||
|
|
||||||
|
@Inject(method = "canUse", at = @At("HEAD"), cancellable = true)
|
||||||
|
private void onCanUse(CallbackInfoReturnable<Boolean> cir) {
|
||||||
|
if (AntimobConfig.get("엔더맨")) {
|
||||||
|
cir.setReturnValue(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,24 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.ProtectFarmlandConfig;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
|
import net.minecraft.world.entity.Entity;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import net.minecraft.world.level.block.FarmBlock;
|
||||||
|
import net.minecraft.world.level.block.state.BlockState;
|
||||||
|
import net.neoforged.neoforge.common.CommonHooks;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Redirect;
|
||||||
|
|
||||||
|
@Mixin(FarmBlock.class)
|
||||||
|
public class FarmBlockMixin {
|
||||||
|
|
||||||
|
@Redirect(method = "fallOn", at = @At(value = "INVOKE", target = "Lnet/neoforged/neoforge/common/CommonHooks;onFarmlandTrample(Lnet/minecraft/world/level/Level;Lnet/minecraft/core/BlockPos;Lnet/minecraft/world/level/block/state/BlockState;FLnet/minecraft/world/entity/Entity;)Z"))
|
||||||
|
private boolean redirectOnFarmlandTrample(Level level, BlockPos pos, BlockState dirtState, float fallDistance, Entity entity) {
|
||||||
|
if (ProtectFarmlandConfig.isEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return CommonHooks.onFarmlandTrample(level, pos, dirtState, fallDistance, entity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.AntimobConfig;
|
||||||
|
import net.minecraft.world.entity.projectile.LargeFireball;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.ModifyArg;
|
||||||
|
|
||||||
|
@Mixin(LargeFireball.class)
|
||||||
|
public abstract class LargeFireballMixin {
|
||||||
|
|
||||||
|
@ModifyArg(
|
||||||
|
method = "onHit(Lnet/minecraft/world/phys/HitResult;)V",
|
||||||
|
at = @At(
|
||||||
|
value = "INVOKE",
|
||||||
|
target = "Lnet/minecraft/world/level/Level;explode(Lnet/minecraft/world/entity/Entity;DDDFZLnet/minecraft/world/level/Level$ExplosionInteraction;)Lnet/minecraft/world/level/Explosion;"
|
||||||
|
),
|
||||||
|
index = 5
|
||||||
|
)
|
||||||
|
private boolean disableFire(boolean original) {
|
||||||
|
if (AntimobConfig.get("가스트")) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ModifyArg(
|
||||||
|
method = "onHit(Lnet/minecraft/world/phys/HitResult;)V",
|
||||||
|
at = @At(
|
||||||
|
value = "INVOKE",
|
||||||
|
target = "Lnet/minecraft/world/level/Level;explode(Lnet/minecraft/world/entity/Entity;DDDFZLnet/minecraft/world/level/Level$ExplosionInteraction;)Lnet/minecraft/world/level/Explosion;"
|
||||||
|
),
|
||||||
|
index = 6
|
||||||
|
)
|
||||||
|
private Level.ExplosionInteraction preventBlockBreak(Level.ExplosionInteraction original) {
|
||||||
|
if (AntimobConfig.get("가스트")) {
|
||||||
|
return Level.ExplosionInteraction.NONE;
|
||||||
|
}
|
||||||
|
return original;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore;
|
||||||
|
import com.mojang.authlib.GameProfile;
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket;
|
||||||
|
import org.spongepowered.asm.mixin.Final;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.Mutable;
|
||||||
|
import org.spongepowered.asm.mixin.Shadow;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ClientboundPlayerInfoUpdatePacket 믹스인
|
||||||
|
* 패킷 전송 시 GameProfile 이름을 커스텀 닉네임으로 변경
|
||||||
|
*/
|
||||||
|
@Mixin(ClientboundPlayerInfoUpdatePacket.class)
|
||||||
|
public class PlayerInfoPacketMixin {
|
||||||
|
|
||||||
|
@Shadow
|
||||||
|
@Final
|
||||||
|
@Mutable
|
||||||
|
private List<ClientboundPlayerInfoUpdatePacket.Entry> entries;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* write 메서드 전에 entries 수정
|
||||||
|
*/
|
||||||
|
@Inject(method = "write", at = @At("HEAD"))
|
||||||
|
private void onWrite(CallbackInfo ci) {
|
||||||
|
modifyEntries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* entries 리스트 수정
|
||||||
|
*/
|
||||||
|
private void modifyEntries() {
|
||||||
|
if (this.entries == null || this.entries.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<ClientboundPlayerInfoUpdatePacket.Entry> modifiedEntries = new ArrayList<>();
|
||||||
|
boolean modified = false;
|
||||||
|
|
||||||
|
for (ClientboundPlayerInfoUpdatePacket.Entry entry : this.entries) {
|
||||||
|
UUID playerUuid = entry.profileId();
|
||||||
|
String customNickname = NicknameDataStore.getNickname(playerUuid);
|
||||||
|
|
||||||
|
if (customNickname != null && entry.profile() != null) {
|
||||||
|
// 새 GameProfile 생성 (UUID 유지, 이름만 변경)
|
||||||
|
GameProfile originalProfile = entry.profile();
|
||||||
|
GameProfile newProfile = new GameProfile(playerUuid, customNickname);
|
||||||
|
|
||||||
|
// 스킨 텍스처 복사
|
||||||
|
newProfile.getProperties().putAll(originalProfile.getProperties());
|
||||||
|
|
||||||
|
// 새 Entry 생성
|
||||||
|
ClientboundPlayerInfoUpdatePacket.Entry newEntry = new ClientboundPlayerInfoUpdatePacket.Entry(
|
||||||
|
playerUuid,
|
||||||
|
newProfile,
|
||||||
|
entry.listed(),
|
||||||
|
entry.latency(),
|
||||||
|
entry.gameMode(),
|
||||||
|
entry.displayName(),
|
||||||
|
entry.chatSession());
|
||||||
|
modifiedEntries.add(newEntry);
|
||||||
|
modified = true;
|
||||||
|
} else {
|
||||||
|
modifiedEntries.add(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
this.entries = modifiedEntries;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import net.minecraft.network.Connection;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import net.minecraft.server.network.CommonListenerCookie;
|
||||||
|
import net.minecraft.server.players.PlayerList;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
@Mixin(PlayerList.class)
|
||||||
|
public class PlayerListMixin {
|
||||||
|
|
||||||
|
@Inject(method = "broadcastSystemMessage(Lnet/minecraft/network/chat/Component;Z)V", at = @At("HEAD"), cancellable = true)
|
||||||
|
private void suppressJoinQuit(Component message, boolean overlay, CallbackInfo ci) {
|
||||||
|
String exceptMessage = message.getString();
|
||||||
|
|
||||||
|
if (exceptMessage.contains("joined the game") || exceptMessage.contains("left the game")) {
|
||||||
|
ci.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(method = "placeNewPlayer", at = @At("TAIL"))
|
||||||
|
private void onPlaceNewPlayer(Connection connection, ServerPlayer player, CommonListenerCookie cookie, CallbackInfo ci) {
|
||||||
|
player.refreshDisplayName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore;
|
||||||
|
import net.minecraft.network.chat.Component;
|
||||||
|
import net.minecraft.world.entity.EntityType;
|
||||||
|
import net.minecraft.world.entity.LivingEntity;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player 믹스인
|
||||||
|
* getName()과 getDisplayName()을 후킹하여 커스텀 닉네임 반환
|
||||||
|
*/
|
||||||
|
@Mixin(Player.class)
|
||||||
|
public abstract class PlayerMixin extends LivingEntity {
|
||||||
|
protected PlayerMixin(EntityType<? extends LivingEntity> entityType, Level level) {
|
||||||
|
super(entityType, level);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(at = @At("HEAD"), method = "getName", cancellable = true)
|
||||||
|
private void onGetName(CallbackInfoReturnable<Component> cir) {
|
||||||
|
if (!this.level().isClientSide()) {
|
||||||
|
String nickname = NicknameDataStore.getNickname(this.getUUID());
|
||||||
|
if (nickname != null && !nickname.isEmpty()) {
|
||||||
|
cir.setReturnValue(Component.literal(nickname));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject(at = @At("HEAD"), method = "getDisplayName", cancellable = true)
|
||||||
|
private void onGetDisplayName(CallbackInfoReturnable<Component> cir) {
|
||||||
|
if (!this.level().isClientSide()) {
|
||||||
|
String nickname = NicknameDataStore.getNickname(this.getUUID());
|
||||||
|
if (nickname != null && !nickname.isEmpty()) {
|
||||||
|
cir.setReturnValue(Component.literal(nickname));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
package com.beemer.essentials.mixin;
|
||||||
|
|
||||||
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
|
import org.spongepowered.asm.mixin.Mixin;
|
||||||
|
import org.spongepowered.asm.mixin.injection.At;
|
||||||
|
import org.spongepowered.asm.mixin.injection.Inject;
|
||||||
|
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
||||||
|
|
||||||
|
@Mixin(ServerPlayer.class)
|
||||||
|
public class ServerPlayerMixin {
|
||||||
|
@Inject(method = "restoreFrom(Lnet/minecraft/server/level/ServerPlayer;Z)V", at = @At("TAIL"))
|
||||||
|
private void onRestoreFrom(ServerPlayer oldPlayer, boolean keepEverything, CallbackInfo ci) {
|
||||||
|
|
||||||
|
// 이 Mixin이 주입된 'this' 객체가 바로 리스폰/차원 이동으로
|
||||||
|
// 생성된 '새로운' ServerPlayer 객체입니다.
|
||||||
|
ServerPlayer newPlayer = (ServerPlayer) (Object) this;
|
||||||
|
|
||||||
|
// 이전 플레이어의 데이터가 모두 복사된 직후(TAIL),
|
||||||
|
// 닉네임을 새로고침합니다.
|
||||||
|
newPlayer.refreshDisplayName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
package com.beemer.essentials
|
||||||
|
|
||||||
|
import com.beemer.essentials.command.*
|
||||||
|
import com.beemer.essentials.event.ChatEvents
|
||||||
|
import com.beemer.essentials.event.ModEvents
|
||||||
|
import com.beemer.essentials.event.PlayerEvents
|
||||||
|
import com.beemer.essentials.event.ServerEvents
|
||||||
|
import com.beemer.essentials.nickname.NicknameCommand
|
||||||
|
import net.neoforged.bus.api.IEventBus
|
||||||
|
import net.neoforged.fml.common.Mod
|
||||||
|
import net.neoforged.neoforge.common.NeoForge
|
||||||
|
|
||||||
|
@Mod(Essentials.ID)
|
||||||
|
class Essentials(modEventBus: IEventBus) {
|
||||||
|
companion object {
|
||||||
|
const val ID = "essentials"
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
modEventBus.addListener(ModEvents::onServerSetup)
|
||||||
|
|
||||||
|
NeoForge.EVENT_BUS.register(ServerEvents)
|
||||||
|
NeoForge.EVENT_BUS.register(PlayerEvents)
|
||||||
|
NeoForge.EVENT_BUS.register(ChatEvents)
|
||||||
|
|
||||||
|
NeoForge.EVENT_BUS.register(SpawnCommand)
|
||||||
|
NeoForge.EVENT_BUS.register(PlayerCommand)
|
||||||
|
NeoForge.EVENT_BUS.register(TeleportCommand)
|
||||||
|
NeoForge.EVENT_BUS.register(ChatCommand)
|
||||||
|
NeoForge.EVENT_BUS.register(ProtectFarmlandCommand)
|
||||||
|
NeoForge.EVENT_BUS.register(AntimobCommand)
|
||||||
|
NeoForge.EVENT_BUS.register(CoordinateCommand)
|
||||||
|
NeoForge.EVENT_BUS.register(NicknameCommand)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
||||||
|
package com.beemer.essentials.command
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.AntimobConfig
|
||||||
|
import com.beemer.essentials.gui.AntimobGui
|
||||||
|
import com.beemer.essentials.util.CommandUtils
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.world.SimpleContainer
|
||||||
|
import net.minecraft.world.SimpleMenuProvider
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
object AntimobCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
listOf("antimob", "안티몹").forEach { command ->
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(command).executes { context ->
|
||||||
|
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||||
|
|
||||||
|
AntimobConfig.loadConfig()
|
||||||
|
|
||||||
|
val container = SimpleContainer(9 * 2)
|
||||||
|
|
||||||
|
player.openMenu(
|
||||||
|
SimpleMenuProvider({ windowId, inv, _ ->
|
||||||
|
AntimobGui(
|
||||||
|
windowId,
|
||||||
|
inv,
|
||||||
|
container,
|
||||||
|
player
|
||||||
|
)
|
||||||
|
}, Component.literal("안티몹 설정").withStyle { it.withColor(ChatFormatting.DARK_GRAY) })
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,90 @@
|
||||||
|
package com.beemer.essentials.command
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.ChatConfig
|
||||||
|
import com.beemer.essentials.util.ChatUtils
|
||||||
|
import com.mojang.brigadier.exceptions.CommandSyntaxException
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.CommandSourceStack
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
object ChatCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
registerReloadCommand("chat", "reload", event)
|
||||||
|
registerReloadCommand("채팅", "새로고침", event)
|
||||||
|
|
||||||
|
registerClearCommand("chat", "clear", event)
|
||||||
|
registerClearCommand("채팅", "비우기", event)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getPlayerOrNull(source: CommandSourceStack): ServerPlayer? {
|
||||||
|
return try {
|
||||||
|
source.player
|
||||||
|
} catch (_: CommandSyntaxException) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hasPermissionOrSend(player: ServerPlayer?, requiredLevel: Int = 2): Boolean {
|
||||||
|
if (player != null && !player.hasPermissions(requiredLevel)) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("해당 명령어를 실행할 권한이 없습니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||||
|
)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerReloadCommand(root: String, sub: String, event: RegisterCommandsEvent) {
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(root).then(
|
||||||
|
Commands.literal(sub).executes { context ->
|
||||||
|
val player = getPlayerOrNull(context.source)
|
||||||
|
if (!hasPermissionOrSend(player)) return@executes 0
|
||||||
|
|
||||||
|
ChatConfig.loadConfig()
|
||||||
|
|
||||||
|
val success = Component.literal("채팅 형식을 새로고침했습니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||||
|
|
||||||
|
if (player == null)
|
||||||
|
context.source.sendSuccess({ success }, false)
|
||||||
|
else
|
||||||
|
player.sendSystemMessage(success)
|
||||||
|
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun registerClearCommand(root: String, sub: String, event: RegisterCommandsEvent) {
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(root).then(
|
||||||
|
Commands.literal(sub).executes { context ->
|
||||||
|
val player = getPlayerOrNull(context.source)
|
||||||
|
if (!hasPermissionOrSend(player)) return@executes 0
|
||||||
|
|
||||||
|
val server = context.source.server
|
||||||
|
val allPlayers = server.playerList.players.filterIsInstance<ServerPlayer>()
|
||||||
|
ChatUtils.clearChatForAll(allPlayers)
|
||||||
|
|
||||||
|
server.playerList.broadcastSystemMessage(
|
||||||
|
Component.literal("📢")
|
||||||
|
.append(Component.literal("\u00A0\u00A0채팅창을 비웠습니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.AQUA) }
|
||||||
|
),
|
||||||
|
false
|
||||||
|
)
|
||||||
|
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,254 @@
|
||||||
|
package com.beemer.essentials.command
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.CoordinateConfig
|
||||||
|
import com.beemer.essentials.config.PlayerConfig
|
||||||
|
import com.beemer.essentials.data.Coordinate
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import com.beemer.essentials.gui.Menu
|
||||||
|
import com.beemer.essentials.gui.createPageContainer
|
||||||
|
import com.beemer.essentials.util.DimensionUtils
|
||||||
|
import com.mojang.brigadier.arguments.StringArgumentType
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.minecraft.world.SimpleMenuProvider
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
object CoordinateCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
// /좌표 - GUI 열기 (페이지 0부터 시작)
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal("좌표").executes { context ->
|
||||||
|
val player = context.source.playerOrException as ServerPlayer
|
||||||
|
openCoordinateGui(player, 0)
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// /좌표이동 <장소> - 바로 이동
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal("좌표이동")
|
||||||
|
.then(
|
||||||
|
Commands.argument("장소", StringArgumentType.greedyString())
|
||||||
|
.suggests { _, builder ->
|
||||||
|
CoordinateConfig.getCoordinates().forEach {
|
||||||
|
builder.suggest(it.name)
|
||||||
|
}
|
||||||
|
builder.buildFuture()
|
||||||
|
}
|
||||||
|
.executes { context ->
|
||||||
|
val player =
|
||||||
|
context.source.playerOrException as
|
||||||
|
ServerPlayer
|
||||||
|
val name =
|
||||||
|
StringArgumentType.getString(
|
||||||
|
context,
|
||||||
|
"장소"
|
||||||
|
)
|
||||||
|
teleportToCoordinate(player, name)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// /좌표추가 <이름>
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal("좌표추가")
|
||||||
|
.then(
|
||||||
|
Commands.argument("이름", StringArgumentType.greedyString())
|
||||||
|
.executes { context ->
|
||||||
|
val player =
|
||||||
|
context.source.entity as?
|
||||||
|
ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
val name =
|
||||||
|
StringArgumentType.getString(
|
||||||
|
context,
|
||||||
|
"이름"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (CoordinateConfig.isExist(name)) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(
|
||||||
|
"$name 좌표가 이미 존재합니다."
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(
|
||||||
|
ChatFormatting
|
||||||
|
.RED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return@executes 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val exactPos = player.position()
|
||||||
|
val blockPos = player.blockPosition()
|
||||||
|
val level = player.level()
|
||||||
|
|
||||||
|
val coordinate =
|
||||||
|
Coordinate(
|
||||||
|
name = name,
|
||||||
|
dimension =
|
||||||
|
level.dimension()
|
||||||
|
.location()
|
||||||
|
.toString(),
|
||||||
|
biome =
|
||||||
|
level.getBiome(
|
||||||
|
blockPos
|
||||||
|
)
|
||||||
|
.unwrapKey()
|
||||||
|
.map {
|
||||||
|
it.location()
|
||||||
|
.toString()
|
||||||
|
}
|
||||||
|
.orElse(
|
||||||
|
"알 수 없음"
|
||||||
|
),
|
||||||
|
x = exactPos.x,
|
||||||
|
y = exactPos.y,
|
||||||
|
z = exactPos.z,
|
||||||
|
creatorUuid =
|
||||||
|
player.uuid
|
||||||
|
.toString()
|
||||||
|
)
|
||||||
|
|
||||||
|
CoordinateConfig.addCoordinate(coordinate)
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(
|
||||||
|
"$name 좌표를 추가했습니다."
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(
|
||||||
|
ChatFormatting
|
||||||
|
.GOLD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// /좌표제거 <이름>
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal("좌표제거")
|
||||||
|
.then(
|
||||||
|
Commands.argument("이름", StringArgumentType.greedyString())
|
||||||
|
.suggests { _, builder ->
|
||||||
|
CoordinateConfig.getCoordinates().forEach {
|
||||||
|
builder.suggest(it.name)
|
||||||
|
}
|
||||||
|
builder.buildFuture()
|
||||||
|
}
|
||||||
|
.executes { context ->
|
||||||
|
val player =
|
||||||
|
context.source.entity as?
|
||||||
|
ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
val name =
|
||||||
|
StringArgumentType.getString(
|
||||||
|
context,
|
||||||
|
"이름"
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!CoordinateConfig.isExist(name)) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(
|
||||||
|
"$name 좌표가 존재하지 않습니다."
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(
|
||||||
|
ChatFormatting
|
||||||
|
.RED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return@executes 0
|
||||||
|
}
|
||||||
|
|
||||||
|
CoordinateConfig.removeCoordinate(name)
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(
|
||||||
|
"$name 좌표를 제거했습니다."
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(
|
||||||
|
ChatFormatting
|
||||||
|
.GOLD
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openCoordinateGui(player: ServerPlayer, page: Int) {
|
||||||
|
val coordinates = CoordinateConfig.getCoordinates()
|
||||||
|
val totalPages =
|
||||||
|
if (coordinates.isEmpty()) 1
|
||||||
|
else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE
|
||||||
|
val container = createPageContainer(player, page)
|
||||||
|
|
||||||
|
player.openMenu(
|
||||||
|
SimpleMenuProvider(
|
||||||
|
{ windowId, inv, _ -> Menu(windowId, inv, container, page) },
|
||||||
|
Component.literal("저장된 좌표 (${page + 1}/$totalPages)").withStyle {
|
||||||
|
it.withBold(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun teleportToCoordinate(player: ServerPlayer, name: String): Int {
|
||||||
|
val coord = CoordinateConfig.getCoordinate(name)
|
||||||
|
if (coord == null) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("$name 좌표가 존재하지 않습니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.RED)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val level = DimensionUtils.getLevelById(player.server, coord.dimension)
|
||||||
|
if (level == null) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("존재하지 않는 차원입니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.RED)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이전 위치 저장
|
||||||
|
val prevPos = player.position()
|
||||||
|
PlayerConfig.recordLastLocation(
|
||||||
|
player,
|
||||||
|
Location(
|
||||||
|
dimension = player.level().dimension().location().toString(),
|
||||||
|
biome =
|
||||||
|
player.level()
|
||||||
|
.getBiome(player.blockPosition())
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains"),
|
||||||
|
x = prevPos.x,
|
||||||
|
y = prevPos.y,
|
||||||
|
z = prevPos.z
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
player.teleportTo(level, coord.x, coord.y, coord.z, player.yRot, player.xRot)
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("$name (으)로 이동했습니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.GOLD)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,78 @@
|
||||||
|
package com.beemer.essentials.command
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.PlayerConfig
|
||||||
|
import com.beemer.essentials.util.CommandUtils
|
||||||
|
import com.beemer.essentials.util.DimensionUtils
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
object PlayerCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
listOf("back", "백").forEach { command ->
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(command).executes { context ->
|
||||||
|
val player =
|
||||||
|
CommandUtils.getPlayerOrSendFailure(context.source)
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
val info = PlayerConfig.getPlayer(player)
|
||||||
|
val lastLocation =
|
||||||
|
info?.lastLocation
|
||||||
|
?: run {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(
|
||||||
|
"이전 위치가 존재하지 않습니다."
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(
|
||||||
|
ChatFormatting
|
||||||
|
.RED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return@executes 0
|
||||||
|
}
|
||||||
|
|
||||||
|
val level =
|
||||||
|
DimensionUtils.getLevelById(
|
||||||
|
player.server,
|
||||||
|
lastLocation.dimension
|
||||||
|
)
|
||||||
|
?: run {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(
|
||||||
|
"존재하지 않는 차원입니다."
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(
|
||||||
|
ChatFormatting
|
||||||
|
.RED
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return@executes 0
|
||||||
|
}
|
||||||
|
|
||||||
|
player.teleportTo(
|
||||||
|
level,
|
||||||
|
lastLocation.x,
|
||||||
|
lastLocation.y,
|
||||||
|
lastLocation.z,
|
||||||
|
player.yRot,
|
||||||
|
player.xRot
|
||||||
|
)
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("이전 위치로 이동했습니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.GOLD)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.beemer.essentials.command
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.ProtectFarmlandConfig
|
||||||
|
import com.beemer.essentials.util.CommandUtils
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
object ProtectFarmlandCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
listOf("protectfarmland", "밭보호").forEach { command ->
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(command).executes { context ->
|
||||||
|
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||||
|
|
||||||
|
val enabled = ProtectFarmlandConfig.toggle()
|
||||||
|
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("밭 보호를 ").withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||||
|
.append(Component.literal(if (enabled) "활성화" else "비활성화").withStyle { it.withColor(ChatFormatting.DARK_GREEN) })
|
||||||
|
.append(Component.literal("했습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
|
||||||
|
)
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,93 @@
|
||||||
|
package com.beemer.essentials.command
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.PlayerConfig
|
||||||
|
import com.beemer.essentials.config.SpawnConfig
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import com.beemer.essentials.util.CommandUtils
|
||||||
|
import com.beemer.essentials.util.DimensionUtils
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
object SpawnCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
listOf("setspawn", "스폰설정").forEach { command ->
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(command).executes { context ->
|
||||||
|
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||||
|
|
||||||
|
val playerLocation = player.blockPosition()
|
||||||
|
val dimensionId = player.level().dimension().location().toString()
|
||||||
|
val biomeId = player.level().getBiome(playerLocation)
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains")
|
||||||
|
|
||||||
|
val location = Location(
|
||||||
|
dimension = dimensionId,
|
||||||
|
biome = biomeId,
|
||||||
|
x = playerLocation.x.toDouble(),
|
||||||
|
y = playerLocation.y.toDouble(),
|
||||||
|
z = playerLocation.z.toDouble()
|
||||||
|
)
|
||||||
|
|
||||||
|
SpawnConfig.setCustomSpawn(location)
|
||||||
|
|
||||||
|
player.sendSystemMessage(Component.literal("스폰 지점이 현재 위치로 설정되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf("spawn", "스폰", "넴주").forEach { command ->
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(command).executes { context ->
|
||||||
|
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||||
|
|
||||||
|
val target = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn()
|
||||||
|
|
||||||
|
target?.let { t ->
|
||||||
|
val level = DimensionUtils.getLevelById(player.server, t.dimension)
|
||||||
|
|
||||||
|
level?.let { l ->
|
||||||
|
val currentPos = player.blockPosition()
|
||||||
|
val currentDimension = player.level().dimension().location().toString()
|
||||||
|
val currentBiome = player.level().getBiome(currentPos)
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains")
|
||||||
|
|
||||||
|
val currentLocation = Location(
|
||||||
|
dimension = currentDimension,
|
||||||
|
biome = currentBiome,
|
||||||
|
x = currentPos.x.toDouble(),
|
||||||
|
y = currentPos.y.toDouble(),
|
||||||
|
z = currentPos.z.toDouble()
|
||||||
|
)
|
||||||
|
|
||||||
|
PlayerConfig.recordLastLocation(player, currentLocation)
|
||||||
|
player.teleportTo(l, t.x, t.y, t.z, player.yRot, player.xRot)
|
||||||
|
player.sendSystemMessage(Component.literal("스폰으로 이동했습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
listOf("delspawn", "스폰삭제").forEach { command ->
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(command).executes { context ->
|
||||||
|
val player = CommandUtils.getPlayerOrSendFailure(context.source) ?: return@executes 0
|
||||||
|
|
||||||
|
SpawnConfig.removeCustomSpawn()
|
||||||
|
player.sendSystemMessage(Component.literal("스폰 지점이 기본 스폰 지점으로 변경되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,147 @@
|
||||||
|
package com.beemer.essentials.command
|
||||||
|
|
||||||
|
import com.beemer.essentials.gui.TeleportGui
|
||||||
|
import com.beemer.essentials.gui.TeleportGui.Companion.CONTAINER_SIZE
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore
|
||||||
|
import com.beemer.essentials.util.CommandUtils
|
||||||
|
import com.beemer.essentials.util.TranslationUtils
|
||||||
|
import com.mojang.authlib.GameProfile
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.core.component.DataComponents
|
||||||
|
import net.minecraft.nbt.CompoundTag
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.minecraft.world.SimpleContainer
|
||||||
|
import net.minecraft.world.SimpleMenuProvider
|
||||||
|
import net.minecraft.world.item.ItemStack
|
||||||
|
import net.minecraft.world.item.Items
|
||||||
|
import net.minecraft.world.item.component.CustomData
|
||||||
|
import net.minecraft.world.item.component.ItemLore
|
||||||
|
import net.minecraft.world.item.component.ResolvableProfile
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
object TeleportCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
listOf("tpa", "텔레포트", "텔포").forEach { command ->
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal(command).executes { context ->
|
||||||
|
val player =
|
||||||
|
CommandUtils.getPlayerOrSendFailure(context.source)
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
val container = SimpleContainer(9 * 3)
|
||||||
|
|
||||||
|
val targetPlayers =
|
||||||
|
player.server.playerList.players.filter {
|
||||||
|
it != player
|
||||||
|
}
|
||||||
|
|
||||||
|
targetPlayers.take(CONTAINER_SIZE).forEachIndexed {
|
||||||
|
idx,
|
||||||
|
target ->
|
||||||
|
val head = makePlayerHead(target)
|
||||||
|
container.setItem(idx, head)
|
||||||
|
}
|
||||||
|
|
||||||
|
player.openMenu(
|
||||||
|
SimpleMenuProvider(
|
||||||
|
{ windowId, inv, _ ->
|
||||||
|
TeleportGui(
|
||||||
|
windowId,
|
||||||
|
inv,
|
||||||
|
container,
|
||||||
|
player
|
||||||
|
)
|
||||||
|
},
|
||||||
|
Component.literal("텔레포트 대상 선택").withStyle {
|
||||||
|
it.withColor(
|
||||||
|
ChatFormatting.DARK_GRAY
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
1
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makePlayerHead(player: ServerPlayer): ItemStack {
|
||||||
|
val headItem = ItemStack(Items.PLAYER_HEAD)
|
||||||
|
val nbtData = CompoundTag()
|
||||||
|
|
||||||
|
// 플레이어의 기존 프로필 사용 (fetchProfile 호출 제거로 속도 개선)
|
||||||
|
val resolvableProfile =
|
||||||
|
try {
|
||||||
|
ResolvableProfile(player.gameProfile)
|
||||||
|
} catch (e: NoSuchMethodError) {
|
||||||
|
val ctor =
|
||||||
|
ResolvableProfile::class.java.getDeclaredConstructor(
|
||||||
|
GameProfile::class.java
|
||||||
|
)
|
||||||
|
ctor.isAccessible = true
|
||||||
|
ctor.newInstance(player.gameProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
val x = player.x.toInt()
|
||||||
|
val y = player.y.toInt()
|
||||||
|
val z = player.z.toInt()
|
||||||
|
|
||||||
|
val dimension = player.level().dimension().location().toString()
|
||||||
|
val biome =
|
||||||
|
player.level()
|
||||||
|
.getBiome(player.blockPosition())
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains")
|
||||||
|
|
||||||
|
val loreList =
|
||||||
|
listOf(
|
||||||
|
Component.literal("디멘션: ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||||
|
.append(
|
||||||
|
Component.literal(
|
||||||
|
TranslationUtils.translateDimension(
|
||||||
|
dimension
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(ChatFormatting.GRAY)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Component.literal("바이옴: ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||||
|
.append(
|
||||||
|
Component.literal(
|
||||||
|
TranslationUtils.translateBiome(
|
||||||
|
biome
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.withStyle {
|
||||||
|
it.withColor(ChatFormatting.GRAY)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Component.literal("좌표: ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||||
|
.append(
|
||||||
|
Component.literal("$x, $y, $z").withStyle {
|
||||||
|
it.withColor(ChatFormatting.GRAY)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 닉네임이 있으면 닉네임 사용
|
||||||
|
val displayName =
|
||||||
|
NicknameDataStore.getNickname(player.uuid) ?: player.gameProfile.name
|
||||||
|
headItem.set(DataComponents.CUSTOM_NAME, Component.literal(displayName))
|
||||||
|
headItem.set(DataComponents.LORE, ItemLore(loreList))
|
||||||
|
headItem.set(DataComponents.CUSTOM_DATA, CustomData.of(nbtData))
|
||||||
|
headItem.set(DataComponents.PROFILE, resolvableProfile)
|
||||||
|
|
||||||
|
return headItem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,84 @@
|
||||||
|
package com.beemer.essentials.config
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
import java.io.IOException
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.nio.file.StandardOpenOption
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
|
||||||
|
object AntimobConfig {
|
||||||
|
private const val MOD_ID = "essentials"
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||||
|
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||||
|
private val FILE_DIR: Path = CONFIG_DIR.resolve("antimob.json")
|
||||||
|
|
||||||
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
|
||||||
|
private val mobs = mapOf(
|
||||||
|
"엔더맨" to false,
|
||||||
|
"크리퍼" to false,
|
||||||
|
"가스트" to false
|
||||||
|
)
|
||||||
|
|
||||||
|
private val map: MutableMap<String, Boolean> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
if (Files.exists(FILE_DIR)) {
|
||||||
|
val json = Files.readString(FILE_DIR)
|
||||||
|
val type = object : TypeToken<Map<String, Boolean>>() {}.type
|
||||||
|
val parsed: Map<String, Boolean> = try {
|
||||||
|
gson.fromJson(json, type) ?: emptyMap()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.warn("[Essentials] antimob.json 파싱 실패, 기본값으로 초기화합니다.", e)
|
||||||
|
emptyMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
map.clear()
|
||||||
|
mobs.forEach { (k, v) ->
|
||||||
|
map[k] = parsed[k] ?: v
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
map.clear()
|
||||||
|
map.putAll(mobs)
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
} catch (e: IOException) {
|
||||||
|
LOGGER.error("[Essentials] antimob 설정 불러오는 중 오류", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
val toWrite = LinkedHashMap<String, Boolean>()
|
||||||
|
|
||||||
|
mobs.keys.forEach { key ->
|
||||||
|
toWrite[key] = map.getOrDefault(key, mobs[key] ?: false)
|
||||||
|
}
|
||||||
|
val json = gson.toJson(toWrite)
|
||||||
|
Files.writeString(FILE_DIR, json)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
LOGGER.error("[Essentials] antimob 설정 저장 실패", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun get(mob: String): Boolean = map.getOrDefault(mob, mobs[mob] ?: false)
|
||||||
|
|
||||||
|
fun toggle(mob: String): Boolean {
|
||||||
|
val newState = !get(mob)
|
||||||
|
map[mob] = newState
|
||||||
|
saveConfig()
|
||||||
|
return newState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.beemer.essentials.config
|
||||||
|
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
object ChatConfig {
|
||||||
|
private const val MOD_ID = "essentials"
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||||
|
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||||
|
private val FILE_DIR: Path = CONFIG_DIR.resolve("chat_format.json")
|
||||||
|
|
||||||
|
private val gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
|
||||||
|
private val defaultFormats = mapOf(
|
||||||
|
"first-time-join" to "&f[&eNEW&f]&r %name%",
|
||||||
|
"join" to "&f[&a+&f]&r %name%",
|
||||||
|
"quit" to "&f[&c-&f]&r %name%",
|
||||||
|
"chat" to "%name% &7&l: &f%message%"
|
||||||
|
)
|
||||||
|
|
||||||
|
private var chatFormats: MutableMap<String, String> = mutableMapOf()
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
|
||||||
|
if (Files.exists(FILE_DIR)) {
|
||||||
|
val json = Files.readString(FILE_DIR)
|
||||||
|
val type = object : TypeToken<MutableMap<String, String>>() {}.type
|
||||||
|
val loaded: MutableMap<String, String> = try {
|
||||||
|
gson.fromJson(json, type)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 채팅 설정 파일을 읽는 중 오류가 발생했습니다. 기본값으로 초기화합니다.", e)
|
||||||
|
mutableMapOf()
|
||||||
|
}
|
||||||
|
|
||||||
|
chatFormats = defaultFormats.toMutableMap().apply {
|
||||||
|
putAll(loaded)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
chatFormats = defaultFormats.toMutableMap()
|
||||||
|
}
|
||||||
|
|
||||||
|
saveConfig()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 채팅 설정을 불러오는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveConfig() {
|
||||||
|
try {
|
||||||
|
val jsonOut = gson.toJson(chatFormats)
|
||||||
|
Files.writeString(FILE_DIR, jsonOut)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 채팅 설정을 저장하는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getFormat(key: String): String? = chatFormats[key]
|
||||||
|
|
||||||
|
fun setFormat(key: String, format: String) {
|
||||||
|
chatFormats[key] = format
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,151 @@
|
||||||
|
package com.beemer.essentials.config
|
||||||
|
|
||||||
|
import com.beemer.essentials.data.Coordinate
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import net.minecraft.nbt.CompoundTag
|
||||||
|
import net.minecraft.nbt.ListTag
|
||||||
|
import net.minecraft.nbt.NbtAccounter
|
||||||
|
import net.minecraft.nbt.NbtIo
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
|
||||||
|
object CoordinateConfig {
|
||||||
|
private const val MOD_ID = "essentials"
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||||
|
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||||
|
private val FILE_DIR: Path = CONFIG_DIR.resolve("coordinates.dat")
|
||||||
|
|
||||||
|
private var coordinates = mutableListOf<Coordinate>()
|
||||||
|
|
||||||
|
fun saveConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
|
||||||
|
val compound = CompoundTag()
|
||||||
|
val list = ListTag()
|
||||||
|
|
||||||
|
coordinates.forEach { coord ->
|
||||||
|
val coordTag = CompoundTag()
|
||||||
|
coordTag.putString("name", coord.name)
|
||||||
|
coordTag.putString("dimension", coord.dimension)
|
||||||
|
coordTag.putString("biome", coord.biome)
|
||||||
|
coordTag.putDouble("x", coord.x)
|
||||||
|
coordTag.putDouble("y", coord.y)
|
||||||
|
coordTag.putDouble("z", coord.z)
|
||||||
|
coord.creatorUuid?.let { coordTag.putString("creatorUuid", it) }
|
||||||
|
list.add(coordTag)
|
||||||
|
}
|
||||||
|
|
||||||
|
compound.put("Coordinates", list)
|
||||||
|
|
||||||
|
FILE_DIR.toFile().outputStream().use { fos -> NbtIo.writeCompressed(compound, fos) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 좌표 데이터를 저장하는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
if (!CONFIG_DIR.exists()) {
|
||||||
|
CONFIG_DIR.toFile().mkdirs()
|
||||||
|
}
|
||||||
|
|
||||||
|
coordinates.clear()
|
||||||
|
|
||||||
|
if (FILE_DIR.exists()) {
|
||||||
|
try {
|
||||||
|
val compound =
|
||||||
|
NbtIo.readCompressed(
|
||||||
|
FILE_DIR.toFile().inputStream(),
|
||||||
|
NbtAccounter.unlimitedHeap()
|
||||||
|
)
|
||||||
|
val list = compound.getList("Coordinates", 10)
|
||||||
|
|
||||||
|
list.forEach { tag ->
|
||||||
|
if (tag is CompoundTag) {
|
||||||
|
val name = tag.getString("name")
|
||||||
|
val dimension = tag.getString("dimension")
|
||||||
|
val biome = tag.getString("biome")
|
||||||
|
|
||||||
|
// 하위 호환: Int로 저장된 경우 Double로 변환
|
||||||
|
val x =
|
||||||
|
if (tag.contains("x", 6)) tag.getDouble("x")
|
||||||
|
else tag.getInt("x").toDouble() + 0.5
|
||||||
|
val y =
|
||||||
|
if (tag.contains("y", 6)) tag.getDouble("y")
|
||||||
|
else tag.getInt("y").toDouble()
|
||||||
|
val z =
|
||||||
|
if (tag.contains("z", 6)) tag.getDouble("z")
|
||||||
|
else tag.getInt("z").toDouble() + 0.5
|
||||||
|
|
||||||
|
val creatorUuid =
|
||||||
|
if (tag.contains("creatorUuid")) tag.getString("creatorUuid")
|
||||||
|
else null
|
||||||
|
|
||||||
|
coordinates.add(Coordinate(name, dimension, biome, x, y, z, creatorUuid))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.info("[Essentials] 총 ${coordinates.size}개의 좌표 데이터를 불러왔습니다.")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 좌표 데이터를 불러오지 못했습니다.", e)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
LOGGER.info("[Essentials] ${FILE_DIR} 파일이 존재하지 않아, 빈 파일을 생성합니다.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addCoordinate(coord: Coordinate) {
|
||||||
|
coordinates.add(coord)
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun removeCoordinate(name: String) {
|
||||||
|
coordinates.removeIf { it.name == name }
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCoordinates(): List<Coordinate> {
|
||||||
|
// 자연 정렬: 숫자 부분을 숫자로 비교
|
||||||
|
return coordinates.sortedWith { a, b -> naturalCompare(a.name, b.name) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 자연 정렬 비교 함수: "테스트1" < "테스트2" < "테스트10"
|
||||||
|
private fun naturalCompare(str1: String, str2: String): Int {
|
||||||
|
val regex = Regex("(\\d+)")
|
||||||
|
val parts1 = regex.split(str1)
|
||||||
|
val parts2 = regex.split(str2)
|
||||||
|
val nums1 = regex.findAll(str1).map { it.value.toLong() }.toList()
|
||||||
|
val nums2 = regex.findAll(str2).map { it.value.toLong() }.toList()
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
var numIdx = 0
|
||||||
|
while (i < parts1.size || i < parts2.size) {
|
||||||
|
val p1 = parts1.getOrNull(i) ?: ""
|
||||||
|
val p2 = parts2.getOrNull(i) ?: ""
|
||||||
|
val cmp = p1.compareTo(p2)
|
||||||
|
if (cmp != 0) return cmp
|
||||||
|
|
||||||
|
if (numIdx < nums1.size || numIdx < nums2.size) {
|
||||||
|
val n1 = nums1.getOrNull(numIdx) ?: 0L
|
||||||
|
val n2 = nums2.getOrNull(numIdx) ?: 0L
|
||||||
|
if (n1 != n2) return n1.compareTo(n2)
|
||||||
|
numIdx++
|
||||||
|
}
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getCoordinate(name: String): Coordinate? {
|
||||||
|
return coordinates.find { it.name == name }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun isFull(): Boolean = false // 페이지 기능으로 제한 해제
|
||||||
|
|
||||||
|
fun isExist(name: String): Boolean = coordinates.any { it.name == name }
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
package com.beemer.essentials.config
|
||||||
|
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import com.beemer.essentials.data.Player
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.time.Duration
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
import java.time.format.DateTimeParseException
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import net.minecraft.nbt.CompoundTag
|
||||||
|
import net.minecraft.nbt.NbtAccounter
|
||||||
|
import net.minecraft.nbt.NbtIo
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
|
||||||
|
object PlayerConfig {
|
||||||
|
private const val MOD_ID = "essentials"
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||||
|
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||||
|
private val FILE_DIR: Path = CONFIG_DIR.resolve("players.dat")
|
||||||
|
|
||||||
|
private val players: MutableMap<String, Player> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
players.clear()
|
||||||
|
|
||||||
|
if (Files.exists(FILE_DIR)) {
|
||||||
|
val root: CompoundTag =
|
||||||
|
FILE_DIR.toFile().inputStream().use { fis ->
|
||||||
|
NbtIo.readCompressed(fis, NbtAccounter.unlimitedHeap())
|
||||||
|
}
|
||||||
|
|
||||||
|
for (uuid in root.allKeys) {
|
||||||
|
val tag = root.getCompound(uuid)
|
||||||
|
val name = tag.getString("name")
|
||||||
|
val firstJoin =
|
||||||
|
try {
|
||||||
|
LocalDateTime.parse(tag.getString("firstJoin"))
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
LocalDateTime.now()
|
||||||
|
}
|
||||||
|
val lastJoin =
|
||||||
|
try {
|
||||||
|
LocalDateTime.parse(tag.getString("lastJoin"))
|
||||||
|
} catch (_: DateTimeParseException) {
|
||||||
|
firstJoin
|
||||||
|
}
|
||||||
|
val playTime = tag.getLong("playTime")
|
||||||
|
val lastLocation =
|
||||||
|
if (tag.contains("lastLocation")) {
|
||||||
|
val loc = tag.getCompound("lastLocation")
|
||||||
|
Location(
|
||||||
|
dimension = loc.getString("dimension"),
|
||||||
|
biome = loc.getString("biome"),
|
||||||
|
x = loc.getDouble("x"),
|
||||||
|
y = loc.getDouble("y"),
|
||||||
|
z = loc.getDouble("z")
|
||||||
|
)
|
||||||
|
} else null
|
||||||
|
|
||||||
|
players[uuid] = Player(name, firstJoin, lastJoin, playTime, lastLocation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 플레이어 데이터를 불러오는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
val root = CompoundTag()
|
||||||
|
|
||||||
|
for ((uuid, info) in players) {
|
||||||
|
val tag =
|
||||||
|
CompoundTag().apply {
|
||||||
|
putString("name", info.name)
|
||||||
|
putString("firstJoin", info.firstJoin.toString())
|
||||||
|
putString("lastJoin", info.lastJoin.toString())
|
||||||
|
putLong("playTime", info.playTime)
|
||||||
|
info.lastLocation?.let { loc ->
|
||||||
|
val locTag =
|
||||||
|
CompoundTag().apply {
|
||||||
|
putString("dimension", loc.dimension)
|
||||||
|
putString("biome", loc.biome)
|
||||||
|
putDouble("x", loc.x)
|
||||||
|
putDouble("y", loc.y)
|
||||||
|
putDouble("z", loc.z)
|
||||||
|
}
|
||||||
|
put("lastLocation", locTag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
root.put(uuid, tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE_DIR.toFile().outputStream().use { fos -> NbtIo.writeCompressed(root, fos) }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 플레이어 데이터를 저장하는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getOrCreate(uuid: String, currentName: String): Player {
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
return players.compute(uuid) { _, existing ->
|
||||||
|
if (existing == null) {
|
||||||
|
Player(
|
||||||
|
name = currentName,
|
||||||
|
firstJoin = now,
|
||||||
|
lastJoin = now,
|
||||||
|
playTime = 0,
|
||||||
|
lastLocation = null
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
if (existing.name != currentName) existing.name = currentName
|
||||||
|
existing
|
||||||
|
}
|
||||||
|
}!!
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordLogin(player: ServerPlayer) {
|
||||||
|
val uuid = player.uuid.toString()
|
||||||
|
val name = player.gameProfile.name
|
||||||
|
val playerInfo = getOrCreate(uuid, name)
|
||||||
|
playerInfo.lastJoin = LocalDateTime.now()
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordLogout(player: ServerPlayer) {
|
||||||
|
val uuid = player.uuid.toString()
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val info = players[uuid] ?: return
|
||||||
|
val delta = Duration.between(info.lastJoin, now).toMillis()
|
||||||
|
if (delta > 0) {
|
||||||
|
info.playTime += delta
|
||||||
|
}
|
||||||
|
info.lastJoin = now
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun recordLastLocation(player: ServerPlayer, location: Location) {
|
||||||
|
val uuid = player.uuid.toString()
|
||||||
|
val info = players[uuid] ?: return
|
||||||
|
info.lastLocation = location
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getPlayer(player: ServerPlayer): Player? {
|
||||||
|
val uuid = player.uuid.toString()
|
||||||
|
return players[uuid]
|
||||||
|
}
|
||||||
|
|
||||||
|
fun getAccumulatedPlayTime(player: ServerPlayer): Long {
|
||||||
|
val uuid = player.uuid.toString()
|
||||||
|
val info = players[uuid] ?: return 0L
|
||||||
|
val now = LocalDateTime.now()
|
||||||
|
val extra = Duration.between(info.lastJoin, now).toMillis().coerceAtLeast(0)
|
||||||
|
return info.playTime + extra
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
package com.beemer.essentials.config
|
||||||
|
|
||||||
|
import com.beemer.essentials.data.ProtectFarmland
|
||||||
|
import net.minecraft.nbt.CompoundTag
|
||||||
|
import net.minecraft.nbt.NbtIo
|
||||||
|
import net.minecraft.nbt.NbtAccounter
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
object ProtectFarmlandConfig {
|
||||||
|
private const val MOD_ID = "essentials"
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||||
|
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||||
|
private val FILE_DIR: Path = CONFIG_DIR.resolve("farmland_protection.dat")
|
||||||
|
|
||||||
|
private var state: ProtectFarmland = ProtectFarmland()
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
|
||||||
|
if (Files.exists(FILE_DIR)) {
|
||||||
|
val root: CompoundTag = FILE_DIR.toFile().inputStream().use { fis ->
|
||||||
|
NbtIo.readCompressed(fis, NbtAccounter.unlimitedHeap())
|
||||||
|
}
|
||||||
|
val enabled = if (root.contains("protectFarmland")) {
|
||||||
|
root.getBoolean("protectFarmland")
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
state = ProtectFarmland(protectFarmland = enabled)
|
||||||
|
} else {
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 밭 보호 설정을 불러오는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun saveConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
val root = CompoundTag().apply {
|
||||||
|
putBoolean("protectFarmland", state.protectFarmland)
|
||||||
|
}
|
||||||
|
FILE_DIR.toFile().outputStream().use { fos ->
|
||||||
|
NbtIo.writeCompressed(root, fos)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 밭 보호 설정을 저장하는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@JvmStatic
|
||||||
|
fun isEnabled(): Boolean = state.protectFarmland
|
||||||
|
|
||||||
|
fun toggle(): Boolean {
|
||||||
|
state = state.copy(protectFarmland = !state.protectFarmland)
|
||||||
|
saveConfig()
|
||||||
|
return state.protectFarmland
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.beemer.essentials.config
|
||||||
|
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import net.minecraft.nbt.CompoundTag
|
||||||
|
import net.minecraft.nbt.NbtAccounter
|
||||||
|
import net.minecraft.nbt.NbtIo
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
|
||||||
|
object SpawnConfig {
|
||||||
|
private const val MOD_ID = "essentials"
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||||
|
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||||
|
private val FILE_DIR: Path = CONFIG_DIR.resolve("spawn.dat")
|
||||||
|
|
||||||
|
private var location = mutableMapOf<String, Location>()
|
||||||
|
|
||||||
|
private const val DEFAULT_SPAWN_NAME = "DefaultSpawn"
|
||||||
|
private const val CUSTOM_SPAWN_NAME = "CustomSpawn"
|
||||||
|
|
||||||
|
fun saveConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
|
||||||
|
val compound = CompoundTag()
|
||||||
|
location.forEach { (name, loc) ->
|
||||||
|
val entry = CompoundTag().apply {
|
||||||
|
putString("dimension", loc.dimension)
|
||||||
|
putString("biome", loc.biome)
|
||||||
|
putDouble("x", loc.x)
|
||||||
|
putDouble("y", loc.y)
|
||||||
|
putDouble("z", loc.z)
|
||||||
|
}
|
||||||
|
compound.put(name, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
FILE_DIR.toFile().outputStream().use { fos ->
|
||||||
|
NbtIo.writeCompressed(compound, fos)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 스폰 데이터를 저장하는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadConfig() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
location.clear()
|
||||||
|
|
||||||
|
if (Files.exists(FILE_DIR)) {
|
||||||
|
val compound: CompoundTag = FILE_DIR.toFile().inputStream().use { fis ->
|
||||||
|
NbtIo.readCompressed(fis, NbtAccounter.unlimitedHeap())
|
||||||
|
}
|
||||||
|
|
||||||
|
compound.allKeys.forEach { name ->
|
||||||
|
val entry = compound.getCompound(name)
|
||||||
|
val location = Location(
|
||||||
|
dimension = entry.getString("dimension"),
|
||||||
|
biome = entry.getString("biome"),
|
||||||
|
x = entry.getDouble("x"),
|
||||||
|
y = entry.getDouble("y"),
|
||||||
|
z = entry.getDouble("z")
|
||||||
|
)
|
||||||
|
SpawnConfig.location[name] = location
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 스폰 데이터를 불러오는 중 오류가 발생했습니다.", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setDefaultSpawn(location: Location) = setSpawnLocation(DEFAULT_SPAWN_NAME, location)
|
||||||
|
fun getDefaultSpawn(): Location? = getSpawnLocation(DEFAULT_SPAWN_NAME)
|
||||||
|
|
||||||
|
fun setCustomSpawn(location: Location) = setSpawnLocation(CUSTOM_SPAWN_NAME, location)
|
||||||
|
fun getCustomSpawn(): Location? = getSpawnLocation(CUSTOM_SPAWN_NAME)
|
||||||
|
|
||||||
|
private fun setSpawnLocation(name: String, location: Location) {
|
||||||
|
SpawnConfig.location[name] = location
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getSpawnLocation(name: String): Location? = location[name]
|
||||||
|
|
||||||
|
fun removeCustomSpawn() {
|
||||||
|
if (location.remove(CUSTOM_SPAWN_NAME) != null) {
|
||||||
|
saveConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.beemer.essentials.data
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 좌표 데이터 클래스
|
||||||
|
* @param name 장소 이름
|
||||||
|
* @param dimension 차원 ID
|
||||||
|
* @param biome 바이옴 ID
|
||||||
|
* @param x X 좌표 (정밀 위치)
|
||||||
|
* @param y Y 좌표 (정밀 위치)
|
||||||
|
* @param z Z 좌표 (정밀 위치)
|
||||||
|
* @param creatorUuid 저장한 플레이어 UUID (닉네임 변경에도 대응)
|
||||||
|
*/
|
||||||
|
data class Coordinate(
|
||||||
|
val name: String,
|
||||||
|
val dimension: String,
|
||||||
|
val biome: String,
|
||||||
|
val x: Double,
|
||||||
|
val y: Double,
|
||||||
|
val z: Double,
|
||||||
|
val creatorUuid: String? = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package com.beemer.essentials.data
|
||||||
|
|
||||||
|
data class Location(
|
||||||
|
val dimension: String,
|
||||||
|
val biome: String,
|
||||||
|
val x: Double,
|
||||||
|
val y: Double,
|
||||||
|
val z: Double
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
package com.beemer.essentials.data
|
||||||
|
|
||||||
|
import java.time.LocalDateTime
|
||||||
|
|
||||||
|
data class Player(
|
||||||
|
var name: String,
|
||||||
|
val firstJoin: LocalDateTime,
|
||||||
|
var lastJoin: LocalDateTime,
|
||||||
|
var playTime: Long,
|
||||||
|
var lastLocation: Location? = null
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package com.beemer.essentials.data
|
||||||
|
|
||||||
|
data class ProtectFarmland(
|
||||||
|
val protectFarmland: Boolean = false
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package com.beemer.essentials.event
|
||||||
|
|
||||||
|
import com.beemer.essentials.util.ChatUtils
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.ServerChatEvent
|
||||||
|
|
||||||
|
object ChatEvents {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onServerChat(event: ServerChatEvent) {
|
||||||
|
val player = event.player as ServerPlayer
|
||||||
|
val raw = event.rawText
|
||||||
|
|
||||||
|
if (raw.startsWith("/"))
|
||||||
|
return
|
||||||
|
|
||||||
|
if (ChatUtils.hasIllegalCharacter(raw)) {
|
||||||
|
event.isCanceled = true
|
||||||
|
player.sendSystemMessage(Component.literal("채팅에 허용되지 않는 문자가 포함되어 있습니다.")
|
||||||
|
.withStyle { it.withColor(net.minecraft.ChatFormatting.RED) })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ChatUtils.broadcastChat(player, raw)
|
||||||
|
|
||||||
|
event.isCanceled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.beemer.essentials.event
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.*
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore
|
||||||
|
import com.beemer.essentials.util.TranslationUtils
|
||||||
|
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
|
||||||
|
|
||||||
|
object ModEvents {
|
||||||
|
fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
|
||||||
|
SpawnConfig.loadConfig()
|
||||||
|
PlayerConfig.loadConfig()
|
||||||
|
ProtectFarmlandConfig.loadConfig()
|
||||||
|
ChatConfig.loadConfig()
|
||||||
|
AntimobConfig.loadConfig()
|
||||||
|
CoordinateConfig.loadConfig()
|
||||||
|
NicknameDataStore.load()
|
||||||
|
|
||||||
|
TranslationUtils.loadTranslations()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,72 @@
|
||||||
|
package com.beemer.essentials.event
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.PlayerConfig
|
||||||
|
import com.beemer.essentials.config.SpawnConfig
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import com.beemer.essentials.util.ChatUtils
|
||||||
|
import com.beemer.essentials.util.DimensionUtils
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||||
|
|
||||||
|
object PlayerEvents {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerRespawn(event: PlayerEvent.PlayerRespawnEvent) {
|
||||||
|
val player = event.entity as? ServerPlayer ?: return
|
||||||
|
val target = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn() ?: return
|
||||||
|
val level = DimensionUtils.getLevelById(player.server, target.dimension) ?: return
|
||||||
|
|
||||||
|
player.teleportTo(level, target.x, target.y, target.z, player.yRot, player.xRot)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerLoggedIn(event: PlayerEvent.PlayerLoggedInEvent) {
|
||||||
|
val player = event.entity as? ServerPlayer ?: return
|
||||||
|
|
||||||
|
val existing = PlayerConfig.getPlayer(player)
|
||||||
|
val isFirstJoin = existing == null
|
||||||
|
|
||||||
|
ChatUtils.broadcastJoin(player, isFirstJoin)
|
||||||
|
|
||||||
|
PlayerConfig.recordLogin(player)
|
||||||
|
|
||||||
|
if (isFirstJoin) {
|
||||||
|
val spawn = SpawnConfig.getCustomSpawn() ?: SpawnConfig.getDefaultSpawn() ?: return
|
||||||
|
val level = DimensionUtils.getLevelById(player.server, spawn.dimension) ?: return
|
||||||
|
|
||||||
|
player.teleportTo(level, spawn.x, spawn.y, spawn.z, player.yRot, player.xRot)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerLoggedOut(event: PlayerEvent.PlayerLoggedOutEvent) {
|
||||||
|
val player = event.entity as? ServerPlayer ?: return
|
||||||
|
|
||||||
|
ChatUtils.broadcastQuit(player)
|
||||||
|
|
||||||
|
PlayerConfig.recordLogout(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerClone(event: PlayerEvent.Clone) {
|
||||||
|
if (!event.isWasDeath) return
|
||||||
|
val oldPlayer = event.original as? ServerPlayer ?: return
|
||||||
|
|
||||||
|
val pos = oldPlayer.blockPosition()
|
||||||
|
val dimension = oldPlayer.level().dimension().location().toString()
|
||||||
|
val biome = oldPlayer.level().getBiome(pos)
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains")
|
||||||
|
|
||||||
|
val lastLoc = Location(
|
||||||
|
dimension = dimension,
|
||||||
|
biome = biome,
|
||||||
|
x = pos.x.toDouble(),
|
||||||
|
y = pos.y.toDouble(),
|
||||||
|
z = pos.z.toDouble()
|
||||||
|
)
|
||||||
|
|
||||||
|
PlayerConfig.recordLastLocation(oldPlayer, lastLoc)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,80 @@
|
||||||
|
package com.beemer.essentials.event
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.PlayerConfig
|
||||||
|
import com.beemer.essentials.config.SpawnConfig
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.core.BlockPos
|
||||||
|
import net.minecraft.core.registries.Registries
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.resources.ResourceKey
|
||||||
|
import net.minecraft.resources.ResourceLocation
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||||
|
import net.neoforged.neoforge.event.server.ServerStartedEvent
|
||||||
|
import net.neoforged.neoforge.event.server.ServerStoppingEvent
|
||||||
|
|
||||||
|
object ServerEvents {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onServerStarted(event: ServerStartedEvent) {
|
||||||
|
if (SpawnConfig.getDefaultSpawn() != null) return
|
||||||
|
|
||||||
|
val server = event.server
|
||||||
|
|
||||||
|
val overworldRL =
|
||||||
|
ResourceLocation.tryParse("minecraft:overworld")
|
||||||
|
?: run {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val overworldKey = ResourceKey.create(Registries.DIMENSION, overworldRL)
|
||||||
|
val overworld = server.getLevel(overworldKey) ?: return
|
||||||
|
|
||||||
|
val spawnPos: BlockPos = overworld.sharedSpawnPos
|
||||||
|
|
||||||
|
val dimensionId = overworld.dimension().location().toString()
|
||||||
|
val biomeId =
|
||||||
|
overworld
|
||||||
|
.getBiome(spawnPos)
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains")
|
||||||
|
|
||||||
|
val location =
|
||||||
|
Location(
|
||||||
|
dimension = dimensionId,
|
||||||
|
biome = biomeId,
|
||||||
|
x = spawnPos.x.toDouble(),
|
||||||
|
y = spawnPos.y.toDouble(),
|
||||||
|
z = spawnPos.z.toDouble()
|
||||||
|
)
|
||||||
|
|
||||||
|
SpawnConfig.setDefaultSpawn(location)
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onServerStopping(event: ServerStoppingEvent) {
|
||||||
|
val server = event.server
|
||||||
|
server.playerList.players.filterIsInstance<ServerPlayer>().forEach {
|
||||||
|
PlayerConfig.recordLogout(it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerLoggedIn(event: PlayerEvent.PlayerLoggedInEvent) {
|
||||||
|
val player = event.entity
|
||||||
|
player.refreshDisplayName()
|
||||||
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerNameFormat(event: PlayerEvent.NameFormat) {
|
||||||
|
val player = event.entity
|
||||||
|
val nickname = NicknameDataStore.getNickname(player.uuid)
|
||||||
|
|
||||||
|
if (nickname != null && event.displayname.string == nickname) {
|
||||||
|
event.displayname =
|
||||||
|
Component.literal(nickname).withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
package com.beemer.essentials.gui
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.AntimobConfig
|
||||||
|
import com.mojang.authlib.GameProfile
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.core.component.DataComponents
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.minecraft.world.Container
|
||||||
|
import net.minecraft.world.entity.player.Inventory
|
||||||
|
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||||
|
import net.minecraft.world.inventory.ClickType
|
||||||
|
import net.minecraft.world.inventory.MenuType
|
||||||
|
import net.minecraft.world.inventory.Slot
|
||||||
|
import net.minecraft.world.item.ItemStack
|
||||||
|
import net.minecraft.world.item.Items
|
||||||
|
import net.minecraft.world.item.component.ResolvableProfile
|
||||||
|
import java.util.*
|
||||||
|
|
||||||
|
class AntimobGui(syncId: Int, playerInv: Inventory, val container: Container, private val viewer: ServerPlayer) : AbstractContainerMenu(MenuType.GENERIC_9x2, syncId) {
|
||||||
|
companion object {
|
||||||
|
const val CONTAINER_COLUMNS = 9
|
||||||
|
const val CONTAINER_ROWS = 2
|
||||||
|
const val CONTAINER_SIZE = CONTAINER_COLUMNS * CONTAINER_ROWS
|
||||||
|
|
||||||
|
const val SLOT_SIZE = 18
|
||||||
|
const val LEFT_PADDING = 8
|
||||||
|
const val TOP_PADDING = 18
|
||||||
|
|
||||||
|
const val PLAYER_INV_TOP = 58
|
||||||
|
const val HOTBAR_Y = 116
|
||||||
|
}
|
||||||
|
|
||||||
|
private val headSlotToMob = mapOf(0 to "크리퍼", 1 to "가스트", 2 to "엔더맨")
|
||||||
|
private val glassSlotToMob = mapOf(9 to "크리퍼", 10 to "가스트", 11 to "엔더맨")
|
||||||
|
|
||||||
|
init {
|
||||||
|
for (i in 0 until CONTAINER_SIZE) {
|
||||||
|
val x = LEFT_PADDING + (i % CONTAINER_COLUMNS) * SLOT_SIZE
|
||||||
|
val y = TOP_PADDING + (i / CONTAINER_COLUMNS) * SLOT_SIZE
|
||||||
|
addSlot(object : Slot(container, i, x, y) {
|
||||||
|
override fun mayPickup(player: net.minecraft.world.entity.player.Player): Boolean = false
|
||||||
|
override fun mayPlace(stack: ItemStack): Boolean = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row in 0 until 3) {
|
||||||
|
for (col in 0 until 9) {
|
||||||
|
val index = col + row * 9 + 9
|
||||||
|
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||||
|
val y = PLAYER_INV_TOP + row * SLOT_SIZE
|
||||||
|
addSlot(Slot(playerInv, index, x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (col in 0 until 9) {
|
||||||
|
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||||
|
val y = HOTBAR_Y
|
||||||
|
addSlot(Slot(playerInv, col, x, y))
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshContainerContents()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clicked(slotId: Int, button: Int, clickType: ClickType, player: net.minecraft.world.entity.player.Player) {
|
||||||
|
if (player is ServerPlayer && slotId in 0 until CONTAINER_SIZE) {
|
||||||
|
if (glassSlotToMob.containsKey(slotId)) {
|
||||||
|
val mob = glassSlotToMob[slotId] ?: return
|
||||||
|
AntimobConfig.toggle(mob)
|
||||||
|
container.setItem(slotId, makeToggleGlass(mob))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
super.clicked(slotId, button, clickType, player)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stillValid(player: net.minecraft.world.entity.player.Player): Boolean = true
|
||||||
|
|
||||||
|
override fun quickMoveStack(player: net.minecraft.world.entity.player.Player, index: Int): ItemStack = ItemStack.EMPTY
|
||||||
|
|
||||||
|
private fun makeMobHead(mob: String, player: ServerPlayer): ItemStack {
|
||||||
|
val headItem = ItemStack(Items.PLAYER_HEAD)
|
||||||
|
|
||||||
|
val uuid = when (mob) {
|
||||||
|
"크리퍼" -> UUID.fromString("057b1c47-1321-4863-a6fe-8887f9ec265f")
|
||||||
|
"가스트" -> UUID.fromString("063085a6-797f-4785-be1a-21cd7580f752")
|
||||||
|
"엔더맨" -> UUID.fromString("40ffb372-12f6-4678-b3f2-2176bf56dd4b")
|
||||||
|
else -> UUID.fromString("c06f8906-4c8a-4911-9c29-ea1dbd1aab82")
|
||||||
|
}
|
||||||
|
|
||||||
|
val filledProfile: GameProfile = try {
|
||||||
|
player.server.sessionService.fetchProfile(uuid, true)?.profile ?: player.gameProfile
|
||||||
|
} catch (_: Exception) {
|
||||||
|
player.gameProfile
|
||||||
|
}
|
||||||
|
|
||||||
|
val resolvableProfile = try {
|
||||||
|
ResolvableProfile(filledProfile)
|
||||||
|
} catch (_: NoSuchMethodError) {
|
||||||
|
val ctor = ResolvableProfile::class.java.getDeclaredConstructor(GameProfile::class.java)
|
||||||
|
ctor.isAccessible = true
|
||||||
|
ctor.newInstance(filledProfile)
|
||||||
|
}
|
||||||
|
|
||||||
|
headItem.set(DataComponents.CUSTOM_NAME, Component.literal(mob).withStyle { it.withColor(ChatFormatting.YELLOW) })
|
||||||
|
headItem.set(DataComponents.PROFILE, resolvableProfile)
|
||||||
|
return headItem
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun makeToggleGlass(mob: String): ItemStack {
|
||||||
|
val enabled = AntimobConfig.get(mob)
|
||||||
|
val glass = if (enabled) ItemStack(Items.GREEN_STAINED_GLASS_PANE) else ItemStack(Items.RED_STAINED_GLASS_PANE)
|
||||||
|
val statusText = if (enabled) "활성화" else "비활성화"
|
||||||
|
glass.set(DataComponents.CUSTOM_NAME, Component.literal(statusText).withStyle { it.withColor(if (enabled) ChatFormatting.GREEN else ChatFormatting.RED) })
|
||||||
|
return glass
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshContainerContents() {
|
||||||
|
headSlotToMob.forEach { (slot, mob) ->
|
||||||
|
container.setItem(slot, makeMobHead(mob, viewer))
|
||||||
|
}
|
||||||
|
glassSlotToMob.forEach { (slot, mob) ->
|
||||||
|
container.setItem(slot, makeToggleGlass(mob))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,302 @@
|
||||||
|
package com.beemer.essentials.gui
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.CoordinateConfig
|
||||||
|
import com.beemer.essentials.config.PlayerConfig
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore
|
||||||
|
import com.beemer.essentials.util.TranslationUtils.translateBiome
|
||||||
|
import com.beemer.essentials.util.TranslationUtils.translateDimension
|
||||||
|
import java.util.UUID
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.core.component.DataComponents
|
||||||
|
import net.minecraft.core.registries.Registries
|
||||||
|
import net.minecraft.nbt.CompoundTag
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.resources.ResourceKey
|
||||||
|
import net.minecraft.resources.ResourceLocation
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.minecraft.world.Container
|
||||||
|
import net.minecraft.world.SimpleContainer
|
||||||
|
import net.minecraft.world.SimpleMenuProvider
|
||||||
|
import net.minecraft.world.entity.player.Inventory
|
||||||
|
import net.minecraft.world.entity.player.Player
|
||||||
|
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||||
|
import net.minecraft.world.inventory.ClickType
|
||||||
|
import net.minecraft.world.inventory.MenuType
|
||||||
|
import net.minecraft.world.inventory.Slot
|
||||||
|
import net.minecraft.world.item.ItemStack
|
||||||
|
import net.minecraft.world.item.Items
|
||||||
|
import net.minecraft.world.item.component.CustomData
|
||||||
|
import net.minecraft.world.item.component.ItemLore
|
||||||
|
|
||||||
|
/** 좌표 GUI - 페이지 기능 포함 5줄(45개) 표시 + 6번째 줄에 이전/다음 버튼 */
|
||||||
|
class Menu(
|
||||||
|
syncId: Int,
|
||||||
|
playerInv: Inventory,
|
||||||
|
val container: Container,
|
||||||
|
private var currentPage: Int = 0
|
||||||
|
) : AbstractContainerMenu(MenuType.GENERIC_9x6, syncId) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val ITEMS_PER_PAGE = 45 // 5줄 * 9
|
||||||
|
const val PREV_BUTTON_SLOT = 45 // 6번째 줄 첫 번째
|
||||||
|
const val NEXT_BUTTON_SLOT = 53 // 6번째 줄 마지막
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
// 54개 슬롯 추가
|
||||||
|
for (i in 0 until 54) {
|
||||||
|
val x = 8 + (i % 9) * 18
|
||||||
|
val y = 18 + (i / 9) * 18
|
||||||
|
addSlot(
|
||||||
|
object : Slot(container, i, x, y) {
|
||||||
|
override fun mayPickup(player: Player): Boolean = false
|
||||||
|
override fun mayPlace(stack: ItemStack): Boolean = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플레이어 인벤토리 슬롯
|
||||||
|
for (row in 0 until 3) {
|
||||||
|
for (col in 0 until 9) {
|
||||||
|
val index = col + row * 9 + 9
|
||||||
|
val x = 8 + col * 18
|
||||||
|
val y = 140 + row * 18
|
||||||
|
addSlot(Slot(playerInv, index, x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (col in 0 until 9) {
|
||||||
|
val x = 8 + col * 18
|
||||||
|
val y = 198
|
||||||
|
addSlot(Slot(playerInv, col, x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clicked(slotId: Int, button: Int, clickType: ClickType, player: Player) {
|
||||||
|
if (slotId !in 0 until container.containerSize || player !is ServerPlayer) {
|
||||||
|
super.clicked(slotId, button, clickType, player)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val stack = slots[slotId].item
|
||||||
|
val tag = stack.get(DataComponents.CUSTOM_DATA)?.copyTag() ?: return
|
||||||
|
|
||||||
|
// 이전 페이지 버튼
|
||||||
|
if (slotId == PREV_BUTTON_SLOT &&
|
||||||
|
tag.contains("action") &&
|
||||||
|
tag.getString("action") == "prev"
|
||||||
|
) {
|
||||||
|
if (currentPage > 0) {
|
||||||
|
openPage(player, currentPage - 1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 페이지 버튼
|
||||||
|
if (slotId == NEXT_BUTTON_SLOT &&
|
||||||
|
tag.contains("action") &&
|
||||||
|
tag.getString("action") == "next"
|
||||||
|
) {
|
||||||
|
val totalPages = getTotalPages()
|
||||||
|
if (currentPage < totalPages - 1) {
|
||||||
|
openPage(player, currentPage + 1)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 좌표 아이템 클릭 시 텔레포트
|
||||||
|
if (tag.contains("name") && tag.contains("x")) {
|
||||||
|
val x = tag.getDouble("x")
|
||||||
|
val y = tag.getDouble("y")
|
||||||
|
val z = tag.getDouble("z")
|
||||||
|
val dimension =
|
||||||
|
ResourceLocation.tryParse(tag.getString("dimension"))?.let {
|
||||||
|
ResourceKey.create(Registries.DIMENSION, it)
|
||||||
|
}
|
||||||
|
val level = dimension?.let { player.server.getLevel(it) }
|
||||||
|
|
||||||
|
if (level != null) {
|
||||||
|
// 이전 위치 저장
|
||||||
|
val prevPos = player.blockPosition()
|
||||||
|
PlayerConfig.recordLastLocation(
|
||||||
|
player,
|
||||||
|
Location(
|
||||||
|
dimension = player.level().dimension().location().toString(),
|
||||||
|
biome =
|
||||||
|
player.level()
|
||||||
|
.getBiome(prevPos)
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains"),
|
||||||
|
x = prevPos.x.toDouble(),
|
||||||
|
y = prevPos.y.toDouble(),
|
||||||
|
z = prevPos.z.toDouble()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
player.teleportTo(level, x, y, z, player.yRot, player.xRot)
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(tag.getString("name"))
|
||||||
|
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||||
|
.append(
|
||||||
|
Component.literal("(으)로 이동했습니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.GOLD)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
player.closeContainer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getTotalPages(): Int {
|
||||||
|
val totalItems = CoordinateConfig.getCoordinates().size
|
||||||
|
return if (totalItems == 0) 1 else (totalItems + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openPage(player: ServerPlayer, page: Int) {
|
||||||
|
val container = createPageContainer(player, page)
|
||||||
|
player.openMenu(
|
||||||
|
SimpleMenuProvider(
|
||||||
|
{ windowId, inv, _ -> Menu(windowId, inv, container, page) },
|
||||||
|
Component.literal("저장된 좌표 (${page + 1}/${getTotalPages()})").withStyle {
|
||||||
|
it.withBold(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stillValid(player: Player): Boolean = true
|
||||||
|
override fun quickMoveStack(player: Player, index: Int): ItemStack = ItemStack.EMPTY
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 페이지별 컨테이너 생성 */
|
||||||
|
fun createPageContainer(player: ServerPlayer, page: Int): SimpleContainer {
|
||||||
|
val container = SimpleContainer(54)
|
||||||
|
val coordinates = CoordinateConfig.getCoordinates()
|
||||||
|
val startIdx = page * Menu.ITEMS_PER_PAGE
|
||||||
|
val endIdx = minOf(startIdx + Menu.ITEMS_PER_PAGE, coordinates.size)
|
||||||
|
val totalPages =
|
||||||
|
if (coordinates.isEmpty()) 1
|
||||||
|
else (coordinates.size + Menu.ITEMS_PER_PAGE - 1) / Menu.ITEMS_PER_PAGE
|
||||||
|
|
||||||
|
// 좌표 아이템 추가 (5줄, 45개)
|
||||||
|
for (i in startIdx until endIdx) {
|
||||||
|
val coord = coordinates[i]
|
||||||
|
val item = ItemStack(Items.PAPER)
|
||||||
|
val tag =
|
||||||
|
CompoundTag().apply {
|
||||||
|
putString("name", coord.name)
|
||||||
|
putString("dimension", coord.dimension)
|
||||||
|
putDouble("x", coord.x)
|
||||||
|
putDouble("y", coord.y)
|
||||||
|
putDouble("z", coord.z)
|
||||||
|
}
|
||||||
|
|
||||||
|
val creatorName =
|
||||||
|
coord.creatorUuid?.let { uuidStr ->
|
||||||
|
try {
|
||||||
|
val uuid = UUID.fromString(uuidStr)
|
||||||
|
NicknameDataStore.getNickname(uuid)
|
||||||
|
?: player.server.playerList.getPlayer(uuid)?.gameProfile?.name
|
||||||
|
} catch (_: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayName =
|
||||||
|
if (creatorName != null) {
|
||||||
|
Component.literal(coord.name)
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GOLD).withBold(true) }
|
||||||
|
.append(
|
||||||
|
Component.literal(" ($creatorName)").withStyle {
|
||||||
|
it.withColor(ChatFormatting.AQUA).withBold(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Component.literal(coord.name).withStyle {
|
||||||
|
it.withColor(ChatFormatting.GOLD).withBold(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val loreList: List<Component> =
|
||||||
|
listOf(
|
||||||
|
Component.literal("디멘션: ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||||
|
.append(
|
||||||
|
Component.literal(translateDimension(coord.dimension))
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GRAY) }
|
||||||
|
),
|
||||||
|
Component.literal("바이옴: ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||||
|
.append(
|
||||||
|
Component.literal(translateBiome(coord.biome)).withStyle {
|
||||||
|
it.withColor(ChatFormatting.GRAY)
|
||||||
|
}
|
||||||
|
),
|
||||||
|
Component.literal("좌표: ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.DARK_GREEN) }
|
||||||
|
.append(
|
||||||
|
Component.literal(
|
||||||
|
"${coord.x.toInt()}, ${coord.y.toInt()}, ${coord.z.toInt()}"
|
||||||
|
)
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GRAY) }
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
item.set(DataComponents.CUSTOM_NAME, displayName)
|
||||||
|
item.set(DataComponents.LORE, ItemLore(loreList))
|
||||||
|
item.set(DataComponents.CUSTOM_DATA, CustomData.of(tag))
|
||||||
|
container.setItem(i - startIdx, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 맨 아래줄 빈 공간을 회색 유리판으로 채우기 (슬롯 46-52)
|
||||||
|
val fillerItem = ItemStack(Items.GRAY_STAINED_GLASS_PANE)
|
||||||
|
fillerItem.set(
|
||||||
|
DataComponents.CUSTOM_NAME,
|
||||||
|
Component.literal(" ").withStyle { it.withColor(ChatFormatting.DARK_GRAY) }
|
||||||
|
)
|
||||||
|
for (slot in 46..52) {
|
||||||
|
container.setItem(slot, fillerItem.copy())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이전 페이지 버튼 (슬롯 45)
|
||||||
|
if (page > 0) {
|
||||||
|
val prevItem = ItemStack(Items.LIME_STAINED_GLASS_PANE)
|
||||||
|
val prevTag = CompoundTag().apply { putString("action", "prev") }
|
||||||
|
prevItem.set(
|
||||||
|
DataComponents.CUSTOM_NAME,
|
||||||
|
Component.literal("이전 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
|
||||||
|
)
|
||||||
|
prevItem.set(DataComponents.CUSTOM_DATA, CustomData.of(prevTag))
|
||||||
|
container.setItem(Menu.PREV_BUTTON_SLOT, prevItem)
|
||||||
|
} else {
|
||||||
|
val disabledItem = ItemStack(Items.RED_STAINED_GLASS_PANE)
|
||||||
|
disabledItem.set(
|
||||||
|
DataComponents.CUSTOM_NAME,
|
||||||
|
Component.literal("이전 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
|
||||||
|
)
|
||||||
|
container.setItem(Menu.PREV_BUTTON_SLOT, disabledItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 다음 페이지 버튼 (슬롯 53)
|
||||||
|
if (page < totalPages - 1) {
|
||||||
|
val nextItem = ItemStack(Items.LIME_STAINED_GLASS_PANE)
|
||||||
|
val nextTag = CompoundTag().apply { putString("action", "next") }
|
||||||
|
nextItem.set(
|
||||||
|
DataComponents.CUSTOM_NAME,
|
||||||
|
Component.literal("다음 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
|
||||||
|
)
|
||||||
|
nextItem.set(DataComponents.CUSTOM_DATA, CustomData.of(nextTag))
|
||||||
|
container.setItem(Menu.NEXT_BUTTON_SLOT, nextItem)
|
||||||
|
} else {
|
||||||
|
val disabledItem = ItemStack(Items.RED_STAINED_GLASS_PANE)
|
||||||
|
disabledItem.set(
|
||||||
|
DataComponents.CUSTOM_NAME,
|
||||||
|
Component.literal("다음 페이지").withStyle { it.withColor(ChatFormatting.YELLOW) }
|
||||||
|
)
|
||||||
|
container.setItem(Menu.NEXT_BUTTON_SLOT, disabledItem)
|
||||||
|
}
|
||||||
|
|
||||||
|
return container
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
package com.beemer.essentials.gui
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.PlayerConfig
|
||||||
|
import com.beemer.essentials.data.Location
|
||||||
|
import com.beemer.essentials.data.Player
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.minecraft.world.Container
|
||||||
|
import net.minecraft.world.entity.player.Inventory
|
||||||
|
import net.minecraft.world.inventory.AbstractContainerMenu
|
||||||
|
import net.minecraft.world.inventory.ClickType
|
||||||
|
import net.minecraft.world.inventory.MenuType
|
||||||
|
import net.minecraft.world.inventory.Slot
|
||||||
|
import net.minecraft.world.item.ItemStack
|
||||||
|
|
||||||
|
class TeleportGui(
|
||||||
|
syncId: Int,
|
||||||
|
playerInv: Inventory,
|
||||||
|
val container: Container,
|
||||||
|
val viewer: ServerPlayer
|
||||||
|
) : AbstractContainerMenu(MenuType.GENERIC_9x3, syncId) {
|
||||||
|
companion object {
|
||||||
|
const val CONTAINER_COLUMNS = 9
|
||||||
|
const val CONTAINER_ROWS = 3
|
||||||
|
const val CONTAINER_SIZE = CONTAINER_COLUMNS * CONTAINER_ROWS
|
||||||
|
|
||||||
|
const val SLOT_SIZE = 18
|
||||||
|
const val LEFT_PADDING = 8
|
||||||
|
const val TOP_PADDING = 18
|
||||||
|
|
||||||
|
const val PLAYER_INV_TOP = 86
|
||||||
|
const val HOTBAR_Y = 144
|
||||||
|
}
|
||||||
|
|
||||||
|
private val targetPlayers =
|
||||||
|
viewer.server
|
||||||
|
.playerList
|
||||||
|
.players
|
||||||
|
.filterIsInstance<ServerPlayer>()
|
||||||
|
.filter { it.uuid != viewer.uuid }
|
||||||
|
.map { it.uuid }
|
||||||
|
|
||||||
|
init {
|
||||||
|
for (i in 0 until CONTAINER_SIZE) {
|
||||||
|
val x = LEFT_PADDING + (i % CONTAINER_COLUMNS) * SLOT_SIZE
|
||||||
|
val y = TOP_PADDING + (i / CONTAINER_COLUMNS) * SLOT_SIZE
|
||||||
|
addSlot(
|
||||||
|
object : Slot(container, i, x, y) {
|
||||||
|
override fun mayPickup(
|
||||||
|
player: net.minecraft.world.entity.player.Player
|
||||||
|
): Boolean = false
|
||||||
|
override fun mayPlace(stack: ItemStack): Boolean = false
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
for (row in 0 until 3) {
|
||||||
|
for (col in 0 until 9) {
|
||||||
|
val index = col + row * 9 + 9
|
||||||
|
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||||
|
val y = PLAYER_INV_TOP + row * SLOT_SIZE
|
||||||
|
addSlot(Slot(playerInv, index, x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (col in 0 until 9) {
|
||||||
|
val x = LEFT_PADDING + col * SLOT_SIZE
|
||||||
|
val y = HOTBAR_Y
|
||||||
|
addSlot(Slot(playerInv, col, x, y))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun clicked(
|
||||||
|
slotId: Int,
|
||||||
|
button: Int,
|
||||||
|
clickType: ClickType,
|
||||||
|
player: net.minecraft.world.entity.player.Player
|
||||||
|
) {
|
||||||
|
if (slotId in 0 until CONTAINER_SIZE && player is ServerPlayer) {
|
||||||
|
val currentTargetPlayer =
|
||||||
|
targetPlayers.getOrNull(slotId)?.let { player.server.playerList.getPlayer(it) }
|
||||||
|
|
||||||
|
if (currentTargetPlayer != null && player.uuid != currentTargetPlayer.uuid) {
|
||||||
|
val prevPos = player.blockPosition()
|
||||||
|
val prevDimension = player.level().dimension().location().toString()
|
||||||
|
val prevBiome =
|
||||||
|
player.level()
|
||||||
|
.getBiome(prevPos)
|
||||||
|
.unwrapKey()
|
||||||
|
.map { it.location().toString() }
|
||||||
|
.orElse("minecraft:plains")
|
||||||
|
val previousLocation =
|
||||||
|
Location(
|
||||||
|
dimension = prevDimension,
|
||||||
|
biome = prevBiome,
|
||||||
|
x = prevPos.x.toDouble(),
|
||||||
|
y = prevPos.y.toDouble(),
|
||||||
|
z = prevPos.z.toDouble()
|
||||||
|
)
|
||||||
|
PlayerConfig.recordLastLocation(player, previousLocation)
|
||||||
|
|
||||||
|
val targetLevel = currentTargetPlayer.serverLevel()
|
||||||
|
|
||||||
|
player.teleportTo(
|
||||||
|
targetLevel,
|
||||||
|
currentTargetPlayer.x,
|
||||||
|
currentTargetPlayer.y,
|
||||||
|
currentTargetPlayer.z,
|
||||||
|
currentTargetPlayer.yRot,
|
||||||
|
currentTargetPlayer.xRot
|
||||||
|
)
|
||||||
|
|
||||||
|
// 닉네임이 있으면 닉네임 사용
|
||||||
|
val targetName =
|
||||||
|
NicknameDataStore.getNickname(currentTargetPlayer.uuid)
|
||||||
|
?: currentTargetPlayer.gameProfile.name
|
||||||
|
val playerName =
|
||||||
|
NicknameDataStore.getNickname(player.uuid) ?: player.gameProfile.name
|
||||||
|
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal(targetName)
|
||||||
|
.withStyle { it.withColor(ChatFormatting.AQUA) }
|
||||||
|
.append(
|
||||||
|
Component.literal("님에게 텔레포트 했습니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.GOLD)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
currentTargetPlayer.sendSystemMessage(
|
||||||
|
Component.literal(playerName)
|
||||||
|
.withStyle { it.withColor(ChatFormatting.AQUA) }
|
||||||
|
.append(
|
||||||
|
Component.literal("님이 당신에게 텔레포트 했습니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.GOLD)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("해당 플레이어는 현재 오프라인입니다.").withStyle {
|
||||||
|
it.withColor(ChatFormatting.RED)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
player.closeContainer()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
super.clicked(slotId, button, clickType, player)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun stillValid(player: net.minecraft.world.entity.player.Player): Boolean = true
|
||||||
|
|
||||||
|
override fun quickMoveStack(
|
||||||
|
player: net.minecraft.world.entity.player.Player,
|
||||||
|
index: Int
|
||||||
|
): ItemStack = ItemStack.EMPTY
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,127 @@
|
||||||
|
|
||||||
|
package com.beemer.essentials.nickname
|
||||||
|
|
||||||
|
import com.mojang.brigadier.arguments.StringArgumentType
|
||||||
|
import net.minecraft.ChatFormatting
|
||||||
|
import net.minecraft.commands.Commands
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 닉네임 명령어
|
||||||
|
* /닉네임 변경 <닉네임>
|
||||||
|
* /닉네임 초기화
|
||||||
|
*/
|
||||||
|
object NicknameCommand {
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
// 한글 명령어
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal("닉네임")
|
||||||
|
.then(
|
||||||
|
Commands.literal("변경")
|
||||||
|
.then(
|
||||||
|
Commands.argument("닉네임", StringArgumentType.greedyString())
|
||||||
|
.executes { context ->
|
||||||
|
val player = context.source.entity as? ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
val nickname = StringArgumentType.getString(context, "닉네임").trim()
|
||||||
|
executeSet(player, nickname)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
Commands.literal("초기화")
|
||||||
|
.executes { context ->
|
||||||
|
val player = context.source.entity as? ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
executeReset(player)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
// 영어 명령어
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal("nickname")
|
||||||
|
.then(
|
||||||
|
Commands.literal("set")
|
||||||
|
.then(
|
||||||
|
Commands.argument("name", StringArgumentType.greedyString())
|
||||||
|
.executes { context ->
|
||||||
|
val player = context.source.entity as? ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
val nickname = StringArgumentType.getString(context, "name").trim()
|
||||||
|
executeSet(player, nickname)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
Commands.literal("reset")
|
||||||
|
.executes { context ->
|
||||||
|
val player = context.source.entity as? ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
executeReset(player)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeSet(player: ServerPlayer, nickname: String): Int {
|
||||||
|
// 유효성 검사: 길이
|
||||||
|
if (nickname.length < 2 || nickname.length > 16) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("닉네임은 2~16자 사이여야 합니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 유효성 검사: 중복
|
||||||
|
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("이미 사용 중인 닉네임입니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 닉네임 저장 및 적용
|
||||||
|
NicknameDataStore.setNickname(player.uuid, nickname)
|
||||||
|
NicknameManager.applyNickname(player, nickname)
|
||||||
|
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("닉네임이 ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||||
|
.append(Component.literal(nickname).withStyle { it.withColor(ChatFormatting.AQUA) })
|
||||||
|
.append(Component.literal("(으)로 변경되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
|
||||||
|
)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeReset(player: ServerPlayer): Int {
|
||||||
|
if (!NicknameDataStore.hasNickname(player.uuid)) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("설정된 닉네임이 없습니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
NicknameDataStore.removeNickname(player.uuid)
|
||||||
|
NicknameManager.removeNickname(player)
|
||||||
|
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("닉네임이 초기화되었습니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||||
|
)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,89 @@
|
||||||
|
package com.beemer.essentials.nickname
|
||||||
|
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import com.google.gson.GsonBuilder
|
||||||
|
import com.google.gson.reflect.TypeToken
|
||||||
|
import java.nio.file.Files
|
||||||
|
import java.nio.file.Path
|
||||||
|
import java.util.UUID
|
||||||
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
|
||||||
|
/** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */
|
||||||
|
object NicknameDataStore {
|
||||||
|
private const val MOD_ID = "essentials"
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
private val BASE_CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get()
|
||||||
|
private val CONFIG_DIR: Path = BASE_CONFIG_DIR.resolve(MOD_ID)
|
||||||
|
private val FILE_PATH: Path = CONFIG_DIR.resolve("nicknames.json")
|
||||||
|
|
||||||
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
|
||||||
|
// UUID -> 닉네임 매핑
|
||||||
|
private val nicknames: MutableMap<String, String> = ConcurrentHashMap()
|
||||||
|
|
||||||
|
/** 닉네임 데이터 로드 */
|
||||||
|
fun load() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
|
||||||
|
if (Files.exists(FILE_PATH)) {
|
||||||
|
val json = Files.readString(FILE_PATH)
|
||||||
|
val type = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
val loaded: Map<String, String> = gson.fromJson(json, type) ?: emptyMap()
|
||||||
|
nicknames.clear()
|
||||||
|
nicknames.putAll(loaded)
|
||||||
|
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}개")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 데이터 저장 */
|
||||||
|
fun save() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(CONFIG_DIR)
|
||||||
|
val json = gson.toJson(nicknames)
|
||||||
|
Files.writeString(FILE_PATH, json)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.error("[Essentials] 닉네임 데이터 저장 실패", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 설정 */
|
||||||
|
fun setNickname(uuid: UUID, nickname: String) {
|
||||||
|
nicknames[uuid.toString()] = nickname
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 제거 */
|
||||||
|
fun removeNickname(uuid: UUID) {
|
||||||
|
nicknames.remove(uuid.toString())
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 조회 */
|
||||||
|
@JvmStatic
|
||||||
|
fun getNickname(uuid: UUID): String? {
|
||||||
|
return nicknames[uuid.toString()]
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 중복 확인 */
|
||||||
|
fun isNicknameTaken(nickname: String, excludeUUID: UUID? = null): Boolean {
|
||||||
|
val target = nickname.trim()
|
||||||
|
if (target.isEmpty()) return false
|
||||||
|
return nicknames.entries.any {
|
||||||
|
it.value.equals(target, ignoreCase = true) &&
|
||||||
|
(excludeUUID == null || it.key != excludeUUID.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 플레이어에게 닉네임이 있는지 확인 */
|
||||||
|
fun hasNickname(uuid: UUID): Boolean {
|
||||||
|
return nicknames.containsKey(uuid.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,108 @@
|
||||||
|
package com.beemer.essentials.nickname
|
||||||
|
|
||||||
|
import java.util.EnumSet
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundPlayerInfoRemovePacket
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundPlayerInfoUpdatePacket
|
||||||
|
import net.minecraft.network.protocol.game.ClientboundRemoveEntitiesPacket
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
import org.apache.logging.log4j.Logger
|
||||||
|
|
||||||
|
/** 닉네임 관리 유틸리티 패킷 재전송 및 엔티티 재추적을 통한 닉네임 즉시 반영 */
|
||||||
|
object NicknameManager {
|
||||||
|
private val LOGGER: Logger = LogManager.getLogger("essentials")
|
||||||
|
|
||||||
|
/** 닉네임 적용 */
|
||||||
|
fun applyNickname(player: ServerPlayer, nickname: String) {
|
||||||
|
val server = player.server
|
||||||
|
|
||||||
|
server.execute {
|
||||||
|
refreshPlayerInfo(player)
|
||||||
|
respawnPlayerEntity(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.info("[Essentials] [${player.gameProfile.name}] 닉네임 적용: $nickname")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 닉네임 제거 */
|
||||||
|
fun removeNickname(player: ServerPlayer) {
|
||||||
|
val server = player.server
|
||||||
|
|
||||||
|
server.execute {
|
||||||
|
refreshPlayerInfo(player)
|
||||||
|
respawnPlayerEntity(player)
|
||||||
|
}
|
||||||
|
|
||||||
|
LOGGER.info("[Essentials] [${player.gameProfile.name}] 닉네임 초기화")
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 플레이어 정보 갱신 (탭 목록) */
|
||||||
|
private fun refreshPlayerInfo(player: ServerPlayer) {
|
||||||
|
val server = player.server
|
||||||
|
|
||||||
|
val removePacket = ClientboundPlayerInfoRemovePacket(listOf(player.uuid))
|
||||||
|
val addPacket =
|
||||||
|
ClientboundPlayerInfoUpdatePacket(
|
||||||
|
EnumSet.of(
|
||||||
|
ClientboundPlayerInfoUpdatePacket.Action.ADD_PLAYER,
|
||||||
|
ClientboundPlayerInfoUpdatePacket.Action.INITIALIZE_CHAT,
|
||||||
|
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_GAME_MODE,
|
||||||
|
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LISTED,
|
||||||
|
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_LATENCY,
|
||||||
|
ClientboundPlayerInfoUpdatePacket.Action.UPDATE_DISPLAY_NAME
|
||||||
|
),
|
||||||
|
listOf(player)
|
||||||
|
)
|
||||||
|
|
||||||
|
server.playerList.players.forEach { recipient ->
|
||||||
|
recipient.connection.send(removePacket)
|
||||||
|
recipient.connection.send(addPacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 플레이어 엔티티 재전송 (머리 위 이름 갱신) */
|
||||||
|
private fun respawnPlayerEntity(player: ServerPlayer) {
|
||||||
|
val server = player.server
|
||||||
|
val level = player.serverLevel()
|
||||||
|
val chunkMap = level.chunkSource.chunkMap
|
||||||
|
|
||||||
|
val removePacket = ClientboundRemoveEntitiesPacket(player.id)
|
||||||
|
|
||||||
|
server.playerList.players.forEach { recipient ->
|
||||||
|
if (recipient != player) {
|
||||||
|
recipient.connection.send(removePacket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 리플렉션으로 엔티티 재추적
|
||||||
|
try {
|
||||||
|
val chunkMapClass = chunkMap::class.java
|
||||||
|
|
||||||
|
val removeMethod =
|
||||||
|
chunkMapClass.getDeclaredMethod(
|
||||||
|
"removeEntity",
|
||||||
|
net.minecraft.world.entity.Entity::class.java
|
||||||
|
)
|
||||||
|
removeMethod.isAccessible = true
|
||||||
|
removeMethod.invoke(chunkMap, player)
|
||||||
|
|
||||||
|
val addMethod =
|
||||||
|
chunkMapClass.getDeclaredMethod(
|
||||||
|
"addEntity",
|
||||||
|
net.minecraft.world.entity.Entity::class.java
|
||||||
|
)
|
||||||
|
addMethod.isAccessible = true
|
||||||
|
addMethod.invoke(chunkMap, player)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
LOGGER.warn("[Essentials] 엔티티 재추적 실패: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 플레이어 접속 시 호출 */
|
||||||
|
fun onPlayerJoin(player: ServerPlayer) {
|
||||||
|
val nickname = NicknameDataStore.getNickname(player.uuid)
|
||||||
|
if (nickname != null) {
|
||||||
|
LOGGER.info("[Essentials] [${player.gameProfile.name}] 저장된 닉네임 적용: $nickname")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,95 @@
|
||||||
|
package com.beemer.essentials.util
|
||||||
|
|
||||||
|
import com.beemer.essentials.config.ChatConfig
|
||||||
|
import com.beemer.essentials.nickname.NicknameDataStore
|
||||||
|
import java.time.ZoneId
|
||||||
|
import java.time.ZonedDateTime
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
|
||||||
|
object ChatUtils {
|
||||||
|
private const val SECTION = '\u00a7'
|
||||||
|
private val legacyPattern = Regex("&([0-9A-FK-ORa-fk-or])")
|
||||||
|
private val hexPattern = Regex("&#([A-Fa-f0-9]{6})")
|
||||||
|
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|
||||||
|
private fun translateChatColors(text: String?): String {
|
||||||
|
if (text.isNullOrEmpty()) return ""
|
||||||
|
|
||||||
|
var result = text
|
||||||
|
|
||||||
|
result =
|
||||||
|
hexPattern.replace(result) { match ->
|
||||||
|
val hex = match.groupValues[1].lowercase()
|
||||||
|
val builder = StringBuilder()
|
||||||
|
builder.append(SECTION).append('x')
|
||||||
|
for (c in hex) {
|
||||||
|
builder.append(SECTION).append(c)
|
||||||
|
}
|
||||||
|
builder.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
result =
|
||||||
|
legacyPattern.replace(result) { match ->
|
||||||
|
val code = match.groupValues[1].lowercase()
|
||||||
|
"$SECTION$code"
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun currentTime(): String {
|
||||||
|
return ZonedDateTime.now(ZoneId.of("Asia/Seoul")).format(timeFormatter)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun applyPlaceholders(template: String, name: String, message: String): String {
|
||||||
|
return template.replace("%time%", currentTime())
|
||||||
|
.replace("%name%", name)
|
||||||
|
.replace("%message%", message)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun chatFormatting(template: String, player: ServerPlayer, message: String): String {
|
||||||
|
// 닉네임이 있으면 닉네임 사용, 없으면 원본 이름
|
||||||
|
val name = NicknameDataStore.getNickname(player.uuid) ?: player.gameProfile.name
|
||||||
|
val withPlaceholders = applyPlaceholders(template, name, message)
|
||||||
|
return translateChatColors(withPlaceholders)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastJoin(player: ServerPlayer, firstTime: Boolean) {
|
||||||
|
val key = if (firstTime) "first-time-join" else "join"
|
||||||
|
val template = ChatConfig.getFormat(key) ?: defaultJoinTemplate(firstTime)
|
||||||
|
val formatted = chatFormatting(template, player, "")
|
||||||
|
player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastQuit(player: ServerPlayer) {
|
||||||
|
val template = ChatConfig.getFormat("quit") ?: "&f[&c-&f]&r %name%"
|
||||||
|
val formatted = chatFormatting(template, player, "")
|
||||||
|
player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun broadcastChat(player: ServerPlayer, rawMessage: String) {
|
||||||
|
val template = ChatConfig.getFormat("chat") ?: "%name% &7&l: &f%message%"
|
||||||
|
val formatted = chatFormatting(template, player, rawMessage)
|
||||||
|
player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun defaultJoinTemplate(firstTime: Boolean): String =
|
||||||
|
if (firstTime) "&f[&eNEW&f]&r %name%" else "&f[&a+&f]&r %name%"
|
||||||
|
|
||||||
|
fun hasIllegalCharacter(message: String): Boolean {
|
||||||
|
for (c in message) {
|
||||||
|
if (c < 32.toChar() || c == 127.toChar()) return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearChat(player: ServerPlayer, lines: Int = 100) {
|
||||||
|
repeat(lines) { player.sendSystemMessage(Component.literal(" ")) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clearChatForAll(players: Collection<ServerPlayer>, lines: Int = 100) {
|
||||||
|
for (p in players) clearChat(p, lines)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.beemer.essentials.util
|
||||||
|
|
||||||
|
import net.minecraft.commands.CommandSourceStack
|
||||||
|
import net.minecraft.network.chat.Component
|
||||||
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
|
||||||
|
object CommandUtils {
|
||||||
|
fun getPlayerOrSendFailure(source: CommandSourceStack, failureMessage: String = "해당 명령어는 플레이어만 사용할 수 있습니다."): ServerPlayer? {
|
||||||
|
return try {
|
||||||
|
source.playerOrException ?: run {
|
||||||
|
source.sendFailure(Component.literal(failureMessage))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
source.sendFailure(Component.literal(failureMessage))
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.beemer.essentials.util
|
||||||
|
|
||||||
|
import net.minecraft.core.registries.Registries
|
||||||
|
import net.minecraft.resources.ResourceKey
|
||||||
|
import net.minecraft.resources.ResourceLocation
|
||||||
|
import net.minecraft.server.MinecraftServer
|
||||||
|
import net.minecraft.server.level.ServerLevel
|
||||||
|
import net.minecraft.world.level.Level
|
||||||
|
|
||||||
|
object DimensionUtils {
|
||||||
|
fun getLevelById(server: MinecraftServer, dimensionId: String): ServerLevel? {
|
||||||
|
val rl = ResourceLocation.tryParse(dimensionId) ?: return null
|
||||||
|
val key: ResourceKey<Level> = ResourceKey.create(Registries.DIMENSION, rl)
|
||||||
|
return server.getLevel(key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package com.beemer.essentials.util
|
||||||
|
|
||||||
|
object TimeUtils {
|
||||||
|
fun formatPlayTimeMillis(ms: Long): String {
|
||||||
|
var seconds = ms / 1000
|
||||||
|
val days = seconds / (24 * 3600)
|
||||||
|
seconds %= (24 * 3600)
|
||||||
|
val hours = seconds / 3600
|
||||||
|
seconds %= 3600
|
||||||
|
val minutes = seconds / 60
|
||||||
|
seconds %= 60
|
||||||
|
|
||||||
|
return buildString {
|
||||||
|
if (days > 0) append("${days}일 ")
|
||||||
|
if (hours > 0) append("${hours}시간 ")
|
||||||
|
if (minutes > 0 || hours > 0 || days > 0) append("${minutes}분 ")
|
||||||
|
append("${seconds}초")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.beemer.essentials.util
|
||||||
|
|
||||||
|
import com.google.common.reflect.TypeToken
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import java.io.InputStreamReader
|
||||||
|
|
||||||
|
object TranslationUtils {
|
||||||
|
private val translations = mutableMapOf<String, String>()
|
||||||
|
private val gson = Gson()
|
||||||
|
private val mapType = object : TypeToken<Map<String, String>>() {}.type
|
||||||
|
|
||||||
|
fun loadTranslations() {
|
||||||
|
try {
|
||||||
|
this::class.java.getResourceAsStream("/assets/essentials/lang/ko_kr.json")?.use { stream ->
|
||||||
|
InputStreamReader(stream).use { reader ->
|
||||||
|
val items: Map<String, String> = gson.fromJson(reader, mapType)
|
||||||
|
translations.putAll(items)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
e.printStackTrace()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun translate(prefix: String, id: String): String {
|
||||||
|
val key = "$prefix.${id.replace(':', '.')}"
|
||||||
|
return translations[key] ?: id
|
||||||
|
}
|
||||||
|
|
||||||
|
fun translateBiome(id: String) = translate("biome", id)
|
||||||
|
|
||||||
|
fun translateDimension(id: String) = translate("dimension", id)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
{
|
||||||
|
"dimension.minecraft.overworld": "오버월드",
|
||||||
|
"dimension.minecraft.the_nether": "네더",
|
||||||
|
"dimension.minecraft.the_end": "엔드",
|
||||||
|
"biome.minecraft.badlands": "악지",
|
||||||
|
"biome.minecraft.bamboo_jungle": "대나무 정글",
|
||||||
|
"biome.minecraft.basalt_deltas": "현무암 삼각주",
|
||||||
|
"biome.minecraft.beach": "해변",
|
||||||
|
"biome.minecraft.birch_forest": "자작나무 숲",
|
||||||
|
"biome.minecraft.cherry_grove": "벚나무 숲",
|
||||||
|
"biome.minecraft.cold_ocean": "차가운 바다",
|
||||||
|
"biome.minecraft.crimson_forest": "진홍빛 숲",
|
||||||
|
"biome.minecraft.dark_forest": "어두운 숲",
|
||||||
|
"biome.minecraft.deep_cold_ocean": "깊고 차가운 바다",
|
||||||
|
"biome.minecraft.deep_dark": "깊은 어둠",
|
||||||
|
"biome.minecraft.deep_frozen_ocean": "깊고 얼어붙은 바다",
|
||||||
|
"biome.minecraft.deep_lukewarm_ocean": "깊고 미지근한 바다",
|
||||||
|
"biome.minecraft.deep_ocean": "깊은 바다",
|
||||||
|
"biome.minecraft.desert": "사막",
|
||||||
|
"biome.minecraft.dripstone_caves": "점적석 동굴",
|
||||||
|
"biome.minecraft.end_barrens": "엔드 불모지",
|
||||||
|
"biome.minecraft.end_highlands": "엔드 고지",
|
||||||
|
"biome.minecraft.end_midlands": "엔드 중지",
|
||||||
|
"biome.minecraft.eroded_badlands": "침식된 악지",
|
||||||
|
"biome.minecraft.flower_forest": "꽃 숲",
|
||||||
|
"biome.minecraft.forest": "숲",
|
||||||
|
"biome.minecraft.frozen_ocean": "얼어붙은 바다",
|
||||||
|
"biome.minecraft.frozen_peaks": "얼어붙은 봉우리",
|
||||||
|
"biome.minecraft.frozen_river": "얼어붙은 강",
|
||||||
|
"biome.minecraft.grove": "산림",
|
||||||
|
"biome.minecraft.ice_spikes": "역고드름",
|
||||||
|
"biome.minecraft.jagged_peaks": "뾰족한 봉우리",
|
||||||
|
"biome.minecraft.jungle": "정글",
|
||||||
|
"biome.minecraft.lukewarm_ocean": "미지근한 바다",
|
||||||
|
"biome.minecraft.lush_caves": "무성한 동굴",
|
||||||
|
"biome.minecraft.mangrove_swamp": "맹그로브 늪",
|
||||||
|
"biome.minecraft.meadow": "목초지",
|
||||||
|
"biome.minecraft.mushroom_fields": "버섯 들판",
|
||||||
|
"biome.minecraft.nether_wastes": "네더 황무지",
|
||||||
|
"biome.minecraft.ocean": "바다",
|
||||||
|
"biome.minecraft.old_growth_birch_forest": "자작나무 원시림",
|
||||||
|
"biome.minecraft.old_growth_pine_taiga": "소나무 원시 타이가",
|
||||||
|
"biome.minecraft.old_growth_spruce_taiga": "가문비나무 원시 타이가",
|
||||||
|
"biome.minecraft.plains": "평원",
|
||||||
|
"biome.minecraft.river": "강",
|
||||||
|
"biome.minecraft.savanna": "사바나",
|
||||||
|
"biome.minecraft.savanna_plateau": "사바나 고원",
|
||||||
|
"biome.minecraft.small_end_islands": "작은 엔드 섬",
|
||||||
|
"biome.minecraft.snowy_beach": "눈 덮인 해변",
|
||||||
|
"biome.minecraft.snowy_plains": "눈 덮인 평원",
|
||||||
|
"biome.minecraft.snowy_slopes": "눈 덮인 비탈",
|
||||||
|
"biome.minecraft.snowy_taiga": "눈 덮인 타이가",
|
||||||
|
"biome.minecraft.soul_sand_valley": "영혼 모래 골짜기",
|
||||||
|
"biome.minecraft.sparse_jungle": "듬성듬성한 정글",
|
||||||
|
"biome.minecraft.stony_peaks": "돌 봉우리",
|
||||||
|
"biome.minecraft.stony_shore": "돌 해안",
|
||||||
|
"biome.minecraft.sunflower_plains": "해바라기 평원",
|
||||||
|
"biome.minecraft.swamp": "늪",
|
||||||
|
"biome.minecraft.taiga": "타이가",
|
||||||
|
"biome.minecraft.the_end": "엔드",
|
||||||
|
"biome.minecraft.the_void": "공허",
|
||||||
|
"biome.minecraft.warm_ocean": "따뜻한 바다",
|
||||||
|
"biome.minecraft.warped_forest": "뒤틀린 숲",
|
||||||
|
"biome.minecraft.windswept_forest": "바람이 세찬 숲",
|
||||||
|
"biome.minecraft.windswept_gravelly_hills": "바람이 세찬 자갈투성이 언덕",
|
||||||
|
"biome.minecraft.windswept_hills": "바람이 세찬 언덕",
|
||||||
|
"biome.minecraft.windswept_savanna": "바람이 세찬 사바나",
|
||||||
|
"biome.minecraft.wooded_badlands": "나무가 우거진 악지"
|
||||||
|
}
|
||||||
19
Essentials/src/main/resources/essentials.mixins.json
Normal file
19
Essentials/src/main/resources/essentials.mixins.json
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"required": true,
|
||||||
|
"package": "com.beemer.essentials.mixin",
|
||||||
|
"compatibilityLevel": "JAVA_21",
|
||||||
|
"mixins": [
|
||||||
|
"CreeperMixin",
|
||||||
|
"EndermanTakeBlockGoalMixin",
|
||||||
|
"FarmBlockMixin",
|
||||||
|
"LargeFireballMixin",
|
||||||
|
"PlayerInfoPacketMixin",
|
||||||
|
"PlayerListMixin",
|
||||||
|
"PlayerMixin",
|
||||||
|
"ServerPlayerMixin"
|
||||||
|
],
|
||||||
|
"client": [],
|
||||||
|
"injectors": {
|
||||||
|
"defaultRequire": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
79
Essentials/src/main/templates/META-INF/neoforge.mods.toml
Normal file
79
Essentials/src/main/templates/META-INF/neoforge.mods.toml
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# This is an example mods.toml file. It contains the data relating to the loading mods.
|
||||||
|
# There are several mandatory fields (#mandatory), and many more that are optional (#optional).
|
||||||
|
# The overall format is standard TOML format, v0.5.0.
|
||||||
|
# Note that there are a couple of TOML lists in this file.
|
||||||
|
# Find more information on toml format here: https://github.com/toml-lang/toml
|
||||||
|
# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml
|
||||||
|
modLoader = "kotlinforforge" #mandatory
|
||||||
|
# A version range to match for said mod loader - for regular FML @Mod it will be the the FML version. This is currently 47.
|
||||||
|
loaderVersion = "${loader_version_range}" #mandatory
|
||||||
|
# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties.
|
||||||
|
# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here.
|
||||||
|
license = "${mod_license}"
|
||||||
|
# A URL to refer people to when problems occur with this mod
|
||||||
|
#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional
|
||||||
|
# A list of mods - how many allowed here is determined by the individual mod loader
|
||||||
|
[[mods]] #mandatory
|
||||||
|
# The modid of the mod
|
||||||
|
modId = "${mod_id}" #mandatory
|
||||||
|
# The version number of the mod
|
||||||
|
version = "${mod_version}" #mandatory
|
||||||
|
# A display name for the mod
|
||||||
|
displayName = "${mod_name}" #mandatory
|
||||||
|
# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforge.net/docs/misc/updatechecker/
|
||||||
|
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
|
||||||
|
# A URL for the "homepage" for this mod, displayed in the mod UI
|
||||||
|
#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional
|
||||||
|
# A file name (in the root of the mod JAR) containing a logo for display
|
||||||
|
#logoFile="essentials.png" #optional
|
||||||
|
# A text field displayed in the mod UI
|
||||||
|
#credits="" #optional
|
||||||
|
# A text field displayed in the mod UI
|
||||||
|
authors = "${mod_authors}" #optional
|
||||||
|
|
||||||
|
# The description text for the mod (multi line!) (#mandatory)
|
||||||
|
description = '''${mod_description}'''
|
||||||
|
|
||||||
|
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
|
||||||
|
[[mixins]]
|
||||||
|
config="${mod_id}.mixins.json"
|
||||||
|
|
||||||
|
# The [[accessTransformers]] block allows you to declare where your AT file is.
|
||||||
|
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg
|
||||||
|
#[[accessTransformers]]
|
||||||
|
#file="META-INF/accesstransformer.cfg"
|
||||||
|
|
||||||
|
# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json
|
||||||
|
|
||||||
|
# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional.
|
||||||
|
[[dependencies."${mod_id}"]] #optional
|
||||||
|
# the modid of the dependency
|
||||||
|
modId = "neoforge" #mandatory
|
||||||
|
# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive).
|
||||||
|
# 'required' requires the mod to exist, 'optional' does not
|
||||||
|
# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning
|
||||||
|
type = "required" #mandatory
|
||||||
|
# Optional field describing why the dependency is required or why it is incompatible
|
||||||
|
# reason="..."
|
||||||
|
# The version range of the dependency
|
||||||
|
versionRange = "${neo_version_range}" #mandatory
|
||||||
|
# An ordering relationship for the dependency.
|
||||||
|
# BEFORE - This mod is loaded BEFORE the dependency
|
||||||
|
# AFTER - This mod is loaded AFTER the dependency
|
||||||
|
ordering = "NONE"
|
||||||
|
# Side this dependency is applied on - BOTH, CLIENT, or SERVER
|
||||||
|
side = "BOTH"
|
||||||
|
# Here's another dependency
|
||||||
|
[[dependencies."${mod_id}"]]
|
||||||
|
modId = "minecraft"
|
||||||
|
type = "required"
|
||||||
|
# This version range declares a minimum of the current minecraft version up to but not including the next major version
|
||||||
|
versionRange = "${minecraft_version_range}"
|
||||||
|
ordering = "NONE"
|
||||||
|
side = "BOTH"
|
||||||
|
|
||||||
|
# Features are specific properties of the game environment, that you may want to declare you require. This example declares
|
||||||
|
# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't
|
||||||
|
# stop your mod loading on the server for example.
|
||||||
|
#[features."${mod_id}"]
|
||||||
|
#openGLVersion="[3.2,)"
|
||||||
55
README.md
Normal file
55
README.md
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
# ⛏️ Minecraft Mods
|
||||||
|
|
||||||
|
NeoForge 1.21.1 기반 마인크래프트 서버 모드 모음입니다.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 모드 목록
|
||||||
|
|
||||||
|
| 모드 | 설명 |
|
||||||
|
| ------------------------------- | ------------------------------------- |
|
||||||
|
| [Essentials](./Essentials/) | 서버 필수 기능 (좌표 관리, 닉네임 등) |
|
||||||
|
| [ServerStatus](./ServerStatus/) | HTTP API로 서버 상태 제공 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
|
| 기술 | 설명 |
|
||||||
|
| -------------------- | --------------------- |
|
||||||
|
| **NeoForge** | Minecraft 모딩 플랫폼 |
|
||||||
|
| **Kotlin** | 주 개발 언어 |
|
||||||
|
| **Kotlin for Forge** | NeoForge Kotlin 지원 |
|
||||||
|
| **Java 21** | JVM 버전 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 빌드 방법
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 각 모드 디렉토리에서
|
||||||
|
./gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드된 JAR 파일은 `build/libs/`에 생성됩니다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 프로젝트 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
minecraft-mod/
|
||||||
|
├── Essentials/ # 서버 필수 기능 모드
|
||||||
|
├── ServerStatus/ # 서버 상태 API 모드
|
||||||
|
└── .gitignore
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 라이선스
|
||||||
|
|
||||||
|
MIT License
|
||||||
79
ServerStatus/README.md
Normal file
79
ServerStatus/README.md
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
# 🌐 ServerStatus
|
||||||
|
|
||||||
|
HTTP API로 마인크래프트 서버 상태를 제공하는 NeoForge 모드입니다.
|
||||||
|
|
||||||
|

|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ 주요 기능
|
||||||
|
|
||||||
|
- 📊 **서버 상태 API** - 실시간 서버 정보 제공
|
||||||
|
- 👥 **플레이어 목록** - 접속 중인 플레이어 정보
|
||||||
|
- ⚡ **TPS 모니터링** - 서버 성능 지표
|
||||||
|
- 🗺️ **월드 정보** - 로드된 월드 및 청크 정보
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📡 API 엔드포인트
|
||||||
|
|
||||||
|
기본 포트: **25580**
|
||||||
|
|
||||||
|
### 서버 상태
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /api/status
|
||||||
|
```
|
||||||
|
|
||||||
|
응답 예시:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"online": true,
|
||||||
|
"players": {
|
||||||
|
"online": 5,
|
||||||
|
"max": 20,
|
||||||
|
"list": ["Player1", "Player2"]
|
||||||
|
},
|
||||||
|
"tps": 20.0,
|
||||||
|
"version": "1.21.1"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 기술 스택
|
||||||
|
|
||||||
|
| 기술 | 설명 |
|
||||||
|
| ------------------------- | --------------------- |
|
||||||
|
| **NeoForge** | Minecraft 모딩 플랫폼 |
|
||||||
|
| **Kotlin** | 주 개발 언어 |
|
||||||
|
| **Java HttpServer** | 내장 HTTP 서버 |
|
||||||
|
| **kotlinx.serialization** | JSON 직렬화 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📁 구조
|
||||||
|
|
||||||
|
```
|
||||||
|
ServerStatus/
|
||||||
|
├── src/main/
|
||||||
|
│ ├── kotlin/co/caadiq/serverstatus/
|
||||||
|
│ │ ├── api/ # HTTP API 핸들러
|
||||||
|
│ │ └── ServerStatus.kt
|
||||||
|
│ └── resources/
|
||||||
|
│ └── META-INF/ # 모드 메타데이터
|
||||||
|
└── build.gradle.kts
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 빌드
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew build
|
||||||
|
```
|
||||||
|
|
||||||
|
빌드된 JAR: `build/libs/serverstatus-1.0.0.jar`
|
||||||
77
ServerStatus/build.gradle.kts
Normal file
77
ServerStatus/build.gradle.kts
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
plugins {
|
||||||
|
id("net.neoforged.moddev") version "2.0.80"
|
||||||
|
kotlin("jvm") version "2.0.0"
|
||||||
|
kotlin("plugin.serialization") version "2.0.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
version = project.property("mod_version") as String
|
||||||
|
group = project.property("mod_group_id") as String
|
||||||
|
|
||||||
|
repositories {
|
||||||
|
mavenCentral()
|
||||||
|
maven {
|
||||||
|
name = "Kotlin for Forge"
|
||||||
|
url = uri("https://thedarkcolour.github.io/KotlinForForge/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
base {
|
||||||
|
archivesName.set(project.property("mod_id") as String)
|
||||||
|
}
|
||||||
|
|
||||||
|
java {
|
||||||
|
toolchain {
|
||||||
|
languageVersion.set(JavaLanguageVersion.of(21))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
neoForge {
|
||||||
|
version = project.property("neo_version") as String
|
||||||
|
|
||||||
|
runs {
|
||||||
|
create("server") {
|
||||||
|
server()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mods {
|
||||||
|
create(project.property("mod_id") as String) {
|
||||||
|
sourceSet(sourceSets.main.get())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// Kotlin for Forge (kotlinx-serialization 포함)
|
||||||
|
implementation("thedarkcolour:kotlinforforge-neoforge:${project.property("kotlin_for_forge_version")}")
|
||||||
|
// 외부 의존성 없음 - Java 내장 HttpServer 사용
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.withType<JavaCompile>().configureEach {
|
||||||
|
options.encoding = "UTF-8"
|
||||||
|
}
|
||||||
|
|
||||||
|
tasks.processResources {
|
||||||
|
val replaceProperties = mapOf(
|
||||||
|
"mod_id" to project.property("mod_id"),
|
||||||
|
"mod_name" to project.property("mod_name"),
|
||||||
|
"mod_license" to project.property("mod_license"),
|
||||||
|
"mod_version" to project.property("mod_version"),
|
||||||
|
"mod_authors" to project.property("mod_authors"),
|
||||||
|
"mod_description" to "Provides server status via HTTP API",
|
||||||
|
"neo_version_range" to project.property("neo_version_range"),
|
||||||
|
"loader_version_range" to project.property("loader_version_range"),
|
||||||
|
"minecraft_version_range" to project.property("minecraft_version_range")
|
||||||
|
)
|
||||||
|
inputs.properties(replaceProperties)
|
||||||
|
|
||||||
|
filesMatching(listOf("META-INF/neoforge.mods.toml")) {
|
||||||
|
expand(replaceProperties)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
ServerStatus/gradle.properties
Normal file
24
ServerStatus/gradle.properties
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# Mod 기본 설정
|
||||||
|
mod_id=serverstatus
|
||||||
|
mod_name=Server Status
|
||||||
|
mod_license=MIT
|
||||||
|
mod_version=1.0.0
|
||||||
|
mod_group_id=co.caadiq.serverstatus
|
||||||
|
mod_authors=Caadiq
|
||||||
|
|
||||||
|
# NeoForge 버전
|
||||||
|
minecraft_version=1.21.1
|
||||||
|
minecraft_version_range=[1.21.1,1.22)
|
||||||
|
neo_version=21.1.77
|
||||||
|
neo_version_range=[21.1.0,)
|
||||||
|
loader_version_range=[4,)
|
||||||
|
|
||||||
|
# Kotlin 버전
|
||||||
|
kotlin_version=2.0.0
|
||||||
|
kotlin_for_forge_version=5.6.0
|
||||||
|
|
||||||
|
# Gradle
|
||||||
|
org.gradle.jvmargs=-Xmx3G
|
||||||
|
org.gradle.daemon=true
|
||||||
|
org.gradle.parallel=true
|
||||||
|
org.gradle.caching=true
|
||||||
BIN
ServerStatus/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
BIN
ServerStatus/gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
7
ServerStatus/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
7
ServerStatus/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
distributionBase=GRADLE_USER_HOME
|
||||||
|
distributionPath=wrapper/dists
|
||||||
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.10-bin.zip
|
||||||
|
networkTimeout=10000
|
||||||
|
validateDistributionUrl=true
|
||||||
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
zipStorePath=wrapper/dists
|
||||||
39
ServerStatus/gradlew
vendored
Executable file
39
ServerStatus/gradlew
vendored
Executable file
|
|
@ -0,0 +1,39 @@
|
||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
#
|
||||||
|
# Gradle start up script for POSIX
|
||||||
|
#
|
||||||
|
|
||||||
|
# Resolve links: $0 may be a link
|
||||||
|
app_path=$0
|
||||||
|
while [ -h "$app_path" ]; do
|
||||||
|
ls=$( ls -ld "$app_path" )
|
||||||
|
link=${ls#*' -> '}
|
||||||
|
case $link in
|
||||||
|
/*) app_path=$link ;;
|
||||||
|
*) app_path=$( dirname "$app_path" )/$link ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
APP_HOME=$( cd "$( dirname "$app_path" )" && pwd -P )
|
||||||
|
APP_NAME="Gradle"
|
||||||
|
APP_BASE_NAME=$( basename "$0" )
|
||||||
|
|
||||||
|
# Add default JVM options here
|
||||||
|
DEFAULT_JVM_OPTS="-Xmx64m -Xms64m"
|
||||||
|
|
||||||
|
# Classpath
|
||||||
|
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||||
|
|
||||||
|
# Find Java
|
||||||
|
if [ -n "$JAVA_HOME" ] ; then
|
||||||
|
JAVACMD=$JAVA_HOME/bin/java
|
||||||
|
else
|
||||||
|
JAVACMD=java
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Execute
|
||||||
|
exec "$JAVACMD" $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS \
|
||||||
|
-classpath "$CLASSPATH" \
|
||||||
|
org.gradle.wrapper.GradleWrapperMain \
|
||||||
|
"$@"
|
||||||
16
ServerStatus/settings.gradle.kts
Normal file
16
ServerStatus/settings.gradle.kts
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
mavenLocal()
|
||||||
|
gradlePluginPortal()
|
||||||
|
maven {
|
||||||
|
name = "NeoForged"
|
||||||
|
url = uri("https://maven.neoforged.net/releases")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plugins {
|
||||||
|
id("org.gradle.toolchains.foojay-resolver-convention") version "0.8.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "ServerStatus"
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
package co.caadiq.serverstatus
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.config.ModConfig
|
||||||
|
import co.caadiq.serverstatus.config.PlayerDataStore
|
||||||
|
import co.caadiq.serverstatus.data.PlayerTracker
|
||||||
|
import co.caadiq.serverstatus.data.ServerDataCollector
|
||||||
|
import co.caadiq.serverstatus.data.WorldDataCollector
|
||||||
|
import co.caadiq.serverstatus.data.PlayerStatsCollector
|
||||||
|
import co.caadiq.serverstatus.network.HttpApiServer
|
||||||
|
import net.neoforged.bus.api.IEventBus
|
||||||
|
import net.neoforged.fml.ModContainer
|
||||||
|
import net.neoforged.fml.common.Mod
|
||||||
|
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
|
||||||
|
import net.neoforged.neoforge.common.NeoForge
|
||||||
|
import org.apache.logging.log4j.LogManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 메인 모드 클래스
|
||||||
|
* 서버 상태 정보를 HTTP API로 제공
|
||||||
|
*/
|
||||||
|
@Mod(ServerStatusMod.MOD_ID)
|
||||||
|
class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val MOD_ID = "serverstatus"
|
||||||
|
val LOGGER = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
|
lateinit var config: ModConfig
|
||||||
|
private set
|
||||||
|
lateinit var playerDataStore: PlayerDataStore
|
||||||
|
private set
|
||||||
|
lateinit var serverDataCollector: ServerDataCollector
|
||||||
|
private set
|
||||||
|
lateinit var worldDataCollector: WorldDataCollector
|
||||||
|
private set
|
||||||
|
lateinit var playerStatsCollector: PlayerStatsCollector
|
||||||
|
private set
|
||||||
|
lateinit var httpApiServer: HttpApiServer
|
||||||
|
private set
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
LOGGER.info("[$MOD_ID] 모드 초기화 중...")
|
||||||
|
|
||||||
|
// 설정 초기화
|
||||||
|
config = ModConfig.load()
|
||||||
|
playerDataStore = PlayerDataStore.load()
|
||||||
|
|
||||||
|
// 서버 전용 이벤트 등록
|
||||||
|
modBus.addListener(::onServerSetup)
|
||||||
|
|
||||||
|
// 플레이어 이벤트 리스너 등록
|
||||||
|
NeoForge.EVENT_BUS.register(PlayerTracker)
|
||||||
|
|
||||||
|
LOGGER.info("[$MOD_ID] 모드 초기화 완료")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전용 서버 설정 이벤트
|
||||||
|
*/
|
||||||
|
private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
|
||||||
|
LOGGER.info("[$MOD_ID] 서버 설정 중...")
|
||||||
|
|
||||||
|
// 데이터 수집기 초기화
|
||||||
|
serverDataCollector = ServerDataCollector()
|
||||||
|
worldDataCollector = WorldDataCollector()
|
||||||
|
playerStatsCollector = PlayerStatsCollector()
|
||||||
|
|
||||||
|
// HTTP API 서버 시작
|
||||||
|
httpApiServer = HttpApiServer(config.httpPort)
|
||||||
|
httpApiServer.start()
|
||||||
|
|
||||||
|
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package co.caadiq.serverstatus.config
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모드 설정 클래스
|
||||||
|
* config/serverstatus/ 폴더에 저장
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ModConfig(
|
||||||
|
val httpPort: Int = 8080
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val json = Json {
|
||||||
|
prettyPrint = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// config/serverstatus/ 폴더에 저장
|
||||||
|
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||||
|
private val configPath = configDir.resolve("config.json")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 파일 로드 (없으면 기본값으로 생성)
|
||||||
|
*/
|
||||||
|
fun load(): ModConfig {
|
||||||
|
return try {
|
||||||
|
if (configPath.exists()) {
|
||||||
|
json.decodeFromString<ModConfig>(configPath.readText())
|
||||||
|
} else {
|
||||||
|
// 기본 설정 생성
|
||||||
|
val default = ModConfig()
|
||||||
|
save(default)
|
||||||
|
default
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("설정 로드 실패, 기본값 사용: ${e.message}")
|
||||||
|
ModConfig()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 설정 저장
|
||||||
|
*/
|
||||||
|
fun save(config: ModConfig) {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(configDir)
|
||||||
|
configPath.writeText(json.encodeToString(serializer(), config))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("설정 저장 실패: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
package co.caadiq.serverstatus.config
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import co.caadiq.serverstatus.data.PlayerStats
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.Transient
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 데이터 저장소
|
||||||
|
* 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리
|
||||||
|
* config/serverstatus/ 폴더에 저장
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayerData(
|
||||||
|
val uuid: String,
|
||||||
|
var name: String,
|
||||||
|
var firstJoin: Long = System.currentTimeMillis(),
|
||||||
|
var lastJoin: Long = System.currentTimeMillis(),
|
||||||
|
var lastLeave: Long = 0L,
|
||||||
|
var totalPlayTimeMs: Long = 0L,
|
||||||
|
// 마지막 로그아웃 시 저장된 통계
|
||||||
|
var savedStats: PlayerStats? = null,
|
||||||
|
@Transient
|
||||||
|
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
||||||
|
) {
|
||||||
|
/**
|
||||||
|
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
|
||||||
|
*/
|
||||||
|
fun getCurrentSessionMs(): Long {
|
||||||
|
return if (isOnline) {
|
||||||
|
System.currentTimeMillis() - lastJoin
|
||||||
|
} else {
|
||||||
|
0L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 실시간 총 플레이타임 (ms) - 누적 + 현재 세션
|
||||||
|
*/
|
||||||
|
fun getRealTimeTotalMs(): Long {
|
||||||
|
return totalPlayTimeMs + getCurrentSessionMs()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlayerDataStore(
|
||||||
|
val players: MutableMap<String, PlayerData> = mutableMapOf()
|
||||||
|
) {
|
||||||
|
companion object {
|
||||||
|
private val json = Json {
|
||||||
|
prettyPrint = true
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// config/serverstatus/ 폴더에 저장
|
||||||
|
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||||
|
private val dataPath = configDir.resolve("players.json")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 데이터 로드
|
||||||
|
*/
|
||||||
|
fun load(): PlayerDataStore {
|
||||||
|
return try {
|
||||||
|
if (dataPath.exists()) {
|
||||||
|
json.decodeFromString<PlayerDataStore>(dataPath.readText())
|
||||||
|
} else {
|
||||||
|
PlayerDataStore()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("플레이어 데이터 로드 실패: ${e.message}")
|
||||||
|
PlayerDataStore()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 데이터 저장
|
||||||
|
*/
|
||||||
|
fun save() {
|
||||||
|
try {
|
||||||
|
Files.createDirectories(configDir)
|
||||||
|
dataPath.writeText(json.encodeToString(serializer(), this))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 입장 처리
|
||||||
|
*/
|
||||||
|
fun onPlayerJoin(uuid: String, name: String) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val player = players.getOrPut(uuid) {
|
||||||
|
PlayerData(uuid = uuid, name = name, firstJoin = now)
|
||||||
|
}
|
||||||
|
player.name = name
|
||||||
|
player.lastJoin = now
|
||||||
|
player.isOnline = true
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 퇴장 처리 (통계 저장 포함)
|
||||||
|
*/
|
||||||
|
fun onPlayerLeave(uuid: String, stats: PlayerStats?) {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
players[uuid]?.let { player ->
|
||||||
|
player.lastLeave = now
|
||||||
|
// 이번 세션 플레이타임을 누적에 추가
|
||||||
|
player.totalPlayTimeMs += (now - player.lastJoin)
|
||||||
|
player.isOnline = false
|
||||||
|
// 통계 저장
|
||||||
|
if (stats != null) {
|
||||||
|
player.savedStats = stats
|
||||||
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}")
|
||||||
|
}
|
||||||
|
save()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 정보 조회
|
||||||
|
*/
|
||||||
|
fun getPlayer(uuid: String): PlayerData? = players[uuid]
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 플레이어 목록 조회
|
||||||
|
*/
|
||||||
|
fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 온라인 상태 확인
|
||||||
|
*/
|
||||||
|
fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장된 통계 조회
|
||||||
|
*/
|
||||||
|
fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,201 @@
|
||||||
|
package co.caadiq.serverstatus.data
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import net.minecraft.core.registries.BuiltInRegistries
|
||||||
|
import net.minecraft.stats.Stats
|
||||||
|
import net.neoforged.neoforge.server.ServerLifecycleHooks
|
||||||
|
|
||||||
|
/** 플레이어 통계 수집기 모든 블록과 엔티티의 통계를 동적으로 수집 */
|
||||||
|
class PlayerStatsCollector {
|
||||||
|
|
||||||
|
/** 플레이어 통계 수집 */
|
||||||
|
fun collectStats(uuid: String): PlayerStats? {
|
||||||
|
val server = ServerLifecycleHooks.getCurrentServer() ?: return null
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val playerUuid = UUID.fromString(uuid)
|
||||||
|
val player = server.playerList.getPlayer(playerUuid) ?: return null
|
||||||
|
val statsManager = server.playerList.getPlayerStats(player)
|
||||||
|
|
||||||
|
// 일반 통계
|
||||||
|
val general =
|
||||||
|
GeneralStats(
|
||||||
|
playTime = statsManager.getValue(Stats.CUSTOM.get(Stats.PLAY_TIME)),
|
||||||
|
deaths = statsManager.getValue(Stats.CUSTOM.get(Stats.DEATHS)),
|
||||||
|
mobKills = statsManager.getValue(Stats.CUSTOM.get(Stats.MOB_KILLS)),
|
||||||
|
playerKills =
|
||||||
|
statsManager.getValue(Stats.CUSTOM.get(Stats.PLAYER_KILLS)),
|
||||||
|
damageDealt =
|
||||||
|
statsManager.getValue(Stats.CUSTOM.get(Stats.DAMAGE_DEALT)),
|
||||||
|
damageTaken =
|
||||||
|
statsManager.getValue(Stats.CUSTOM.get(Stats.DAMAGE_TAKEN)),
|
||||||
|
jumps = statsManager.getValue(Stats.CUSTOM.get(Stats.JUMP)),
|
||||||
|
distanceWalked =
|
||||||
|
statsManager.getValue(Stats.CUSTOM.get(Stats.WALK_ONE_CM)) /
|
||||||
|
100,
|
||||||
|
distanceFlown =
|
||||||
|
(statsManager.getValue(Stats.CUSTOM.get(Stats.FLY_ONE_CM)) +
|
||||||
|
statsManager.getValue(
|
||||||
|
Stats.CUSTOM.get(Stats.AVIATE_ONE_CM)
|
||||||
|
)) / 100,
|
||||||
|
distanceSwum =
|
||||||
|
statsManager.getValue(Stats.CUSTOM.get(Stats.SWIM_ONE_CM)) / 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// 아이템 통계 (블록/아이템별로 채굴, 설치, 획득, 제작)
|
||||||
|
val itemStats = mutableMapOf<String, ItemStat>()
|
||||||
|
|
||||||
|
// 블록 채굴 통계
|
||||||
|
BuiltInRegistries.BLOCK.forEach { block ->
|
||||||
|
val mined = statsManager.getValue(Stats.BLOCK_MINED.get(block))
|
||||||
|
if (mined > 0) {
|
||||||
|
val blockId = BuiltInRegistries.BLOCK.getKey(block)?.path ?: return@forEach
|
||||||
|
itemStats.getOrPut(blockId) { ItemStat() }.mined = mined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아이템 획득 통계
|
||||||
|
BuiltInRegistries.ITEM.forEach { item ->
|
||||||
|
val picked = statsManager.getValue(Stats.ITEM_PICKED_UP.get(item))
|
||||||
|
if (picked > 0) {
|
||||||
|
val itemId = BuiltInRegistries.ITEM.getKey(item)?.path ?: return@forEach
|
||||||
|
itemStats.getOrPut(itemId) { ItemStat() }.pickedUp = picked
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아이템 사용(설치) 통계
|
||||||
|
BuiltInRegistries.ITEM.forEach { item ->
|
||||||
|
val used = statsManager.getValue(Stats.ITEM_USED.get(item))
|
||||||
|
if (used > 0) {
|
||||||
|
val itemId = BuiltInRegistries.ITEM.getKey(item)?.path ?: return@forEach
|
||||||
|
itemStats.getOrPut(itemId) { ItemStat() }.used = used
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 아이템 제작 통계
|
||||||
|
BuiltInRegistries.ITEM.forEach { item ->
|
||||||
|
val crafted = statsManager.getValue(Stats.ITEM_CRAFTED.get(item))
|
||||||
|
if (crafted > 0) {
|
||||||
|
val itemId = BuiltInRegistries.ITEM.getKey(item)?.path ?: return@forEach
|
||||||
|
itemStats.getOrPut(itemId) { ItemStat() }.crafted = crafted
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 몹 통계 (죽인것, 죽은것)
|
||||||
|
val mobStats = mutableMapOf<String, MobStat>()
|
||||||
|
|
||||||
|
// 엔티티 처치 통계
|
||||||
|
BuiltInRegistries.ENTITY_TYPE.forEach { entityType ->
|
||||||
|
try {
|
||||||
|
val killed = statsManager.getValue(Stats.ENTITY_KILLED.get(entityType))
|
||||||
|
if (killed > 0) {
|
||||||
|
val entityId =
|
||||||
|
BuiltInRegistries.ENTITY_TYPE.getKey(entityType)?.path
|
||||||
|
?: return@forEach
|
||||||
|
mobStats.getOrPut(entityId) { MobStat() }.killed = killed
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 엔티티에게 죽은 통계
|
||||||
|
BuiltInRegistries.ENTITY_TYPE.forEach { entityType ->
|
||||||
|
try {
|
||||||
|
val killedBy = statsManager.getValue(Stats.ENTITY_KILLED_BY.get(entityType))
|
||||||
|
if (killedBy > 0) {
|
||||||
|
val entityId =
|
||||||
|
BuiltInRegistries.ENTITY_TYPE.getKey(entityType)?.path
|
||||||
|
?: return@forEach
|
||||||
|
mobStats.getOrPut(entityId) { MobStat() }.killedBy = killedBy
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map을 직렬화 가능한 형태로 변환
|
||||||
|
val itemStatsSerializable =
|
||||||
|
itemStats.mapValues { (_, stat) ->
|
||||||
|
mapOf(
|
||||||
|
"mined" to stat.mined,
|
||||||
|
"used" to stat.used,
|
||||||
|
"pickedUp" to stat.pickedUp,
|
||||||
|
"crafted" to stat.crafted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val mobStatsSerializable =
|
||||||
|
mobStats.mapValues { (_, stat) ->
|
||||||
|
mapOf("killed" to stat.killed, "killedBy" to stat.killedBy)
|
||||||
|
}
|
||||||
|
|
||||||
|
PlayerStats(
|
||||||
|
uuid = uuid,
|
||||||
|
general = general,
|
||||||
|
items = itemStatsSerializable,
|
||||||
|
mobs = mobStatsSerializable
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("플레이어 통계 수집 오류: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 플레이어 도전 과제 수집 (추후 구현) */
|
||||||
|
fun collectAdvancements(uuid: String): AdvancementsInfo? {
|
||||||
|
// NeoForge 1.21.1 API 호환성 문제로 추후 구현
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 내부 헬퍼 클래스
|
||||||
|
class ItemStat {
|
||||||
|
var mined: Int = 0
|
||||||
|
var used: Int = 0
|
||||||
|
var pickedUp: Int = 0
|
||||||
|
var crafted: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
class MobStat {
|
||||||
|
var killed: Int = 0
|
||||||
|
var killedBy: Int = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlayerStats(
|
||||||
|
val uuid: String,
|
||||||
|
val general: GeneralStats,
|
||||||
|
val items: Map<String, Map<String, Int>>, // 아이템 ID -> {mined, used, pickedUp, crafted}
|
||||||
|
val mobs: Map<String, Map<String, Int>> // 엔티티 ID -> {killed, killedBy}
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class GeneralStats(
|
||||||
|
val playTime: Int, // 틱
|
||||||
|
val deaths: Int,
|
||||||
|
val mobKills: Int,
|
||||||
|
val playerKills: Int,
|
||||||
|
val damageDealt: Int,
|
||||||
|
val damageTaken: Int,
|
||||||
|
val jumps: Int,
|
||||||
|
val distanceWalked: Int, // 미터
|
||||||
|
val distanceFlown: Int,
|
||||||
|
val distanceSwum: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdvancementsInfo(
|
||||||
|
val uuid: String,
|
||||||
|
val total: Int,
|
||||||
|
val completed: Int,
|
||||||
|
val inProgress: Int,
|
||||||
|
val completedList: List<AdvancementInfo>,
|
||||||
|
val inProgressList: List<AdvancementInfo>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AdvancementInfo(
|
||||||
|
val id: String,
|
||||||
|
val title: String,
|
||||||
|
val description: String,
|
||||||
|
val completed: Boolean
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package co.caadiq.serverstatus.data
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
|
import net.neoforged.neoforge.event.entity.player.PlayerEvent
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 이벤트 추적기
|
||||||
|
* 입장/퇴장 이벤트를 감지하여 데이터 저장
|
||||||
|
*/
|
||||||
|
object PlayerTracker {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 입장 이벤트
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) {
|
||||||
|
val player = event.entity
|
||||||
|
val uuid = player.stringUUID
|
||||||
|
val name = player.name.string
|
||||||
|
|
||||||
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)")
|
||||||
|
|
||||||
|
// 플레이어 데이터 저장
|
||||||
|
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 퇴장 이벤트
|
||||||
|
*/
|
||||||
|
@SubscribeEvent
|
||||||
|
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
|
||||||
|
val player = event.entity
|
||||||
|
val uuid = player.stringUUID
|
||||||
|
val name = player.name.string
|
||||||
|
|
||||||
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
|
||||||
|
|
||||||
|
// 통계 수집 후 저장
|
||||||
|
val stats = try {
|
||||||
|
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}")
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 플레이어 데이터 및 통계 저장
|
||||||
|
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,143 @@
|
||||||
|
package co.caadiq.serverstatus.data
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import co.caadiq.serverstatus.network.ModInfo
|
||||||
|
import co.caadiq.serverstatus.network.OnlinePlayer
|
||||||
|
import co.caadiq.serverstatus.network.PlayersInfo
|
||||||
|
import co.caadiq.serverstatus.network.ServerStatus
|
||||||
|
import net.minecraft.server.MinecraftServer
|
||||||
|
import net.minecraft.world.Difficulty
|
||||||
|
import net.neoforged.fml.ModList
|
||||||
|
import net.neoforged.neoforge.server.ServerLifecycleHooks
|
||||||
|
|
||||||
|
/** 서버 데이터 수집기 서버 상태, 버전, 난이도, 게임 규칙 등을 수집 */
|
||||||
|
class ServerDataCollector {
|
||||||
|
|
||||||
|
// 서버 시작 시간 기록
|
||||||
|
private val serverStartTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
/** 서버 인스턴스 가져오기 */
|
||||||
|
private fun getServer(): MinecraftServer? = ServerLifecycleHooks.getCurrentServer()
|
||||||
|
|
||||||
|
/** 전체 서버 상태 수집 */
|
||||||
|
fun collectStatus(): ServerStatus {
|
||||||
|
val server = getServer()
|
||||||
|
|
||||||
|
return ServerStatus(
|
||||||
|
online = server != null,
|
||||||
|
version = getMinecraftVersion(),
|
||||||
|
modLoader = getModLoaderInfo(),
|
||||||
|
difficulty = getDifficulty(),
|
||||||
|
uptimeMinutes = getUptimeMinutes(),
|
||||||
|
players = getPlayersInfo(),
|
||||||
|
gameRules = getGameRules(),
|
||||||
|
mods = getModsList()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 마인크래프트 버전 */
|
||||||
|
private fun getMinecraftVersion(): String {
|
||||||
|
return try {
|
||||||
|
net.minecraft.SharedConstants.getCurrentVersion().name
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"Unknown"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 모드 로더 정보 */
|
||||||
|
private fun getModLoaderInfo(): String {
|
||||||
|
return try {
|
||||||
|
val neoforge = ModList.get().getModContainerById("neoforge")
|
||||||
|
if (neoforge.isPresent) {
|
||||||
|
"NeoForge ${neoforge.get().modInfo.version}"
|
||||||
|
} else {
|
||||||
|
"NeoForge"
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
"NeoForge"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게임 난이도 */
|
||||||
|
private fun getDifficulty(): String {
|
||||||
|
val server = getServer() ?: return "알 수 없음"
|
||||||
|
return when (server.worldData.difficulty) {
|
||||||
|
Difficulty.PEACEFUL -> "평화로움"
|
||||||
|
Difficulty.EASY -> "쉬움"
|
||||||
|
Difficulty.NORMAL -> "보통"
|
||||||
|
Difficulty.HARD -> "어려움"
|
||||||
|
else -> "알 수 없음"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 서버 가동 시간 (분) */
|
||||||
|
private fun getUptimeMinutes(): Long {
|
||||||
|
return (System.currentTimeMillis() - serverStartTime) / 1000 / 60
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 플레이어 정보 */
|
||||||
|
private fun getPlayersInfo(): PlayersInfo {
|
||||||
|
val server = getServer() ?: return PlayersInfo(0, 20, emptyList())
|
||||||
|
val playerList = server.playerList
|
||||||
|
|
||||||
|
val onlinePlayers =
|
||||||
|
playerList.players.map { player ->
|
||||||
|
OnlinePlayer(
|
||||||
|
name = player.name.string,
|
||||||
|
uuid = player.stringUUID,
|
||||||
|
isOp = playerList.isOp(player.gameProfile)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlayersInfo(
|
||||||
|
current = playerList.playerCount,
|
||||||
|
max = playerList.maxPlayers,
|
||||||
|
online = onlinePlayers
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 게임 규칙 목록 */
|
||||||
|
private fun getGameRules(): Map<String, Boolean> {
|
||||||
|
val server = getServer() ?: return emptyMap()
|
||||||
|
val gameRules = mutableMapOf<String, Boolean>()
|
||||||
|
|
||||||
|
try {
|
||||||
|
val level = server.overworld() ?: return emptyMap()
|
||||||
|
val rules = level.gameRules
|
||||||
|
|
||||||
|
// Boolean 타입 게임 규칙만 수집
|
||||||
|
net.minecraft.world.level.GameRules.visitGameRuleTypes(
|
||||||
|
object : net.minecraft.world.level.GameRules.GameRuleTypeVisitor {
|
||||||
|
override fun <T : net.minecraft.world.level.GameRules.Value<T>> visit(
|
||||||
|
key: net.minecraft.world.level.GameRules.Key<T>,
|
||||||
|
type: net.minecraft.world.level.GameRules.Type<T>
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
val value = rules.getRule(key)
|
||||||
|
if (value is net.minecraft.world.level.GameRules.BooleanValue) {
|
||||||
|
gameRules[key.id] = value.get()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// 개별 규칙 수집 실패 시 무시
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.debug("게임 규칙 수집 중 오류 (서버 시작 중일 수 있음): ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return gameRules
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 설치된 모드 목록 */
|
||||||
|
private fun getModsList(): List<ModInfo> {
|
||||||
|
return try {
|
||||||
|
ModList.get().mods.filter { it.modId != "minecraft" && it.modId != "neoforge" }.map {
|
||||||
|
ModInfo(it.modId, it.version.toString())
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,111 @@
|
||||||
|
package co.caadiq.serverstatus.data
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import net.minecraft.world.level.Level
|
||||||
|
import net.neoforged.neoforge.server.ServerLifecycleHooks
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 월드 정보 수집기
|
||||||
|
*/
|
||||||
|
class WorldDataCollector {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모든 월드 정보 수집
|
||||||
|
*/
|
||||||
|
fun collectWorlds(): List<WorldInfo> {
|
||||||
|
val server = ServerLifecycleHooks.getCurrentServer() ?: return emptyList()
|
||||||
|
|
||||||
|
return try {
|
||||||
|
server.allLevels.map { level ->
|
||||||
|
val dimensionKey = level.dimension().location().toString()
|
||||||
|
val worldData = level.levelData
|
||||||
|
|
||||||
|
// 해당 월드에 접속 중인 플레이어 목록 (위치 포함)
|
||||||
|
val playersInWorld = level.players().map { player ->
|
||||||
|
PlayerInWorld(
|
||||||
|
uuid = player.uuid.toString(),
|
||||||
|
name = player.name.string,
|
||||||
|
x = player.x.toInt(),
|
||||||
|
y = player.y.toInt(),
|
||||||
|
z = player.z.toInt()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
WorldInfo(
|
||||||
|
dimension = dimensionKey,
|
||||||
|
displayName = getDimensionDisplayName(dimensionKey),
|
||||||
|
weather = getWeatherInfo(level),
|
||||||
|
time = TimeInfo(
|
||||||
|
dayTime = (worldData.dayTime % 24000L),
|
||||||
|
day = (worldData.dayTime / 24000L).toInt() + 1
|
||||||
|
),
|
||||||
|
playerCount = level.players().size,
|
||||||
|
players = playersInWorld
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("월드 정보 수집 오류: ${e.message}")
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getDimensionDisplayName(key: String): String {
|
||||||
|
return when (key) {
|
||||||
|
"minecraft:overworld" -> "오버월드"
|
||||||
|
"minecraft:the_nether" -> "네더"
|
||||||
|
"minecraft:the_end" -> "엔드"
|
||||||
|
else -> key.substringAfter(":")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun getWeatherInfo(level: Level): WeatherInfo {
|
||||||
|
val isRaining = level.isRaining
|
||||||
|
val isThundering = level.isThundering
|
||||||
|
|
||||||
|
val type = when {
|
||||||
|
isThundering -> "thunderstorm"
|
||||||
|
isRaining -> "rain"
|
||||||
|
else -> "clear"
|
||||||
|
}
|
||||||
|
|
||||||
|
val displayName = when (type) {
|
||||||
|
"thunderstorm" -> "뇌우"
|
||||||
|
"rain" -> "비"
|
||||||
|
else -> "맑음"
|
||||||
|
}
|
||||||
|
|
||||||
|
return WeatherInfo(type, displayName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WorldInfo(
|
||||||
|
val dimension: String,
|
||||||
|
val displayName: String,
|
||||||
|
val weather: WeatherInfo,
|
||||||
|
val time: TimeInfo,
|
||||||
|
val playerCount: Int,
|
||||||
|
val players: List<PlayerInWorld>
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class PlayerInWorld(
|
||||||
|
val uuid: String,
|
||||||
|
val name: String,
|
||||||
|
val x: Int,
|
||||||
|
val y: Int,
|
||||||
|
val z: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WeatherInfo(
|
||||||
|
val type: String,
|
||||||
|
val displayName: String
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class TimeInfo(
|
||||||
|
val dayTime: Long, // 0-24000 (틱)
|
||||||
|
val day: Int // 몇 일차
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,219 @@
|
||||||
|
package co.caadiq.serverstatus.network
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
|
import co.caadiq.serverstatus.data.AdvancementsInfo
|
||||||
|
import co.caadiq.serverstatus.data.PlayerStats
|
||||||
|
import co.caadiq.serverstatus.data.WorldInfo
|
||||||
|
import com.sun.net.httpserver.HttpExchange
|
||||||
|
import com.sun.net.httpserver.HttpHandler
|
||||||
|
import com.sun.net.httpserver.HttpServer
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
import kotlinx.serialization.encodeToString
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
import java.util.concurrent.Executors
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP API 서버
|
||||||
|
* Java 내장 HttpServer 사용 - 외부 의존성 없음
|
||||||
|
*/
|
||||||
|
class HttpApiServer(private val port: Int) {
|
||||||
|
|
||||||
|
private val json = Json {
|
||||||
|
prettyPrint = false
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private var server: HttpServer? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 시작
|
||||||
|
*/
|
||||||
|
fun start() {
|
||||||
|
try {
|
||||||
|
server = HttpServer.create(InetSocketAddress(port), 0)
|
||||||
|
server?.executor = Executors.newFixedThreadPool(4)
|
||||||
|
|
||||||
|
// 엔드포인트 등록
|
||||||
|
server?.createContext("/status", StatusHandler())
|
||||||
|
server?.createContext("/players", PlayersHandler())
|
||||||
|
server?.createContext("/player", PlayerHandler())
|
||||||
|
server?.createContext("/worlds", WorldsHandler())
|
||||||
|
|
||||||
|
server?.start()
|
||||||
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] HTTP API 서버 시작: http://0.0.0.0:$port")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] HTTP 서버 시작 실패: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 종료
|
||||||
|
*/
|
||||||
|
fun stop() {
|
||||||
|
server?.stop(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CORS 헤더 추가 및 응답 전송
|
||||||
|
*/
|
||||||
|
private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) {
|
||||||
|
exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8")
|
||||||
|
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
|
||||||
|
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
|
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
|
val bytes = response.toByteArray(StandardCharsets.UTF_8)
|
||||||
|
exchange.sendResponseHeaders(statusCode, bytes.size.toLong())
|
||||||
|
exchange.responseBody.use { it.write(bytes) }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlayerData를 PlayerDetail로 변환
|
||||||
|
*/
|
||||||
|
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
|
||||||
|
return PlayerDetail(
|
||||||
|
uuid = player.uuid,
|
||||||
|
name = player.name,
|
||||||
|
firstJoin = player.firstJoin,
|
||||||
|
lastJoin = player.lastJoin,
|
||||||
|
lastLeave = player.lastLeave,
|
||||||
|
isOnline = player.isOnline,
|
||||||
|
currentSessionMs = player.getCurrentSessionMs(),
|
||||||
|
totalPlayTimeMs = player.totalPlayTimeMs
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /status - 서버 상태
|
||||||
|
*/
|
||||||
|
private inner class StatusHandler : HttpHandler {
|
||||||
|
override fun handle(exchange: HttpExchange) {
|
||||||
|
if (exchange.requestMethod == "OPTIONS") {
|
||||||
|
sendJsonResponse(exchange, "", 204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val status = ServerStatusMod.serverDataCollector.collectStatus()
|
||||||
|
val response = json.encodeToString(status)
|
||||||
|
sendJsonResponse(exchange, response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("상태 조회 오류: ${e.message}")
|
||||||
|
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /players - 전체 플레이어 목록
|
||||||
|
*/
|
||||||
|
private inner class PlayersHandler : HttpHandler {
|
||||||
|
override fun handle(exchange: HttpExchange) {
|
||||||
|
if (exchange.requestMethod == "OPTIONS") {
|
||||||
|
sendJsonResponse(exchange, "", 204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val players = ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) }
|
||||||
|
val response = json.encodeToString(AllPlayersResponse(players))
|
||||||
|
sendJsonResponse(exchange, response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("플레이어 목록 조회 오류: ${e.message}")
|
||||||
|
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /player/{uuid} - 특정 플레이어 정보
|
||||||
|
* GET /player/{uuid}/stats - 플레이어 통계
|
||||||
|
* GET /player/{uuid}/advancements - 플레이어 도전과제
|
||||||
|
*/
|
||||||
|
private inner class PlayerHandler : HttpHandler {
|
||||||
|
override fun handle(exchange: HttpExchange) {
|
||||||
|
if (exchange.requestMethod == "OPTIONS") {
|
||||||
|
sendJsonResponse(exchange, "", 204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val path = exchange.requestURI.path
|
||||||
|
val parts = path.removePrefix("/player/").split("/")
|
||||||
|
val uuid = parts.getOrNull(0)?.takeIf { it.isNotBlank() }
|
||||||
|
val subPath = parts.getOrNull(1)
|
||||||
|
|
||||||
|
if (uuid == null) {
|
||||||
|
sendJsonResponse(exchange, """{"error": "UUID required"}""", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
when (subPath) {
|
||||||
|
"stats" -> {
|
||||||
|
// 온라인이면 실시간 통계, 오프라인이면 저장된 통계
|
||||||
|
val isOnline = ServerStatusMod.playerDataStore.isPlayerOnline(uuid)
|
||||||
|
val stats = if (isOnline) {
|
||||||
|
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||||
|
} else {
|
||||||
|
ServerStatusMod.playerDataStore.getSavedStats(uuid)
|
||||||
|
}
|
||||||
|
if (stats != null) {
|
||||||
|
sendJsonResponse(exchange, json.encodeToString(stats))
|
||||||
|
} else {
|
||||||
|
sendJsonResponse(exchange, """{"error": "Player stats not available"}""", 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
"advancements" -> {
|
||||||
|
val advancements = ServerStatusMod.playerStatsCollector.collectAdvancements(uuid)
|
||||||
|
if (advancements != null) {
|
||||||
|
sendJsonResponse(exchange, json.encodeToString(advancements))
|
||||||
|
} else {
|
||||||
|
sendJsonResponse(exchange, """{"error": "Player not online or not found"}""", 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// 기본: 플레이어 정보
|
||||||
|
val player = ServerStatusMod.playerDataStore.getPlayer(uuid)
|
||||||
|
if (player != null) {
|
||||||
|
sendJsonResponse(exchange, json.encodeToString(toPlayerDetail(player)))
|
||||||
|
} else {
|
||||||
|
sendJsonResponse(exchange, """{"error": "Player not found"}""", 404)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("플레이어 조회 오류: ${e.message}")
|
||||||
|
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /worlds - 월드 정보
|
||||||
|
*/
|
||||||
|
private inner class WorldsHandler : HttpHandler {
|
||||||
|
override fun handle(exchange: HttpExchange) {
|
||||||
|
if (exchange.requestMethod == "OPTIONS") {
|
||||||
|
sendJsonResponse(exchange, "", 204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
val worlds = ServerStatusMod.worldDataCollector.collectWorlds()
|
||||||
|
val response = json.encodeToString(WorldsResponse(worlds))
|
||||||
|
sendJsonResponse(exchange, response)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
ServerStatusMod.LOGGER.error("월드 조회 오류: ${e.message}")
|
||||||
|
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class WorldsResponse(
|
||||||
|
val worlds: List<WorldInfo>
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
package co.caadiq.serverstatus.network
|
||||||
|
|
||||||
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 서버 상태
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ServerStatus(
|
||||||
|
val online: Boolean,
|
||||||
|
val version: String,
|
||||||
|
val modLoader: String,
|
||||||
|
val difficulty: String,
|
||||||
|
val uptimeMinutes: Long,
|
||||||
|
val players: PlayersInfo,
|
||||||
|
val gameRules: Map<String, Boolean>,
|
||||||
|
val mods: List<ModInfo>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 정보
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayersInfo(
|
||||||
|
val current: Int,
|
||||||
|
val max: Int,
|
||||||
|
val online: List<OnlinePlayer>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 접속 중인 플레이어
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class OnlinePlayer(
|
||||||
|
val name: String,
|
||||||
|
val uuid: String,
|
||||||
|
val isOp: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 모드 정보
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class ModInfo(
|
||||||
|
val id: String,
|
||||||
|
val version: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 상세 정보 (전체 플레이어용)
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayerDetail(
|
||||||
|
val uuid: String,
|
||||||
|
val name: String,
|
||||||
|
val firstJoin: Long,
|
||||||
|
val lastJoin: Long,
|
||||||
|
val lastLeave: Long,
|
||||||
|
val isOnline: Boolean,
|
||||||
|
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
|
||||||
|
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 전체 플레이어 목록 응답
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class AllPlayersResponse(
|
||||||
|
val players: List<PlayerDetail>
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 입장 이벤트
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayerJoinEvent(
|
||||||
|
val uuid: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 플레이어 퇴장 이벤트
|
||||||
|
*/
|
||||||
|
@Serializable
|
||||||
|
data class PlayerLeaveEvent(
|
||||||
|
val uuid: String,
|
||||||
|
val name: String
|
||||||
|
)
|
||||||
32
ServerStatus/src/main/resources/META-INF/neoforge.mods.toml
Normal file
32
ServerStatus/src/main/resources/META-INF/neoforge.mods.toml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
modLoader="kotlinforforge"
|
||||||
|
loaderVersion="${loader_version_range}"
|
||||||
|
license="${mod_license}"
|
||||||
|
issueTrackerURL=""
|
||||||
|
|
||||||
|
[[mods]]
|
||||||
|
modId="${mod_id}"
|
||||||
|
version="${mod_version}"
|
||||||
|
displayName="${mod_name}"
|
||||||
|
authors="${mod_authors}"
|
||||||
|
description='''${mod_description}'''
|
||||||
|
|
||||||
|
[[dependencies.${mod_id}]]
|
||||||
|
modId="neoforge"
|
||||||
|
type="required"
|
||||||
|
versionRange="${neo_version_range}"
|
||||||
|
ordering="NONE"
|
||||||
|
side="SERVER"
|
||||||
|
|
||||||
|
[[dependencies.${mod_id}]]
|
||||||
|
modId="minecraft"
|
||||||
|
type="required"
|
||||||
|
versionRange="${minecraft_version_range}"
|
||||||
|
ordering="NONE"
|
||||||
|
side="SERVER"
|
||||||
|
|
||||||
|
[[dependencies.${mod_id}]]
|
||||||
|
modId="kotlinforforge"
|
||||||
|
type="required"
|
||||||
|
versionRange="[5.0.0,)"
|
||||||
|
ordering="NONE"
|
||||||
|
side="SERVER"
|
||||||
Loading…
Add table
Reference in a new issue