Initial commit: Essentials and ServerStatus mods

This commit is contained in:
Caadiq 2025-12-17 18:41:31 +09:00
commit 8f2c2c7941
71 changed files with 5292 additions and 0 deletions

21
.gitignore vendored Normal file
View 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
View file

@ -0,0 +1,66 @@
# ⚙️ Essentials
마인크래프트 서버 필수 기능을 제공하는 NeoForge 모드입니다.
![NeoForge](https://img.shields.io/badge/NeoForge-21.1.194-orange?logo=curseforge)
![Minecraft](https://img.shields.io/badge/Minecraft-1.21.1-green)
![Kotlin](https://img.shields.io/badge/Kotlin-2.0-7F52FF?logo=kotlin)
---
## ✨ 주요 기능
- 📍 **좌표 관리** - 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
View 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
}
}

View 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=

Binary file not shown.

View 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
View 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
View 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

View 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'
}

View file

@ -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;
}
}

View file

@ -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);
}
}
}

View file

@ -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);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}
}

View file

@ -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();
}
}

View file

@ -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));
}
}
}
}

View file

@ -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();
}
}

View file

@ -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)
}
}

View file

@ -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
}
)
}
}
}

View file

@ -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
}
)
)
}
}

View file

@ -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
}
}

View file

@ -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
}
)
}
}
}

View file

@ -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
}
)
}
}
}

View file

@ -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
}
)
}
}
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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 }
}

View file

@ -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
}
}

View file

@ -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
}
}

View file

@ -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()
}
}
}

View file

@ -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
)

View file

@ -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
)

View file

@ -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
)

View file

@ -0,0 +1,5 @@
package com.beemer.essentials.data
data class ProtectFarmland(
val protectFarmland: Boolean = false
)

View file

@ -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
}
}

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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) }
}
}
}

View file

@ -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))
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}
}

View file

@ -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())
}
}

View file

@ -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")
}
}
}

View file

@ -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)
}
}

View file

@ -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
}
}
}

View file

@ -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)
}
}

View file

@ -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}")
}
}
}

View file

@ -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)
}

View file

@ -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": "나무가 우거진 악지"
}

View 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
}
}

View 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
View file

@ -0,0 +1,55 @@
# ⛏️ Minecraft Mods
NeoForge 1.21.1 기반 마인크래프트 서버 모드 모음입니다.
![NeoForge](https://img.shields.io/badge/NeoForge-1.21.1-orange?logo=curseforge)
![Kotlin](https://img.shields.io/badge/Kotlin-2.0-7F52FF?logo=kotlin)
![License](https://img.shields.io/badge/License-MIT-green)
---
## 📦 모드 목록
| 모드 | 설명 |
| ------------------------------- | ------------------------------------- |
| [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
View file

@ -0,0 +1,79 @@
# 🌐 ServerStatus
HTTP API로 마인크래프트 서버 상태를 제공하는 NeoForge 모드입니다.
![NeoForge](https://img.shields.io/badge/NeoForge-21.1.77-orange?logo=curseforge)
![Minecraft](https://img.shields.io/badge/Minecraft-1.21.1-green)
![Kotlin](https://img.shields.io/badge/Kotlin-2.0-7F52FF?logo=kotlin)
---
## ✨ 주요 기능
- 📊 **서버 상태 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`

View 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)
}
}

View 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

Binary file not shown.

View 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
View 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 \
"$@"

View 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"

View file

@ -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})")
}
}

View file

@ -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}")
}
}
}
}

View file

@ -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
}

View file

@ -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
)

View file

@ -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)
}
}

View file

@ -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()
}
}
}

View file

@ -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 // 몇 일차
)

View file

@ -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>
)

View file

@ -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
)

View 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"