feat: Discord 연동 모드 추가 /n- 게임 내 채팅 Discord 전송/n- 접속/퇴장 알림/n- 사망 메시지 (한국어 번역)/n- 발전과제 알림 (한국어 번역)/n- 서버 시작/종료 알림/n- README 작성

This commit is contained in:
Caadiq 2025-12-22 14:44:22 +09:00
parent ad253e8499
commit f2fb0ad324
22 changed files with 2038 additions and 0 deletions

89
DiscordBot/README.md Normal file
View file

@ -0,0 +1,89 @@
# 🤖 DiscordBot
마인크래프트 서버 이벤트를 Discord로 전송하는 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)
---
## ✨ 주요 기능
- 💬 **채팅 연동** - 게임 내 채팅을 Discord로 전송
- 🚪 **접속/퇴장 알림** - 플레이어 접속/퇴장 알림
- ⚔️ **사망 메시지** - 플레이어 사망 시 알림 (한국어 번역)
- 🏆 **발전과제 알림** - 발전과제 달성 시 알림 (한국어 번역)
- 🖥️ **서버 시작/종료** - 서버 상태 변경 알림
---
## ⚙️ 설정
`config/discordbot.toml`
```toml
[discord]
webhookUrl = "YOUR_DISCORD_WEBHOOK_URL"
```
---
## 📡 Discord 메시지 형식
### 채팅 메시지
> **플레이어명**: 메시지 내용
### 접속/퇴장
> 🟢 **플레이어명**님이 서버에 접속했습니다.
> 🔴 **플레이어명**님이 서버에서 퇴장했습니다.
### 사망 메시지
> ☠️ **플레이어명**님이 좀비에게 사망했습니다.
### 발전과제
> 🏆 **플레이어명**님이 발전과제 [돌 시대]를 달성했습니다!
---
## 🛠️ 기술 스택
| 기술 | 설명 |
| -------------------- | --------------------- |
| **NeoForge** | Minecraft 모딩 플랫폼 |
| **Kotlin** | 주 개발 언어 |
| **Discord Webhooks** | Discord 연동 |
| **Gson** | JSON 직렬화 |
---
## 📁 구조
```
DiscordBot/
├── src/main/
│ ├── kotlin/com/beemer/discordbot/
│ │ ├── command/ # 명령어 처리
│ │ ├── config/ # 설정 관리
│ │ ├── discord/ # Discord 웹훅
│ │ ├── event/ # 이벤트 핸들러
│ │ ├── util/ # 유틸리티 (번역)
│ │ └── DiscordBot.kt # 메인 모드
│ └── resources/
│ └── META-INF/ # 모드 메타데이터
└── build.gradle
```
---
## 🚀 빌드
```bash
./gradlew build
```
빌드된 JAR: `build/libs/discordbot-1.0.0.jar`

145
DiscordBot/build.gradle Normal file
View file

@ -0,0 +1,145 @@
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" }
}
mavenCentral()
}
base {
archivesName = mod_id
}
java.toolchain.languageVersion = JavaLanguageVersion.of(21)
kotlin.jvmToolchain(21)
neoForge {
version = project.neo_version
parchment {
mappingsVersion = project.parchment_mappings_version
minecraftVersion = project.parchment_minecraft_version
}
runs {
client {
client()
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
server {
server()
programArgument '--nogui'
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
gameTestServer {
type = "gameTestServer"
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
}
data {
data()
programArguments.addAll '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/').getAbsolutePath(), '--existing', file('src/main/resources/').getAbsolutePath()
}
configureEach {
systemProperty 'forge.logging.markers', 'REGISTRIES'
logLevel = org.slf4j.event.Level.DEBUG
}
}
mods {
"${mod_id}" {
sourceSet(sourceSets.main)
}
}
}
sourceSets.main.resources { srcDir 'src/generated/resources' }
// JDA
configurations {
jdaLibs
}
dependencies {
implementation 'thedarkcolour:kotlinforforge-neoforge:5.3.0'
// JDA (Java Discord API)
def jdaDeps = {
exclude module: 'opus-java'
exclude group: 'org.slf4j'
exclude group: 'com.google.errorprone'
exclude group: 'org.jetbrains'
exclude group: 'org.checkerframework'
}
implementation('net.dv8tion:JDA:5.2.1', jdaDeps)
jarJar('net.dv8tion:JDA:5.2.1', jdaDeps)
// JDA JarJar로
jarJar('com.squareup.okhttp3:okhttp:4.12.0')
jarJar('com.squareup.okio:okio:3.6.0')
jarJar('com.squareup.okio:okio-jvm:3.6.0')
jarJar('com.neovisionaries:nv-websocket-client:2.14')
jarJar('net.sf.trove4j:trove4j:3.0.3')
jarJar('com.fasterxml.jackson.core:jackson-core:2.17.0')
jarJar('com.fasterxml.jackson.core:jackson-databind:2.17.0')
jarJar('com.fasterxml.jackson.core:jackson-annotations:2.17.0')
jarJar('org.apache.commons:commons-collections4:4.4')
}
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"
}
sourceSets.main.resources.srcDir generateModMetadata
neoForge.ideSyncTask generateModMetadata
publishing {
publications {
register('mavenJava', MavenPublication) {
from components.java
}
}
repositories {
maven {
url "file://${project.projectDir}/repo"
}
}
}
idea {
module {
downloadSources = true
downloadJavadoc = true
}
}

View file

@ -0,0 +1,24 @@
# 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
minecraft_version=1.21.1
minecraft_version_range=[1.21.1,1.22)
neo_version=21.1.194
neo_version_range=[21,)
loader_version_range=[5.3,)
parchment_minecraft_version=1.21.1
parchment_mappings_version=2024.11.17
## Mod Properties
mod_id=discordbot
mod_name=DiscordBot
mod_license=MIT
mod_version=1.0.0
mod_group_id=com.beemer
mod_authors=
mod_description=Discord integration mod for Minecraft

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
DiscordBot/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
DiscordBot/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,40 @@
package com.beemer.discordbot
import com.beemer.discordbot.command.DiscordCommand
import com.beemer.discordbot.config.DiscordConfig
import com.beemer.discordbot.event.AdvancementEvents
import com.beemer.discordbot.event.ChatEvents
import com.beemer.discordbot.event.DeathEvents
import com.beemer.discordbot.event.PlayerEvents
import com.beemer.discordbot.event.ServerEvents
import net.neoforged.bus.api.IEventBus
import net.neoforged.fml.common.Mod
import net.neoforged.neoforge.common.NeoForge
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger
@Mod(DiscordBot.ID)
class DiscordBot(modEventBus: IEventBus) {
companion object {
const val ID = "discordbot"
val LOGGER: Logger = LogManager.getLogger(ID)
}
init {
// 설정 로드
DiscordConfig.load()
// 번역 파일 로드
com.beemer.discordbot.util.TranslationManager.load()
// 이벤트 등록
NeoForge.EVENT_BUS.register(ServerEvents)
NeoForge.EVENT_BUS.register(PlayerEvents)
NeoForge.EVENT_BUS.register(ChatEvents)
NeoForge.EVENT_BUS.register(DeathEvents)
NeoForge.EVENT_BUS.register(AdvancementEvents)
NeoForge.EVENT_BUS.register(DiscordCommand)
LOGGER.info("[DiscordBot] 모드 초기화 완료")
}
}

View file

@ -0,0 +1,65 @@
package com.beemer.discordbot.command
import com.beemer.discordbot.config.DiscordConfig
import com.beemer.discordbot.discord.BotManager
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 DiscordCommand {
@SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) {
// /디스코드 새로고침
event.dispatcher.register(
Commands.literal("디스코드")
.requires { it.hasPermission(2) }
.then(
Commands.literal("새로고침").executes { context ->
val player = context.source.entity as? ServerPlayer
// 설정 다시 로드
DiscordConfig.reload()
// 봇 재시작
BotManager.restart()
val message =
Component.literal("디스코드 설정을 다시 불러왔습니다.").withStyle {
it.withColor(ChatFormatting.GOLD)
}
player?.sendSystemMessage(message)
?: context.source.sendSuccess({ message }, false)
1
}
)
)
// /discord reload (영문)
event.dispatcher.register(
Commands.literal("discord")
.requires { it.hasPermission(2) }
.then(
Commands.literal("reload").executes { context ->
val player = context.source.entity as? ServerPlayer
DiscordConfig.reload()
BotManager.restart()
val message =
Component.literal("Discord settings reloaded.")
.withStyle { it.withColor(ChatFormatting.GOLD) }
player?.sendSystemMessage(message)
?: context.source.sendSuccess({ message }, false)
1
}
)
)
}
}

View file

@ -0,0 +1,72 @@
package com.beemer.discordbot.config
import com.beemer.discordbot.DiscordBot
import com.google.gson.Gson
import com.google.gson.GsonBuilder
import java.nio.file.Files
import java.nio.file.Path
import net.neoforged.fml.loading.FMLPaths
data class DiscordSettings(
var botToken: String = "",
var channelId: String = "",
var serverAddress: String = "",
var features: Features = Features(),
var messages: Messages = Messages()
)
data class Features(
var chatSync: Boolean = true,
var joinLeave: Boolean = true,
var serverStatus: Boolean = true,
var death: Boolean = true,
var advancement: Boolean = true
)
data class Messages(
var join: String = "**{player}**님이 서버에 접속했습니다.",
var leave: String = "**{player}**님이 서버에서 퇴장했습니다.",
var serverStart: String = "🟢 서버가 시작되었습니다.",
var serverStop: String = "🔴 서버가 종료되었습니다."
)
object DiscordConfig {
private val CONFIG_DIR: Path = FMLPaths.CONFIGDIR.get().resolve(DiscordBot.ID)
private val CONFIG_FILE: Path = CONFIG_DIR.resolve("config.json")
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
var settings: DiscordSettings = DiscordSettings()
private set
fun load() {
try {
Files.createDirectories(CONFIG_DIR)
if (Files.exists(CONFIG_FILE)) {
val json = Files.readString(CONFIG_FILE)
settings = gson.fromJson(json, DiscordSettings::class.java) ?: DiscordSettings()
DiscordBot.LOGGER.info("[DiscordBot] 설정 파일 로드 완료")
} else {
// 기본 설정 파일 생성
save()
DiscordBot.LOGGER.info("[DiscordBot] 기본 설정 파일 생성됨: $CONFIG_FILE")
}
} catch (e: Exception) {
DiscordBot.LOGGER.error("[DiscordBot] 설정 로드 실패", e)
}
}
fun save() {
try {
Files.createDirectories(CONFIG_DIR)
val json = gson.toJson(settings)
Files.writeString(CONFIG_FILE, json)
} catch (e: Exception) {
DiscordBot.LOGGER.error("[DiscordBot] 설정 저장 실패", e)
}
}
fun reload() {
load()
}
}

View file

@ -0,0 +1,271 @@
package com.beemer.discordbot.discord
import com.beemer.discordbot.DiscordBot
import com.beemer.discordbot.config.DiscordConfig
import java.awt.Color
import java.time.Instant
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import net.dv8tion.jda.api.EmbedBuilder
import net.dv8tion.jda.api.JDA
import net.dv8tion.jda.api.JDABuilder
import net.dv8tion.jda.api.entities.Activity
import net.dv8tion.jda.api.requests.GatewayIntent
import net.minecraft.server.MinecraftServer
object BotManager {
private var jda: JDA? = null
private var server: MinecraftServer? = null
private var serverStartTime: Instant? = null
private val scheduler = Executors.newSingleThreadScheduledExecutor()
fun start(minecraftServer: MinecraftServer) {
server = minecraftServer
serverStartTime = Instant.now()
val token = DiscordConfig.settings.botToken
if (token.isBlank()) {
DiscordBot.LOGGER.warn(
"[DiscordBot] 봇 토큰이 설정되지 않았습니다. config/discordbot/config.json을 확인하세요."
)
return
}
try {
jda =
JDABuilder.createDefault(token)
.enableIntents(
GatewayIntent.MESSAGE_CONTENT,
GatewayIntent.GUILD_MESSAGES
)
.addEventListeners(DiscordListener(minecraftServer))
.build()
.awaitReady()
// 봇 상태 업데이트 스케줄러 시작 (1분마다)
startStatusUpdater()
// 채널 토픽에 서버 주소 설정
updateChannelTopic()
DiscordBot.LOGGER.info("[DiscordBot] 디스코드 봇 연결 성공!")
} catch (e: Exception) {
DiscordBot.LOGGER.error("[DiscordBot] 디스코드 봇 연결 실패", e)
}
}
/** 봇 상태(Activity)를 주기적으로 업데이트 */
private fun startStatusUpdater() {
scheduler.scheduleAtFixedRate(
{
try {
updateBotStatus()
} catch (e: Exception) {
DiscordBot.LOGGER.error("[DiscordBot] 상태 업데이트 실패", e)
}
},
0,
1,
TimeUnit.MINUTES
)
}
/** 봇 상태 업데이트 - 서버 구동 시간 표시 */
private fun updateBotStatus() {
val startTime = serverStartTime ?: return
val now = Instant.now()
val duration = java.time.Duration.between(startTime, now)
val hours = duration.toHours()
val minutes = duration.toMinutes() % 60
val statusText =
if (hours > 0) {
"⏱️ ${hours}시간 ${minutes}분 동안 서버 구동 중"
} else {
"⏱️ ${minutes}분 동안 서버 구동 중"
}
jda?.presence?.setPresence(Activity.playing(statusText), false)
}
/** 채널 토픽에 서버 주소 설정 */
private fun updateChannelTopic() {
val serverAddress = DiscordConfig.settings.serverAddress
if (serverAddress.isBlank()) return
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val topic = "🪧 서버 주소: $serverAddress"
channel.manager
.setTopic(topic)
.queue(
{ DiscordBot.LOGGER.info("[DiscordBot] 채널 토픽 업데이트 완료: $topic") },
{ error -> DiscordBot.LOGGER.error("[DiscordBot] 채널 토픽 업데이트 실패", error) }
)
}
fun stop() {
scheduler.shutdown()
jda?.shutdown()
jda = null
server = null
serverStartTime = null
DiscordBot.LOGGER.info("[DiscordBot] 디스코드 봇 연결 해제")
}
fun restart() {
val currentServer = server
stop()
if (currentServer != null) {
start(currentServer)
}
}
fun sendMessage(message: String) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
channel.sendMessage(message).queue()
}
/** 마인크래프트 채팅 → 디스코드 전송 */
fun sendChatMessage(playerName: String, displayName: String, message: String) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val avatarUrl = "https://minotar.net/helm/$playerName/64.png"
val embed =
EmbedBuilder()
.setColor(Color(88, 101, 242)) // 디스코드 블루
.setAuthor(displayName, null, avatarUrl)
.setDescription(message)
.build()
channel.sendMessageEmbeds(embed).queue()
}
/** 플레이어 접속 Embed 전송 */
fun sendPlayerJoinEmbed(playerName: String, displayName: String, uuid: UUID) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val avatarUrl = "https://minotar.net/helm/$playerName/64.png"
val embed =
EmbedBuilder()
.setColor(Color(87, 242, 135)) // 초록색
.setAuthor("${displayName}님이 서버에 접속했습니다", null, avatarUrl)
.build()
channel.sendMessageEmbeds(embed).queue()
}
/** 플레이어 첫 접속 Embed 전송 */
fun sendPlayerFirstJoinEmbed(playerName: String, displayName: String, uuid: UUID) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val avatarUrl = "https://minotar.net/helm/$playerName/64.png"
val embed =
EmbedBuilder()
.setColor(Color(254, 231, 92)) // 노란색
.setAuthor("🎉 ${displayName}님이 서버에 처음 접속했습니다!", null, avatarUrl)
.build()
channel.sendMessageEmbeds(embed).queue()
}
/** 플레이어 퇴장 Embed 전송 */
fun sendPlayerLeaveEmbed(playerName: String, displayName: String, uuid: UUID) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val avatarUrl = "https://minotar.net/helm/$playerName/64.png"
val embed =
EmbedBuilder()
.setColor(Color(237, 66, 69)) // 빨간색
.setAuthor("${displayName}님이 서버에서 퇴장했습니다", null, avatarUrl)
.build()
channel.sendMessageEmbeds(embed).queue()
}
/** 서버 상태 Embed 전송 */
fun sendServerStatusEmbed(isStart: Boolean) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val (title, color) =
if (isStart) {
"🟢 서버가 시작되었습니다" to Color(87, 242, 135)
} else {
"🔴 서버가 종료되었습니다" to Color(237, 66, 69)
}
val embed = EmbedBuilder().setColor(color).setDescription(title).build()
channel.sendMessageEmbeds(embed).queue()
}
fun isConnected(): Boolean = jda != null
/** 플레이어 사망 Embed 전송 */
fun sendDeathEmbed(playerName: String, displayName: String, deathMessage: String) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val avatarUrl = "https://minotar.net/helm/$playerName/64.png"
val embed =
EmbedBuilder()
.setColor(Color(149, 100, 100)) // 밝은 회갈색
.setAuthor("💀 $deathMessage", null, avatarUrl)
.build()
channel.sendMessageEmbeds(embed).queue()
}
/** 발전 과제 달성 Embed 전송 */
fun sendAdvancementEmbed(
playerName: String,
displayName: String,
advancementTitle: String,
advancementDesc: String
) {
val channelId = DiscordConfig.settings.channelId
if (channelId.isBlank()) return
val channel = jda?.getTextChannelById(channelId) ?: return
val avatarUrl = "https://minotar.net/helm/$playerName/64.png"
val embed =
EmbedBuilder()
.setColor(Color(169, 112, 255)) // 보라색
.setAuthor("🏆 ${displayName}님이 발전 과제를 달성했습니다!", null, avatarUrl)
.setDescription("**$advancementTitle**\n> _${advancementDesc}_")
.build()
channel.sendMessageEmbeds(embed).queue()
}
}

View file

@ -0,0 +1,55 @@
package com.beemer.discordbot.discord
import com.beemer.discordbot.DiscordBot
import com.beemer.discordbot.config.DiscordConfig
import net.dv8tion.jda.api.events.message.MessageReceivedEvent
import net.dv8tion.jda.api.hooks.ListenerAdapter
import net.minecraft.ChatFormatting
import net.minecraft.network.chat.Component
import net.minecraft.server.MinecraftServer
class DiscordListener(private val server: MinecraftServer) : ListenerAdapter() {
override fun onMessageReceived(event: MessageReceivedEvent) {
// 봇 메시지 무시
if (event.author.isBot) return
// 채팅 동기화 비활성화 시 무시
if (!DiscordConfig.settings.features.chatSync) return
// 설정된 채널이 아닌 경우 무시
if (event.channel.id != DiscordConfig.settings.channelId) return
val username = event.member?.effectiveName ?: event.author.name
val message = event.message.contentDisplay
// 빈 메시지 무시
if (message.isBlank()) return
// 마인크래프트 서버에 메시지 전송
// #7883f4 = 7898100 (10진수)
val discordColor = 0x7883f4
val chatMessage =
Component.literal("[디스코드] ")
.withStyle { it.withColor(discordColor) }
.append(
Component.literal(username).withStyle {
it.withColor(ChatFormatting.YELLOW)
}
)
.append(
Component.literal(": ").withStyle {
it.withColor(ChatFormatting.GRAY)
}
)
.append(
Component.literal(message).withStyle {
it.withColor(ChatFormatting.WHITE)
}
)
server.playerList.players.forEach { player -> player.sendSystemMessage(chatMessage) }
DiscordBot.LOGGER.info("[DiscordBot] Discord -> MC: <$username> $message")
}
}

View file

@ -0,0 +1,58 @@
package com.beemer.discordbot.event
import com.beemer.discordbot.config.DiscordConfig
import com.beemer.discordbot.discord.BotManager
import com.beemer.discordbot.util.TranslationManager
import java.util.UUID
import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.entity.player.AdvancementEvent
object AdvancementEvents {
@SubscribeEvent
fun onAdvancement(event: AdvancementEvent.AdvancementProgressEvent) {
if (!DiscordConfig.settings.features.advancement) return
// 업적 완료가 아닌 경우 무시
val advancementProgress = event.advancementProgress
if (!advancementProgress.isDone) return
val player = event.entity as? ServerPlayer ?: return
val advancement = event.advancement
// 레시피 업적은 무시
val advancementId = advancement.id.toString()
if (advancementId.contains("recipe")) return
// 표시되지 않는 업적은 무시
val display = advancement.value.display.orElse(null) ?: return
if (!display.shouldAnnounceChat()) return
val playerName = player.gameProfile.name
val uuid = player.uuid
// 번역된 업적 제목과 설명 가져오기
val advancementTitle =
TranslationManager.getAdvancementTitle(advancementId) ?: display.title.string
val advancementDesc =
TranslationManager.getAdvancementDescription(advancementId)
?: display.description.string
// Essentials 모드의 닉네임 가져오기 시도
val displayName = getNickname(uuid) ?: playerName
BotManager.sendAdvancementEmbed(playerName, displayName, advancementTitle, advancementDesc)
}
/** Essentials 모드의 NicknameDataStore에서 닉네임 가져오기 */
private fun getNickname(uuid: UUID): String? {
return try {
val clazz = Class.forName("com.beemer.essentials.nickname.NicknameDataStore")
val instance = clazz.kotlin.objectInstance ?: return null
val method = clazz.getMethod("getNickname", UUID::class.java)
method.invoke(instance, uuid) as? String
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,39 @@
package com.beemer.discordbot.event
import com.beemer.discordbot.config.DiscordConfig
import com.beemer.discordbot.discord.BotManager
import java.util.UUID
import net.neoforged.bus.api.EventPriority
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.ServerChatEvent
object ChatEvents {
@SubscribeEvent(priority = EventPriority.HIGHEST)
fun onServerChat(event: ServerChatEvent) {
if (!DiscordConfig.settings.features.chatSync) return
val playerName = event.player.gameProfile.name
val uuid = event.player.uuid
val message = event.rawText
// 명령어 무시
if (message.startsWith("/")) return
// Essentials 모드의 닉네임 가져오기 시도
val displayName = getNickname(uuid) ?: playerName
BotManager.sendChatMessage(playerName, displayName, message)
}
/** Essentials 모드의 NicknameDataStore에서 닉네임 가져오기 */
private fun getNickname(uuid: UUID): String? {
return try {
val clazz = Class.forName("com.beemer.essentials.nickname.NicknameDataStore")
val instance = clazz.kotlin.objectInstance ?: return null
val method = clazz.getMethod("getNickname", UUID::class.java)
method.invoke(instance, uuid) as? String
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,62 @@
package com.beemer.discordbot.event
import com.beemer.discordbot.config.DiscordConfig
import com.beemer.discordbot.discord.BotManager
import com.beemer.discordbot.util.TranslationManager
import java.util.UUID
import net.minecraft.server.level.ServerPlayer
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.entity.living.LivingDeathEvent
object DeathEvents {
@SubscribeEvent
fun onPlayerDeath(event: LivingDeathEvent) {
if (!DiscordConfig.settings.features.death) return
val entity = event.entity
if (entity !is ServerPlayer) return
val playerName = entity.gameProfile.name
val uuid = entity.uuid
// Essentials 모드의 닉네임 가져오기
val displayName = getNickname(uuid) ?: playerName
// 사망 원인 ID
val msgId = event.source.type().msgId()
// 킬러 이름 (있는 경우)
val killerName =
event.source.entity?.let { killer ->
if (killer is ServerPlayer) {
getNickname(killer.uuid) ?: killer.gameProfile.name
} else {
// 몹 이름 번역 시도
val mobKey = killer.type.descriptionId
TranslationManager.translate(mobKey) ?: killer.name.string
}
}
// 번역된 사망 메시지 가져오기
val deathMessage =
TranslationManager.getDeathMessage(msgId, displayName, killerName)
?: event.source
.getLocalizedDeathMessage(entity)
.string
.replace(playerName, displayName)
BotManager.sendDeathEmbed(playerName, displayName, deathMessage)
}
/** Essentials 모드의 NicknameDataStore에서 닉네임 가져오기 */
private fun getNickname(uuid: UUID): String? {
return try {
val clazz = Class.forName("com.beemer.essentials.nickname.NicknameDataStore")
val instance = clazz.kotlin.objectInstance ?: return null
val method = clazz.getMethod("getNickname", UUID::class.java)
method.invoke(instance, uuid) as? String
} catch (e: Exception) {
null
}
}
}

View file

@ -0,0 +1,67 @@
package com.beemer.discordbot.event
import com.beemer.discordbot.config.DiscordConfig
import com.beemer.discordbot.discord.BotManager
import java.util.UUID
import net.neoforged.bus.api.EventPriority
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.entity.player.PlayerEvent
object PlayerEvents {
@SubscribeEvent(priority = EventPriority.HIGHEST)
fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) {
if (!DiscordConfig.settings.features.joinLeave) return
val playerName = event.entity.gameProfile.name
val uuid = event.entity.uuid
// 첫 접속인지 확인 (Essentials보다 먼저 체크)
val isFirstJoin = !isKnownPlayer(uuid)
// Essentials 모드의 닉네임 가져오기 시도
val displayName = getNickname(uuid) ?: playerName
if (isFirstJoin) {
BotManager.sendPlayerFirstJoinEmbed(playerName, displayName, uuid)
} else {
BotManager.sendPlayerJoinEmbed(playerName, displayName, uuid)
}
}
@SubscribeEvent
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
if (!DiscordConfig.settings.features.joinLeave) return
val playerName = event.entity.gameProfile.name
val uuid = event.entity.uuid
// Essentials 모드의 닉네임 가져오기 시도
val displayName = getNickname(uuid) ?: playerName
BotManager.sendPlayerLeaveEmbed(playerName, displayName, uuid)
}
/** Essentials 모드의 NicknameDataStore에서 닉네임 가져오기 */
private fun getNickname(uuid: UUID): String? {
return try {
val clazz = Class.forName("com.beemer.essentials.nickname.NicknameDataStore")
val instance = clazz.kotlin.objectInstance ?: return null
val method = clazz.getMethod("getNickname", UUID::class.java)
method.invoke(instance, uuid) as? String
} catch (e: Exception) {
null
}
}
/** Essentials 모드의 PlayerConfig에서 플레이어가 등록되어 있는지 확인 */
private fun isKnownPlayer(uuid: UUID): Boolean {
return try {
val clazz = Class.forName("com.beemer.essentials.config.PlayerConfig")
val instance = clazz.kotlin.objectInstance ?: return false
val method = clazz.getMethod("isKnownPlayer", String::class.java)
method.invoke(instance, uuid.toString()) as? Boolean ?: false
} catch (e: Exception) {
false
}
}
}

View file

@ -0,0 +1,31 @@
package com.beemer.discordbot.event
import com.beemer.discordbot.config.DiscordConfig
import com.beemer.discordbot.discord.BotManager
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.server.ServerStartedEvent
import net.neoforged.neoforge.event.server.ServerStoppingEvent
object ServerEvents {
@SubscribeEvent
fun onServerStarted(event: ServerStartedEvent) {
// 봇 시작 (서버 인스턴스 전달)
BotManager.start(event.server)
// 서버 시작 알림
if (DiscordConfig.settings.features.serverStatus) {
BotManager.sendServerStatusEmbed(isStart = true)
}
}
@SubscribeEvent
fun onServerStopping(event: ServerStoppingEvent) {
// 서버 종료 알림
if (DiscordConfig.settings.features.serverStatus) {
BotManager.sendServerStatusEmbed(isStart = false)
}
// 봇 종료
BotManager.stop()
}
}

View file

@ -0,0 +1,94 @@
package com.beemer.discordbot.util
import com.beemer.discordbot.DiscordBot
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import java.io.InputStreamReader
object TranslationManager {
private val gson = Gson()
private var translations: Map<String, String> = emptyMap()
fun load() {
try {
// 모드 리소스에서 번역 파일 로드
val resourcePath = "/assets/discordbot/lang/ko_kr.json"
val inputStream = TranslationManager::class.java.getResourceAsStream(resourcePath)
if (inputStream != null) {
InputStreamReader(inputStream, Charsets.UTF_8).use { reader ->
val type = object : TypeToken<Map<String, String>>() {}.type
translations = gson.fromJson(reader, type) ?: emptyMap()
DiscordBot.LOGGER.info("[DiscordBot] 번역 파일 로드 완료: ${translations.size}")
}
} else {
DiscordBot.LOGGER.warn("[DiscordBot] 번역 파일을 찾을 수 없습니다: $resourcePath")
}
} catch (e: Exception) {
DiscordBot.LOGGER.error("[DiscordBot] 번역 파일 로드 실패", e)
}
}
fun reload() = load()
/** 번역 키로 번역된 문자열 가져오기 */
fun translate(key: String): String? {
return translations[key]
}
/** 사망 메시지 번역 - msgId로 찾기 */
fun getDeathMessage(msgId: String, playerName: String, killerName: String? = null): String? {
// msgId 형식: "inFire", "lava", "dragonBreath.player" 등
// 번역 키 형식: "death.attack.inFire", "death.attack.lava" 등
// 먼저 player 버전 시도 (킬러가 있는 경우)
if (killerName != null) {
val playerKey = "death.attack.$msgId.player"
val playerTemplate = translations[playerKey]
if (playerTemplate != null) {
return formatDeathMessage(playerTemplate, playerName, killerName)
}
}
// 기본 버전 시도
val key = "death.attack.$msgId"
val template = translations[key] ?: return null
return formatDeathMessage(template, playerName, killerName)
}
private fun formatDeathMessage(
template: String,
playerName: String,
killerName: String?
): String {
var result = template
result = result.replace("%1\$s", playerName)
if (killerName != null) {
result = result.replace("%2\$s", killerName)
result = result.replace("%3\$s", "") // 무기 등 추가 인자
}
// 남은 플레이스홀더 제거
result = result.replace(Regex("%\\d+\\\$s"), "")
return result
}
/** 업적 제목 번역 */
fun getAdvancementTitle(advancementId: String): String? {
// minecraft:nether/return_to_sender -> advancements.nether.return_to_sender.title
val key =
"advancements." +
advancementId.removePrefix("minecraft:").replace("/", ".") +
".title"
return translations[key]
}
/** 업적 설명 번역 */
fun getAdvancementDescription(advancementId: String): String? {
val key =
"advancements." +
advancementId.removePrefix("minecraft:").replace("/", ".") +
".description"
return translations[key]
}
}

View file

@ -0,0 +1,533 @@
{
"advancements.adventure.adventuring_time.description": "모든 생물 군계를 발견하세요",
"advancements.adventure.adventuring_time.title": "모험의 시간",
"advancements.adventure.arbalistic.description": "쇠뇌 한 발로 종류가 다른 몹 다섯 마리를 죽이세요",
"advancements.adventure.arbalistic.title": "명사수",
"advancements.adventure.avoid_vibration.description": "스컬크 감지체나 워든 근처에서 웅크린 채 움직여 당신을 감지하지 못하게 하세요",
"advancements.adventure.avoid_vibration.title": "은밀하게 위대하게",
"advancements.adventure.blowback.description": "브리즈가 쏜 돌풍구를 튕겨내 브리즈를 죽이세요",
"advancements.adventure.blowback.title": "역풍",
"advancements.adventure.brush_armadillo.description": "솔을 사용해 아르마딜로에게서 아르마딜로 인갑을 얻으세요",
"advancements.adventure.brush_armadillo.title": "이게 인갑인갑다",
"advancements.adventure.bullseye.description": "30미터 이상 떨어진 곳에서 과녁 블록의 정중앙을 맞추세요",
"advancements.adventure.bullseye.title": "명중",
"advancements.adventure.craft_decorated_pot_using_only_sherds.description": "도자기 조각 4개로 장식된 도자기를 만드세요",
"advancements.adventure.craft_decorated_pot_using_only_sherds.title": "세심한 복원",
"advancements.adventure.crafters_crafting_crafters.description": "제작기가 제작기를 제작할 때 근처에 있으세요",
"advancements.adventure.crafters_crafting_crafters.title": "제작기 제 자신 제작기",
"advancements.adventure.fall_from_world_height.description": "세계의 위쪽 끝(건축 제한)에서 아래쪽 끝까지 자유 낙하하고 살아남으세요",
"advancements.adventure.fall_from_world_height.title": "동굴과 절벽",
"advancements.adventure.hero_of_the_village.description": "습격으로부터 마을을 지켜내세요",
"advancements.adventure.hero_of_the_village.title": "마을의 영웅",
"advancements.adventure.honey_block_slide.description": "꿀 블록을 향해 점프해 낙하를 멈추세요",
"advancements.adventure.honey_block_slide.title": "달콤함에 몸을 맡기다",
"advancements.adventure.kill_a_mob.description": "적대적 몬스터를 죽이세요",
"advancements.adventure.kill_a_mob.title": "몬스터 사냥꾼",
"advancements.adventure.kill_all_mobs.description": "모든 적대적 몬스터를 하나 이상씩 죽이세요",
"advancements.adventure.kill_all_mobs.title": "몬스터 도감",
"advancements.adventure.kill_mob_near_sculk_catalyst.description": "스컬크 촉매 근처에서 몹을 죽이세요",
"advancements.adventure.kill_mob_near_sculk_catalyst.title": "퍼져간다",
"advancements.adventure.lighten_up.description": "구리 전구를 도끼로 긁어내 더 밝아지게 하세요",
"advancements.adventure.lighten_up.title": "불 좀 켜 줄래?",
"advancements.adventure.lightning_rod_with_villager_no_fire.description": "주민의 감전 사고를 화재 없이 막으세요",
"advancements.adventure.lightning_rod_with_villager_no_fire.title": "번개 멈춰!",
"advancements.adventure.minecraft_trials_edition.description": "시련의 회당에 발을 들여놓으세요",
"advancements.adventure.minecraft_trials_edition.title": "인생은 시련의 연속",
"advancements.adventure.ol_betsy.description": "쇠뇌를 쏘세요",
"advancements.adventure.ol_betsy.title": "부러진 화살",
"advancements.adventure.overoverkill.description": "철퇴를 사용해 단 한 번의 타격으로 피해를 하트 50개만큼 입히세요",
"advancements.adventure.overoverkill.title": "하늘에서 철퇴가 내린다면",
"advancements.adventure.play_jukebox_in_meadows.description": "주크박스로 음악을 틀어 목초지에 활기를 불어넣으세요",
"advancements.adventure.play_jukebox_in_meadows.title": "사운드 오브 뮤직",
"advancements.adventure.read_power_from_chiseled_bookshelf.description": "비교기를 사용하여 조각된 책장의 레드스톤 신호를 측정하세요",
"advancements.adventure.read_power_from_chiseled_bookshelf.title": "책의 힘",
"advancements.adventure.revaulting.description": "불길한 시련 열쇠로 불길한 금고의 잠금을 푸세요",
"advancements.adventure.revaulting.title": "불길에 뛰어드는 나",
"advancements.adventure.root.description": "모험, 탐사와 전투",
"advancements.adventure.root.title": "모험",
"advancements.adventure.salvage_sherd.description": "수상한 블록을 솔질해 도자기 조각을 얻으세요",
"advancements.adventure.salvage_sherd.title": "소중한 잔해",
"advancements.adventure.shoot_arrow.description": "화살로 무언가를 맞히세요",
"advancements.adventure.shoot_arrow.title": "정조준",
"advancements.adventure.sleep_in_bed.description": "침대에서 자서 리스폰 지점을 바꾸세요",
"advancements.adventure.sleep_in_bed.title": "달콤한 꿈",
"advancements.adventure.sniper_duel.description": "50미터 이상 떨어져 있는 스켈레톤을 죽이세요",
"advancements.adventure.sniper_duel.title": "저격 대결",
"advancements.adventure.spyglass_at_dragon.description": "망원경으로 엔더 드래곤을 바라보세요",
"advancements.adventure.spyglass_at_dragon.title": "비행기인가?",
"advancements.adventure.spyglass_at_ghast.description": "망원경으로 가스트를 바라보세요",
"advancements.adventure.spyglass_at_ghast.title": "풍선인가?",
"advancements.adventure.spyglass_at_parrot.description": "망원경으로 앵무새를 바라보세요",
"advancements.adventure.spyglass_at_parrot.title": "새인가?",
"advancements.adventure.summon_iron_golem.description": "마을 방어를 돕기 위해 철 골렘을 소환하세요",
"advancements.adventure.summon_iron_golem.title": "도우미 고용",
"advancements.adventure.throw_trident.description": "무언가를 향해 삼지창을 던지세요.\n참고: 가지고 있는 유일한 무기를 내던지는 것은 좋은 생각이 아닙니다.",
"advancements.adventure.throw_trident.title": "준비하시고... 쏘세요!",
"advancements.adventure.totem_of_undying.description": "불사의 토템으로 죽음을 면하세요",
"advancements.adventure.totem_of_undying.title": "죽음을 초월한 자",
"advancements.adventure.trade.description": "주민과 거래하세요",
"advancements.adventure.trade.title": "훌륭한 거래군요!",
"advancements.adventure.trade_at_world_height.description": "건축 높이 제한에서 주민과 거래하세요",
"advancements.adventure.trade_at_world_height.title": "최고의 거래",
"advancements.adventure.trim_with_all_exclusive_armor_patterns.description": "다음 대장장이 형판을 모두 사용해 보세요: 첨탑, 돼지 코, 갈비뼈, 파수, 고요, 벡스, 물결, 길잡이",
"advancements.adventure.trim_with_all_exclusive_armor_patterns.title": "형판 좋은 대장장이",
"advancements.adventure.trim_with_any_armor_pattern.description": "대장장이 작업대에서 장식된 갑옷을 제작하세요",
"advancements.adventure.trim_with_any_armor_pattern.title": "유행의 선도자",
"advancements.adventure.two_birds_one_arrow.description": "관통 화살 한 발로 팬텀 두 마리를 죽이세요",
"advancements.adventure.two_birds_one_arrow.title": "일전쌍조",
"advancements.adventure.under_lock_and_key.description": "시련 열쇠로 금고의 잠금을 푸세요",
"advancements.adventure.under_lock_and_key.title": "열쇠로 잠금 해제",
"advancements.adventure.very_very_frightening.description": "주민에게 벼락을 떨어뜨리세요",
"advancements.adventure.very_very_frightening.title": "동에 번쩍 서에 번쩍",
"advancements.adventure.voluntary_exile.description": "습격 대장을 죽이세요.\n당분간 마을에서 떨어져 있는 게 좋을지도 몰라요...",
"advancements.adventure.voluntary_exile.title": "자진 유배",
"advancements.adventure.walk_on_powder_snow_with_leather_boots.description": "가루눈 위를 걸으세요... 빠지지 않고요",
"advancements.adventure.walk_on_powder_snow_with_leather_boots.title": "토끼처럼 가볍게",
"advancements.adventure.who_needs_rockets.description": "돌풍구를 사용해 자신을 8블록만큼 쏘아 올리세요",
"advancements.adventure.who_needs_rockets.title": "누가 로켓이 필요하대?",
"advancements.adventure.whos_the_pillager_now.description": "약탈자에게 똑같은 무기로 앙갚음해 주세요",
"advancements.adventure.whos_the_pillager_now.title": "이제 누가 약탈자지?",
"advancements.empty": "아무것도 없어 보이네요...",
"advancements.end.dragon_breath.description": "드래곤의 숨결을 유리병에 담으세요",
"advancements.end.dragon_breath.title": "양치질이 필요해 보이는걸",
"advancements.end.dragon_egg.description": "드래곤 알을 들어올리세요",
"advancements.end.dragon_egg.title": "그다음 세대",
"advancements.end.elytra.description": "겉날개를 찾으세요",
"advancements.end.elytra.title": "불가능은 없다",
"advancements.end.enter_end_gateway.description": "섬에서 탈출하세요",
"advancements.end.enter_end_gateway.title": "머나먼 휴양지",
"advancements.end.find_end_city.description": "들어가 보세요, 무슨 일 있겠어요?",
"advancements.end.find_end_city.title": "게임의 끝에서 만난 도시",
"advancements.end.kill_dragon.description": "행운을 빌어요",
"advancements.end.kill_dragon.title": "엔드 해방",
"advancements.end.levitate.description": "셜커의 공격을 맞고 50블록만큼 공중 부양하세요",
"advancements.end.levitate.title": "위쪽 공기 좋은데?",
"advancements.end.respawn_dragon.description": "엔더 드래곤을 다시 소환하세요",
"advancements.end.respawn_dragon.title": "끝 아녔어?",
"advancements.end.root.description": "끝일까요, 아니면 시작일까요?",
"advancements.end.root.title": "엔드",
"advancements.husbandry.allay_deliver_cake_to_note_block.description": "알레이가 소리 블록에 케이크를 떨구게 하세요",
"advancements.husbandry.allay_deliver_cake_to_note_block.title": "생일 축하 노래",
"advancements.husbandry.allay_deliver_item_to_player.description": "알레이에게 아이템을 건네받으세요",
"advancements.husbandry.allay_deliver_item_to_player.title": "난 너의 친구야",
"advancements.husbandry.axolotl_in_a_bucket.description": "양동이로 아홀로틀을 잡으세요",
"advancements.husbandry.axolotl_in_a_bucket.title": "귀여운 포식자",
"advancements.husbandry.balanced_diet.description": "먹을 수 있다면 모두 먹으세요. 그것이 건강에 좋지 않더라도 말이죠",
"advancements.husbandry.balanced_diet.title": "균형 잡힌 식단",
"advancements.husbandry.breed_all_animals.description": "모든 동물을 교배시키세요!",
"advancements.husbandry.breed_all_animals.title": "짝지어주기",
"advancements.husbandry.breed_an_animal.description": "동물 두 마리를 교배시키세요",
"advancements.husbandry.breed_an_animal.title": "아기는 어떻게 태어나?",
"advancements.husbandry.complete_catalogue.description": "모든 종류의 고양이를 길들이세요!",
"advancements.husbandry.complete_catalogue.title": "집사 그 자체",
"advancements.husbandry.feed_snifflet.description": "아기 스니퍼에게 먹이를 주세요",
"advancements.husbandry.feed_snifflet.title": "조그만 킁킁이",
"advancements.husbandry.fishy_business.description": "물고기를 잡으세요",
"advancements.husbandry.fishy_business.title": "강태공이 세월을 낚듯",
"advancements.husbandry.froglights.description": "보관함에 모든 개구리불을 가지세요",
"advancements.husbandry.froglights.title": "우리의 힘을 합친다면!",
"advancements.husbandry.kill_axolotl_target.description": "아홀로틀과 협력해 싸워 이기세요",
"advancements.husbandry.kill_axolotl_target.title": "우정의 치유력!",
"advancements.husbandry.leash_all_frog_variants.description": "모든 종류의 개구리를 끈으로 묶으세요",
"advancements.husbandry.leash_all_frog_variants.title": "개구리 삼총사 출동!",
"advancements.husbandry.make_a_sign_glow.description": "어떤 종류든 표지판의 글자를 빛나게 만드세요",
"advancements.husbandry.make_a_sign_glow.title": "밝은 말 고운 말",
"advancements.husbandry.netherite_hoe.description": "네더라이트 주괴로 괭이를 강화한 후, 삶의 선택들을 돌이켜 보세요",
"advancements.husbandry.netherite_hoe.title": "도를 넘은 전념",
"advancements.husbandry.obtain_sniffer_egg.description": "스니퍼 알을 얻으세요",
"advancements.husbandry.obtain_sniffer_egg.title": "흥미로운 냄새",
"advancements.husbandry.plant_any_sniffer_seed.description": "아무 스니퍼 씨앗이나 심으세요",
"advancements.husbandry.plant_any_sniffer_seed.title": "과거를 심다",
"advancements.husbandry.plant_seed.description": "씨앗을 심고 자라나는 것을 지켜보세요",
"advancements.husbandry.plant_seed.title": "씨앗이 자라나는 곳",
"advancements.husbandry.remove_wolf_armor.description": "가위를 사용해 늑대에게서 늑대 갑옷을 벗기세요",
"advancements.husbandry.remove_wolf_armor.title": "탁월한 가위질",
"advancements.husbandry.repair_wolf_armor.description": "아르마딜로 인갑을 사용해 손상된 늑대 갑옷을 수리하세요",
"advancements.husbandry.repair_wolf_armor.title": "새것처럼",
"advancements.husbandry.ride_a_boat_with_a_goat.description": "염소와 함께 보트에 타세요",
"advancements.husbandry.ride_a_boat_with_a_goat.title": "염소 떴소",
"advancements.husbandry.root.description": "세상은 친구들과 음식으로 가득 차 있어요",
"advancements.husbandry.root.title": "농사",
"advancements.husbandry.safely_harvest_honey.description": "꿀벌을 자극하지 않도록 모닥불을 사용해 벌통에 든 꿀을 유리병에 담으세요",
"advancements.husbandry.safely_harvest_honey.title": "벌집을 내 집처럼",
"advancements.husbandry.silk_touch_nest.description": "벌 세 마리가 들어 있는 벌집을 섬세한 손길을 사용해 옮기세요",
"advancements.husbandry.silk_touch_nest.title": "한 벌 한 벌 정성껏 모시겠습니다",
"advancements.husbandry.tactical_fishing.description": "물고기를 잡으세요... 낚싯대 없이요!",
"advancements.husbandry.tactical_fishing.title": "이 대신 잇몸으로",
"advancements.husbandry.tadpole_in_a_bucket.description": "양동이로 올챙이를 잡으세요",
"advancements.husbandry.tadpole_in_a_bucket.title": "양동이에 올챙이 한 마리",
"advancements.husbandry.tame_an_animal.description": "동물을 길들이세요",
"advancements.husbandry.tame_an_animal.title": "인생의 동반자",
"advancements.husbandry.wax_off.description": "구리 블록의 밀랍칠을 벗기세요!",
"advancements.husbandry.wax_off.title": "밀랍을 벗기자",
"advancements.husbandry.wax_on.description": "구리 블록에 벌집 조각을 사용하세요!",
"advancements.husbandry.wax_on.title": "밀랍을 칠하자",
"advancements.husbandry.whole_pack.description": "모든 종류의 늑대를 한 마리씩 길들이세요",
"advancements.husbandry.whole_pack.title": "외롭지 못한 늑대",
"advancements.nether.all_effects.description": "모든 효과를 동시에 가지세요",
"advancements.nether.all_effects.title": "어쩌다 이 지경까지",
"advancements.nether.all_potions.description": "모든 물약 효과를 동시에 가지세요",
"advancements.nether.all_potions.title": "뿅가는 폭탄주",
"advancements.nether.brew_potion.description": "물약을 만드세요",
"advancements.nether.brew_potion.title": "물약 양조장",
"advancements.nether.charge_respawn_anchor.description": "리스폰 정박기를 최대로 충전하세요",
"advancements.nether.charge_respawn_anchor.title": "목숨 충전",
"advancements.nether.create_beacon.description": "신호기를 제작하고 설치하세요",
"advancements.nether.create_beacon.title": "신호기 꾸리기",
"advancements.nether.create_full_beacon.description": "신호기의 출력을 최대로 만드세요",
"advancements.nether.create_full_beacon.title": "신호자",
"advancements.nether.distract_piglin.description": "금으로 피글린의 주의를 돌리세요",
"advancements.nether.distract_piglin.title": "반짝반짝 눈이 부셔",
"advancements.nether.explore_nether.description": "모든 네더 생물 군계를 탐험하세요",
"advancements.nether.explore_nether.title": "화끈한 관광 명소",
"advancements.nether.fast_travel.description": "네더를 이용해 오버월드의 7 km를 이동하세요",
"advancements.nether.fast_travel.title": "천 리 길도 한 걸음",
"advancements.nether.find_bastion.description": "보루 잔해에 진입하세요",
"advancements.nether.find_bastion.title": "그때가 좋았지",
"advancements.nether.find_fortress.description": "네더 요새 안으로 들어가세요",
"advancements.nether.find_fortress.title": "끔찍한 요새",
"advancements.nether.get_wither_skull.description": "위더 스켈레톤의 해골을 얻으세요",
"advancements.nether.get_wither_skull.title": "으스스한 스켈레톤",
"advancements.nether.loot_bastion.description": "보루 잔해에 있는 상자에서 노획물을 얻으세요",
"advancements.nether.loot_bastion.title": "돼지와 전쟁",
"advancements.nether.netherite_armor.description": "네더라이트 갑옷을 모두 얻으세요",
"advancements.nether.netherite_armor.title": "잔해로 날 감싸줘",
"advancements.nether.obtain_ancient_debris.description": "고대 잔해를 얻으세요",
"advancements.nether.obtain_ancient_debris.title": "깊이 파묻힌 잔해",
"advancements.nether.obtain_blaze_rod.description": "블레이즈의 막대기를 얻으세요",
"advancements.nether.obtain_blaze_rod.title": "포화 속으로",
"advancements.nether.obtain_crying_obsidian.description": "우는 흑요석을 얻으세요",
"advancements.nether.obtain_crying_obsidian.title": "누가 양파를 써나?",
"advancements.nether.return_to_sender.description": "화염구로 가스트를 죽이세요",
"advancements.nether.return_to_sender.title": "전해지지 않은 러브레터",
"advancements.nether.ride_strider.description": "뒤틀린 균 낚싯대를 들고 스트라이더 위에 타세요",
"advancements.nether.ride_strider.title": "두 발 달린 보트",
"advancements.nether.ride_strider_in_overworld_lava.description": "오버월드의 용암 호수에서 스트라이더를 타고 머어어얼리 이동하세요",
"advancements.nether.ride_strider_in_overworld_lava.title": "고향 같은 편안함",
"advancements.nether.root.description": "여름옷을 가져오세요",
"advancements.nether.root.title": "네더",
"advancements.nether.summon_wither.description": "위더를 소환하세요",
"advancements.nether.summon_wither.title": "시들어 버린 언덕",
"advancements.nether.uneasy_alliance.description": "네더에서 가스트를 구출해 오버월드로 안전하게 데려온 다음... 죽이세요",
"advancements.nether.uneasy_alliance.title": "쉽지 않은 동행",
"advancements.nether.use_lodestone.description": "자석석에 나침반을 사용하세요",
"advancements.nether.use_lodestone.title": "집으로 이끌려가네",
"advancements.progress": "%s/%s",
"advancements.sad_label": "ㅠㅠ",
"advancements.story.cure_zombie_villager.description": "좀비 주민을 약화시킨 후 치료하세요",
"advancements.story.cure_zombie_villager.title": "좀비 의사",
"advancements.story.deflect_arrow.description": "방패로 발사체를 튕겨내세요",
"advancements.story.deflect_arrow.title": "저희는 그런 것 받지 않습니다",
"advancements.story.enchant_item.description": "마법 부여대로 아이템에 마법을 부여하세요",
"advancements.story.enchant_item.title": "마법 부여자",
"advancements.story.enter_the_end.description": "엔드 차원문에 진입하세요",
"advancements.story.enter_the_end.title": "이걸로 끝이야?",
"advancements.story.enter_the_nether.description": "네더 차원문을 짓고, 불을 붙여 들어가세요",
"advancements.story.enter_the_nether.title": "더 깊은 곳으로",
"advancements.story.follow_ender_eye.description": "엔더의 눈을 따라가세요",
"advancements.story.follow_ender_eye.title": "스무고개",
"advancements.story.form_obsidian.description": "흑요석을 얻으세요",
"advancements.story.form_obsidian.title": "아이스 버킷 챌린지",
"advancements.story.iron_tools.description": "곡괭이를 개선하세요",
"advancements.story.iron_tools.title": "이젠 철 좀 들어라",
"advancements.story.lava_bucket.description": "양동이에 용암을 채우세요",
"advancements.story.lava_bucket.title": "화끈한 화제",
"advancements.story.mine_diamond.description": "다이아몬드를 얻으세요",
"advancements.story.mine_diamond.title": "다이아몬드다!",
"advancements.story.mine_stone.description": "새 곡괭이로 돌을 채굴하세요",
"advancements.story.mine_stone.title": "석기 시대",
"advancements.story.obtain_armor.description": "철 갑옷으로 스스로를 보호하세요",
"advancements.story.obtain_armor.title": "차려입기",
"advancements.story.root.description": "게임의 핵심과 이야기",
"advancements.story.root.title": "Minecraft",
"advancements.story.shiny_gear.description": "다이아몬드 갑옷은 생명을 구합니다",
"advancements.story.shiny_gear.title": "다이아몬드로 날 감싸줘",
"advancements.story.smelt_iron.description": "철 주괴를 제련하세요",
"advancements.story.smelt_iron.title": "철이 철철 넘쳐",
"advancements.story.upgrade_tools.description": "더 좋은 곡괭이를 만드세요",
"advancements.story.upgrade_tools.title": "더욱더 좋게",
"advancements.toast.challenge": "도전 완료!",
"advancements.toast.goal": "목표 달성!",
"advancements.toast.task": "발전 과제 달성!",
"death.attack.anvil": "%1$s님이 떨어지는 모루에 짓눌렸습니다",
"death.attack.anvil.player": "%1$s님이 %2$s과(와) 싸우다가 떨어지는 모루에 짓눌렸습니다",
"death.attack.arrow": "%1$s님이 %2$s에게 저격당했습니다",
"death.attack.arrow.item": "%1$s님이 %2$s에게 %3$s(으)로 저격당했습니다",
"death.attack.badRespawnPoint.link": "의도적 게임 설계",
"death.attack.badRespawnPoint.message": "%1$s님이 %2$s 때문에 죽었습니다",
"death.attack.cactus": "%1$s님이 찔려 죽었습니다",
"death.attack.cactus.player": "%1$s님이 %2$s에게서 도망치려다 선인장에 찔렸습니다",
"death.attack.cramming": "%1$s님이 으깨져버렸습니다",
"death.attack.cramming.player": "%1$s님이 %2$s에게 짓눌렸습니다",
"death.attack.dragonBreath": "%1$s님이 드래곤의 숨결에 구워졌습니다",
"death.attack.dragonBreath.player": "%1$s님이 %2$s 때문에 드래곤의 숨결에 구워졌습니다",
"death.attack.drown": "%1$s님이 익사했습니다",
"death.attack.drown.player": "%1$s님이 %2$s에게서 도망치려다 익사했습니다",
"death.attack.dryout": "%1$s님이 탈수로 죽었습니다",
"death.attack.dryout.player": "%1$s님이 %2$s에게서 도망치려다 탈수로 죽었습니다",
"death.attack.even_more_magic": "%1$s님이 더욱 심오한 마법에 당했습니다",
"death.attack.explosion": "%1$s님이 폭파당했습니다",
"death.attack.explosion.player": "%1$s님이 %2$s 때문에 폭사했습니다",
"death.attack.explosion.player.item": "%1$s님이 %3$s을(를) 사용한 %2$s 때문에 폭사했습니다",
"death.attack.fall": "%1$s님이 땅바닥으로 곤두박질쳤습니다",
"death.attack.fall.player": "%1$s님이 %2$s에게서 도망치려다 땅바닥으로 곤두박질쳤습니다",
"death.attack.fallingBlock": "%1$s님이 떨어지는 블록에 짓눌렸습니다",
"death.attack.fallingBlock.player": "%1$s님이 %2$s과(와) 싸우다가 떨어지는 블록에 짓눌렸습니다",
"death.attack.fallingStalactite": "%1$s님이 떨어지는 종유석에 찔렸습니다",
"death.attack.fallingStalactite.player": "%1$s님이 %2$s과(와) 싸우다가 떨어지는 종유석에 찔렸습니다",
"death.attack.fireball": "%1$s님이 %2$s님이 던진 화염구에 맞았습니다",
"death.attack.fireball.item": "%1$s님이 %3$s을(를) 사용한 %2$s님이 던진 화염구에 맞았습니다",
"death.attack.fireworks": "%1$s님이 굉음과 함께 폭사했습니다",
"death.attack.fireworks.item": "%1$s님이 %2$s님이 %3$s(으)로 쏜 폭죽 때문에 굉음과 함께 폭사했습니다",
"death.attack.fireworks.player": "%1$s님이 %2$s과(와) 싸우다가 굉음과 함께 폭사했습니다",
"death.attack.flyIntoWall": "%1$s님이 운동 에너지를 경험했습니다",
"death.attack.flyIntoWall.player": "%1$s님이 %2$s에게서 도망치려다 운동 에너지를 경험했습니다",
"death.attack.freeze": "%1$s님이 얼어 죽었습니다",
"death.attack.freeze.player": "%1$s님이 %2$s 때문에 얼어 죽었습니다",
"death.attack.generic": "%1$s님이 죽었습니다",
"death.attack.generic.player": "%1$s님이 %2$s 때문에 죽었습니다",
"death.attack.genericKill": "%1$s님이 죽었습니다",
"death.attack.genericKill.player": "%1$s님이 %2$s과(와) 싸우는 도중 죽었습니다",
"death.attack.hotFloor": "%1$s님이 바닥이 용암인 것을 알아챘습니다",
"death.attack.hotFloor.player": "%1$s님이 %2$s 때문에 위험 지대에 빠졌습니다",
"death.attack.inFire": "%1$s님이 화염에 휩싸였습니다",
"death.attack.inFire.player": "%1$s님이 %2$s과(와) 싸우다가 불에 빠졌습니다",
"death.attack.inWall": "%1$s님이 벽 속에서 질식했습니다",
"death.attack.inWall.player": "%1$s님이 %2$s과(와) 싸우다가 벽 속에서 질식했습니다",
"death.attack.indirectMagic": "%1$s님이 %2$s의 마법에 당했습니다",
"death.attack.indirectMagic.item": "%1$s님이 %2$s의 %3$s에 당했습니다",
"death.attack.lava": "%1$s님이 용암에 빠졌습니다",
"death.attack.lava.player": "%1$s님이 %2$s에게서 도망치려다 용암에 빠졌습니다",
"death.attack.lightningBolt": "%1$s님이 벼락을 맞았습니다",
"death.attack.lightningBolt.player": "%1$s님이 %2$s과(와) 싸우다가 벼락을 맞았습니다",
"death.attack.magic": "%1$s님이 마법에 당했습니다",
"death.attack.magic.player": "%1$s님이 %2$s에게서 도망치려다 마법에 당했습니다",
"death.attack.message_too_long": "완전히 전달하기에는 메시지가 너무 길었습니다. 죄송합니다! 잘라낸 메시지입니다: %s",
"death.attack.mob": "%1$s님이 %2$s에게 살해당했습니다",
"death.attack.mob.item": "%1$s님이 %2$s에게 %3$s(으)로 살해당했습니다",
"death.attack.onFire": "%1$s님이 불타 죽었습니다",
"death.attack.onFire.item": "%1$s님이 %3$s을(를) 든 %2$s과(와) 싸우다가 바삭하게 구워졌습니다",
"death.attack.onFire.player": "%1$s님이 %2$s과(와) 싸우다가 바삭하게 구워졌습니다",
"death.attack.outOfWorld": "%1$s님이 세계 밖으로 떨어졌습니다",
"death.attack.outOfWorld.player": "%1$s은(는) %2$s과(와)는 도저히 한 하늘을 같이 이고 살 수 없었습니다",
"death.attack.outsideBorder": "%1$s님이 세계의 한계를 벗어났습니다",
"death.attack.outsideBorder.player": "%1$s님이 %2$s과(와) 싸우다가 세계의 한계를 벗어났습니다",
"death.attack.player": "%1$s님이 %2$s에게 살해당했습니다",
"death.attack.player.item": "%1$s님이 %2$s에게 %3$s(으)로 살해당했습니다",
"death.attack.sonic_boom": "%1$s은(는) 강한 음파의 비명에 말살됐습니다",
"death.attack.sonic_boom.item": "%1$s님이 %3$s을(를) 든 %2$s에게서 도망치려다 강한 음파의 비명에 말살됐습니다",
"death.attack.sonic_boom.player": "%1$s님이 %2$s에게서 도망치려다 강한 음파의 비명에 말살됐습니다",
"death.attack.stalagmite": "%1$s님이 석순에 찔렸습니다",
"death.attack.stalagmite.player": "%1$s님이 %2$s과(와) 싸우다가 석순에 찔렸습니다",
"death.attack.starve": "%1$s님이 굶어 죽었습니다",
"death.attack.starve.player": "%1$s님이 %2$s과(와) 싸우다가 굶어 죽었습니다",
"death.attack.sting": "%1$s님이 쏘여 죽었습니다",
"death.attack.sting.item": "%1$s님이 %3$s을(를) 사용한 %2$s에게 쏘여 죽었습니다",
"death.attack.sting.player": "%1$s님이 %2$s에게 쏘여 죽었습니다",
"death.attack.sweetBerryBush": "%1$s님이 달콤한 열매 덤불에 찔려 죽었습니다",
"death.attack.sweetBerryBush.player": "%1$s님이 %2$s에게서 도망치려다 달콤한 열매 덤불에 찔려 죽었습니다",
"death.attack.thorns": "%1$s님이 %2$s을(를) 해치려다 죽었습니다",
"death.attack.thorns.item": "%1$s님이 %2$s을(를) 해치려다 %3$s 때문에 죽었습니다",
"death.attack.thrown": "%1$s님이 %2$s에게 구타당했습니다",
"death.attack.thrown.item": "%1$s님이 %2$s에게 %3$s(으)로 구타당했습니다",
"death.attack.trident": "%1$s님이 %2$s에게 찔렸습니다",
"death.attack.trident.item": "%1$s님이 %3$s을(를) 사용한 %2$s에게 찔렸습니다",
"death.attack.wither": "%1$s님이 사그라졌습니다",
"death.attack.wither.player": "%1$s님이 %2$s과(와) 싸우다가 사그라졌습니다",
"death.attack.witherSkull": "%1$s님이 %2$s에게 해골로 저격당했습니다",
"death.attack.witherSkull.item": "%1$s님이 %3$s을(를) 사용한 %2$s에게 해골로 저격당했습니다",
"death.fell.accident.generic": "%1$s님이 높은 곳에서 떨어졌습니다",
"death.fell.accident.ladder": "%1$s님이 사다리에서 떨어졌습니다",
"death.fell.accident.other_climbable": "%1$s님이 무언가를 타고 오르던 중 떨어졌습니다",
"death.fell.accident.scaffolding": "%1$s님이 비계에서 떨어졌습니다",
"death.fell.accident.twisting_vines": "%1$s님이 휘어진 덩굴에서 떨어졌습니다",
"death.fell.accident.vines": "%1$s님이 덩굴에서 떨어졌습니다",
"death.fell.accident.weeping_vines": "%1$s님이 늘어진 덩굴에서 떨어졌습니다",
"death.fell.assist": "%1$s은(는) %2$s 때문에 추락을 피하지 못했습니다",
"death.fell.assist.item": "%1$s은(는) %3$s을(를) 사용한 %2$s 때문에 추락을 피하지 못했습니다",
"death.fell.finish": "%1$s님이 너무 높은 곳에서 떨어진 후, %2$s에게 최후를 맞이했습니다",
"death.fell.finish.item": "%1$s님이 너무 높은 곳에서 떨어진 후, %3$s을(를) 사용한 %2$s에게 최후를 맞이했습니다",
"death.fell.killer": "%1$s은(는) 추락을 피하지 못했습니다",
"entity.minecraft.allay": "알레이",
"entity.minecraft.area_effect_cloud": "광역 효과 구름",
"entity.minecraft.armadillo": "아르마딜로",
"entity.minecraft.armor_stand": "갑옷 거치대",
"entity.minecraft.arrow": "화살",
"entity.minecraft.axolotl": "아홀로틀",
"entity.minecraft.bat": "박쥐",
"entity.minecraft.bee": "꿀벌",
"entity.minecraft.blaze": "블레이즈",
"entity.minecraft.block_display": "블록 표시",
"entity.minecraft.boat": "보트",
"entity.minecraft.bogged": "보그드",
"entity.minecraft.breeze": "브리즈",
"entity.minecraft.breeze_wind_charge": "돌풍구",
"entity.minecraft.camel": "낙타",
"entity.minecraft.cat": "고양이",
"entity.minecraft.cave_spider": "동굴 거미",
"entity.minecraft.chest_boat": "상자가 실린 보트",
"entity.minecraft.chest_minecart": "상자가 실린 광산 수레",
"entity.minecraft.chicken": "닭",
"entity.minecraft.cod": "대구",
"entity.minecraft.command_block_minecart": "명령 블록이 실린 광산 수레",
"entity.minecraft.cow": "소",
"entity.minecraft.creeper": "크리퍼",
"entity.minecraft.dolphin": "돌고래",
"entity.minecraft.donkey": "당나귀",
"entity.minecraft.dragon_fireball": "드래곤 화염구",
"entity.minecraft.drowned": "드라운드",
"entity.minecraft.egg": "던져진 달걀",
"entity.minecraft.elder_guardian": "엘더 가디언",
"entity.minecraft.end_crystal": "엔드 수정",
"entity.minecraft.ender_dragon": "엔더 드래곤",
"entity.minecraft.ender_pearl": "던져진 엔더 진주",
"entity.minecraft.enderman": "엔더맨",
"entity.minecraft.endermite": "엔더마이트",
"entity.minecraft.evoker": "소환사",
"entity.minecraft.evoker_fangs": "소환사 송곳니",
"entity.minecraft.experience_bottle": "던져진 경험치 병",
"entity.minecraft.experience_orb": "경험 구슬",
"entity.minecraft.eye_of_ender": "엔더의 눈",
"entity.minecraft.falling_block": "떨어지는 블록",
"entity.minecraft.falling_block_type": "떨어지는 %s",
"entity.minecraft.fireball": "화염구",
"entity.minecraft.firework_rocket": "폭죽 로켓",
"entity.minecraft.fishing_bobber": "낚시찌",
"entity.minecraft.fox": "여우",
"entity.minecraft.frog": "개구리",
"entity.minecraft.furnace_minecart": "화로가 실린 광산 수레",
"entity.minecraft.ghast": "가스트",
"entity.minecraft.giant": "거인",
"entity.minecraft.glow_item_frame": "발광 아이템 액자",
"entity.minecraft.glow_squid": "발광 오징어",
"entity.minecraft.goat": "염소",
"entity.minecraft.guardian": "가디언",
"entity.minecraft.hoglin": "호글린",
"entity.minecraft.hopper_minecart": "호퍼가 실린 광산 수레",
"entity.minecraft.horse": "말",
"entity.minecraft.husk": "허스크",
"entity.minecraft.illusioner": "환술사",
"entity.minecraft.interaction": "상호 작용",
"entity.minecraft.iron_golem": "철 골렘",
"entity.minecraft.item": "아이템",
"entity.minecraft.item_display": "아이템 표시",
"entity.minecraft.item_frame": "아이템 액자",
"entity.minecraft.killer_bunny": "살인 토끼",
"entity.minecraft.leash_knot": "끈 매듭",
"entity.minecraft.lightning_bolt": "벼락",
"entity.minecraft.llama": "라마",
"entity.minecraft.llama_spit": "라마 침",
"entity.minecraft.magma_cube": "마그마 큐브",
"entity.minecraft.marker": "표지",
"entity.minecraft.minecart": "광산 수레",
"entity.minecraft.mooshroom": "무시룸",
"entity.minecraft.mule": "노새",
"entity.minecraft.ocelot": "오실롯",
"entity.minecraft.ominous_item_spawner": "불길한 아이템 생성기",
"entity.minecraft.painting": "그림",
"entity.minecraft.panda": "판다",
"entity.minecraft.parrot": "앵무새",
"entity.minecraft.phantom": "팬텀",
"entity.minecraft.pig": "돼지",
"entity.minecraft.piglin": "피글린",
"entity.minecraft.piglin_brute": "난폭한 피글린",
"entity.minecraft.pillager": "약탈자",
"entity.minecraft.player": "플레이어",
"entity.minecraft.polar_bear": "북극곰",
"entity.minecraft.potion": "물약",
"entity.minecraft.pufferfish": "복어",
"entity.minecraft.rabbit": "토끼",
"entity.minecraft.ravager": "파괴수",
"entity.minecraft.salmon": "연어",
"entity.minecraft.sheep": "양",
"entity.minecraft.shulker": "셜커",
"entity.minecraft.shulker_bullet": "셜커 탄환",
"entity.minecraft.silverfish": "좀벌레",
"entity.minecraft.skeleton": "스켈레톤",
"entity.minecraft.skeleton_horse": "스켈레톤 말",
"entity.minecraft.slime": "슬라임",
"entity.minecraft.small_fireball": "작은 화염구",
"entity.minecraft.sniffer": "스니퍼",
"entity.minecraft.snow_golem": "눈 골렘",
"entity.minecraft.snowball": "눈덩이",
"entity.minecraft.spawner_minecart": "몬스터 생성기가 실린 광산 수레",
"entity.minecraft.spectral_arrow": "분광 화살",
"entity.minecraft.spider": "거미",
"entity.minecraft.squid": "오징어",
"entity.minecraft.stray": "스트레이",
"entity.minecraft.strider": "스트라이더",
"entity.minecraft.tadpole": "올챙이",
"entity.minecraft.text_display": "문자 표시",
"entity.minecraft.tnt": "점화된 TNT",
"entity.minecraft.tnt_minecart": "TNT가 실린 광산 수레",
"entity.minecraft.trader_llama": "상인 라마",
"entity.minecraft.trident": "삼지창",
"entity.minecraft.tropical_fish": "열대어",
"entity.minecraft.tropical_fish.predefined.0": "아네모네",
"entity.minecraft.tropical_fish.predefined.1": "긴코양쥐돔",
"entity.minecraft.tropical_fish.predefined.10": "깃대돔",
"entity.minecraft.tropical_fish.predefined.11": "오네이트 나비고기",
"entity.minecraft.tropical_fish.predefined.12": "비늘돔",
"entity.minecraft.tropical_fish.predefined.13": "여왕 신선돔",
"entity.minecraft.tropical_fish.predefined.14": "빨간 시클리드",
"entity.minecraft.tropical_fish.predefined.15": "빨간 입술 베도라치",
"entity.minecraft.tropical_fish.predefined.16": "빨간퉁돔",
"entity.minecraft.tropical_fish.predefined.17": "날가지숭어",
"entity.minecraft.tropical_fish.predefined.18": "토마토 흰동가리",
"entity.minecraft.tropical_fish.predefined.19": "쥐치복",
"entity.minecraft.tropical_fish.predefined.2": "남양쥐돔",
"entity.minecraft.tropical_fish.predefined.20": "노랑꼬리비늘돔",
"entity.minecraft.tropical_fish.predefined.21": "노랑양쥐돔",
"entity.minecraft.tropical_fish.predefined.3": "나비고기",
"entity.minecraft.tropical_fish.predefined.4": "시클리드",
"entity.minecraft.tropical_fish.predefined.5": "흰동가리",
"entity.minecraft.tropical_fish.predefined.6": "솜사탕 베타",
"entity.minecraft.tropical_fish.predefined.7": "도티백",
"entity.minecraft.tropical_fish.predefined.8": "황적퉁돔",
"entity.minecraft.tropical_fish.predefined.9": "촉수",
"entity.minecraft.tropical_fish.type.betty": "싸움고기",
"entity.minecraft.tropical_fish.type.blockfish": "사각고기",
"entity.minecraft.tropical_fish.type.brinely": "소금치",
"entity.minecraft.tropical_fish.type.clayfish": "점토고기",
"entity.minecraft.tropical_fish.type.dasher": "날쌘돌이",
"entity.minecraft.tropical_fish.type.flopper": "퍼덕이",
"entity.minecraft.tropical_fish.type.glitter": "반짝이",
"entity.minecraft.tropical_fish.type.kob": "보구치",
"entity.minecraft.tropical_fish.type.snooper": "서성이",
"entity.minecraft.tropical_fish.type.spotty": "점박이",
"entity.minecraft.tropical_fish.type.stripey": "줄무늬",
"entity.minecraft.tropical_fish.type.sunstreak": "볕금고기",
"entity.minecraft.turtle": "거북",
"entity.minecraft.vex": "벡스",
"entity.minecraft.villager": "주민",
"entity.minecraft.villager.armorer": "갑옷 제조인",
"entity.minecraft.villager.butcher": "도살업자",
"entity.minecraft.villager.cartographer": "지도 제작자",
"entity.minecraft.villager.cleric": "성직자",
"entity.minecraft.villager.farmer": "농부",
"entity.minecraft.villager.fisherman": "어부",
"entity.minecraft.villager.fletcher": "화살 제조인",
"entity.minecraft.villager.leatherworker": "가죽 세공인",
"entity.minecraft.villager.librarian": "사서",
"entity.minecraft.villager.mason": "석공",
"entity.minecraft.villager.nitwit": "멍청이",
"entity.minecraft.villager.none": "주민",
"entity.minecraft.villager.shepherd": "양치기",
"entity.minecraft.villager.toolsmith": "도구 대장장이",
"entity.minecraft.villager.weaponsmith": "무기 대장장이",
"entity.minecraft.vindicator": "변명자",
"entity.minecraft.wandering_trader": "떠돌이 상인",
"entity.minecraft.warden": "워든",
"entity.minecraft.wind_charge": "돌풍구",
"entity.minecraft.witch": "마녀",
"entity.minecraft.wither": "위더",
"entity.minecraft.wither_skeleton": "위더 스켈레톤",
"entity.minecraft.wither_skull": "위더 해골",
"entity.minecraft.wolf": "늑대",
"entity.minecraft.zoglin": "조글린",
"entity.minecraft.zombie": "좀비",
"entity.minecraft.zombie_horse": "좀비 말",
"entity.minecraft.zombie_villager": "좀비 주민",
"entity.minecraft.zombified_piglin": "좀비화 피글린",
"entity.not_summonable": "%s 유형의 개체를 소환할 수 없습니다"
}

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 = "BOTH"
[[dependencies.${mod_id}]]
modId = "minecraft"
type = "required"
versionRange = "${minecraft_version_range}"
ordering = "NONE"
side = "BOTH"
[[dependencies.${mod_id}]]
modId = "kotlinforforge"
type = "required"
versionRange = "[5.3.0,)"
ordering = "NONE"
side = "BOTH"

View file

@ -14,6 +14,7 @@ NeoForge 1.21.1 기반 마인크래프트 서버 모드 모음입니다.
| ------------------------------- | ------------------------------------- | | ------------------------------- | ------------------------------------- |
| [Essentials](./Essentials/) | 서버 필수 기능 (좌표 관리, 닉네임 등) | | [Essentials](./Essentials/) | 서버 필수 기능 (좌표 관리, 닉네임 등) |
| [ServerStatus](./ServerStatus/) | HTTP API로 서버 상태 제공 | | [ServerStatus](./ServerStatus/) | HTTP API로 서버 상태 제공 |
| [DiscordBot](./DiscordBot/) | Discord 웹훅으로 서버 이벤트 전송 |
--- ---
@ -45,6 +46,7 @@ NeoForge 1.21.1 기반 마인크래프트 서버 모드 모음입니다.
minecraft-mod/ minecraft-mod/
├── Essentials/ # 서버 필수 기능 모드 ├── Essentials/ # 서버 필수 기능 모드
├── ServerStatus/ # 서버 상태 API 모드 ├── ServerStatus/ # 서버 상태 API 모드
├── DiscordBot/ # Discord 연동 모드
└── .gitignore └── .gitignore
``` ```