Compare commits

...

5 commits

Author SHA1 Message Date
Caadiq
47b5064790 feat: IconExporter 모드 추가 및 Kotlin 변환
- Java 코드를 Kotlin으로 완전 변환
- README.md 작성
- 메인 README.md에 IconExporter 추가
- Kotlin for Forge 의존성 추가
2025-12-26 17:57:41 +09:00
Caadiq
f9fa94bf38 feat: TPS/MSPT/메모리 성능 모니터링 API 추가
- ServerStatus에 tps, mspt, memoryUsedMb, memoryMaxMb 필드 추가
- getMspt() 함수 구현 (averageTickTimeNanos 기반)
- getTps()를 mspt 기반으로 리팩토링
2025-12-23 12:46:03 +09:00
Caadiq
d4faf31b73 feat: 화이트리스트 API 엔드포인트 추가
- GET /whitelist - 화이트리스트 조회 (whitelist.json, server.properties)
- WhitelistPlayer, WhitelistResponse 데이터 클래스 추가
- white-list 값만 체크하여 활성화 상태 반환
2025-12-23 12:18:19 +09:00
Caadiq
054d7b896a feat: 닉네임 동기화 개선 및 로그 수집기 추가
- PlayerDataStore: displayName/actualName 분리 조회 함수 추가
- Essentials 닉네임 동기화 로직 개선
- LogCollector 추가 (실시간 로그 수집)
- HttpApiServer: displayName 반환 추가
2025-12-23 10:07:50 +09:00
Caadiq
19143d969c feat: 콘솔 명령어 실행 API 구현
- 모드: POST /command 엔드포인트 추가

- 모드: minecraftServer 참조 및 서버 이벤트 핸들러

- 백엔드: admin.js 라우트 (JWT + 관리자 권한)

- 프론트엔드: 실제 API 호출로 연동
2025-12-22 15:37:52 +09:00
27 changed files with 2410 additions and 318 deletions

View file

@ -1,4 +1,3 @@
package com.beemer.essentials.nickname
import com.mojang.brigadier.arguments.StringArgumentType
@ -9,119 +8,153 @@ 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
@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)
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)
}
)
)
.then(
Commands.literal("초기화")
.executes { context ->
val player = context.source.entity as? ServerPlayer
?: return@executes 0
return 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)
// 유효성 검사: 중복
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
player.sendSystemMessage(
Component.literal("이미 사용 중인 닉네임입니다.").withStyle {
it.withColor(ChatFormatting.RED)
}
)
)
.then(
Commands.literal("reset")
.executes { context ->
val player = context.source.entity as? ServerPlayer
?: return@executes 0
return 0
}
executeReset(player)
// 닉네임 저장 및 적용 (gameProfile.name = 실제 마인크래프트 이름)
NicknameDataStore.setNickname(player.uuid, player.gameProfile.name, 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)
}
)
)
}
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
return 1
}
// 유효성 검사: 중복
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

@ -11,6 +11,12 @@ import net.neoforged.fml.loading.FMLPaths
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger
/** 닉네임 데이터 엔트리 - 실제 이름과 닉네임을 함께 저장 */
data class NicknameEntry(
val originalName: String, // 실제 마인크래프트 이름
val nickname: String // 설정된 닉네임
)
/** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */
object NicknameDataStore {
private const val MOD_ID = "essentials"
@ -22,8 +28,8 @@ object NicknameDataStore {
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
// UUID -> 닉네임 매핑
private val nicknames: MutableMap<String, String> = ConcurrentHashMap()
// UUID -> NicknameEntry 매핑
private val nicknames: MutableMap<String, NicknameEntry> = ConcurrentHashMap()
/** 닉네임 데이터 로드 */
fun load() {
@ -32,11 +38,26 @@ object NicknameDataStore {
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}")
// 먼저 새 형식으로 로드 시도
try {
val type = object : TypeToken<Map<String, NicknameEntry>>() {}.type
val loaded: Map<String, NicknameEntry> = gson.fromJson(json, type) ?: emptyMap()
nicknames.clear()
nicknames.putAll(loaded)
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}")
} catch (e: Exception) {
// 기존 형식(UUID -> String)으로 마이그레이션
val oldType = object : TypeToken<Map<String, String>>() {}.type
val oldData: Map<String, String> = gson.fromJson(json, oldType) ?: emptyMap()
nicknames.clear()
oldData.forEach { (uuid, nickname) ->
// 기존 데이터는 실제 이름을 알 수 없으므로 "Unknown"으로 설정
nicknames[uuid] = NicknameEntry("Unknown", nickname)
}
save() // 새 형식으로 저장
LOGGER.info("[Essentials] 닉네임 데이터 마이그레이션 완료: ${nicknames.size}")
}
}
} catch (e: Exception) {
LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e)
@ -54,9 +75,9 @@ object NicknameDataStore {
}
}
/** 닉네임 설정 */
fun setNickname(uuid: UUID, nickname: String) {
nicknames[uuid.toString()] = nickname
/** 닉네임 설정 (실제 이름과 함께) */
fun setNickname(uuid: UUID, originalName: String, nickname: String) {
nicknames[uuid.toString()] = NicknameEntry(originalName, nickname)
save()
}
@ -69,6 +90,18 @@ object NicknameDataStore {
/** 닉네임 조회 */
@JvmStatic
fun getNickname(uuid: UUID): String? {
return nicknames[uuid.toString()]?.nickname
}
/** 실제 이름 조회 */
@JvmStatic
fun getOriginalName(uuid: UUID): String? {
return nicknames[uuid.toString()]?.originalName
}
/** 전체 엔트리 조회 */
@JvmStatic
fun getEntry(uuid: UUID): NicknameEntry? {
return nicknames[uuid.toString()]
}
@ -77,7 +110,7 @@ object NicknameDataStore {
val target = nickname.trim()
if (target.isEmpty()) return false
return nicknames.entries.any {
it.value.equals(target, ignoreCase = true) &&
it.value.nickname.equals(target, ignoreCase = true) &&
(excludeUUID == null || it.key != excludeUUID.toString())
}
}
@ -91,7 +124,7 @@ object NicknameDataStore {
fun getUuidByNickname(nickname: String): UUID? {
val target = nickname.trim()
if (target.isEmpty()) return null
val entry = nicknames.entries.find { it.value.equals(target, ignoreCase = true) }
val entry = nicknames.entries.find { it.value.nickname.equals(target, ignoreCase = true) }
return entry?.key?.let { UUID.fromString(it) }
}
}

19
IconExporter/.gitignore vendored Normal file
View file

@ -0,0 +1,19 @@
# Gradle
.gradle/
build/
bin/
# IDE
.idea/
*.iml
*.ipr
*.iws
.eclipse/
.settings/
.project
.classpath
.kotlin/
# 런타임 생성 파일
run/
logs/

110
IconExporter/README.md Normal file
View file

@ -0,0 +1,110 @@
# 🖼️ IconExporter
마인크래프트 아이템/블록 아이콘을 PNG 파일로 추출하는 클라이언트 전용 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)
---
## ✨ 주요 기능
- 🎨 **고품질 아이콘 추출** - 게임 내 실제 렌더링과 동일한 품질
- 📦 **모드 아이템 지원** - 바닐라 + 설치된 모든 모드 아이템 추출
- ⚡ **대량 추출** - 틱 기반 큐 시스템으로 게임 프리즈 없이 대량 처리
- 🖼️ **투명 배경** - PNG 알파 채널 지원
- 📏 **크기 조절** - 16x16 ~ 512x512 커스텀 크기
---
## 💻 사용법
### 커맨드 목록
| 커맨드 | 설명 |
| ------------------------------------ | --------------------------- |
| `/iconexport all` | 모든 아이템 추출 |
| `/iconexport mod <modid>` | 특정 모드 아이템만 추출 |
| `/iconexport id <namespace:item>` | 단일 아이템 추출 |
| `/iconexport size <16-512>` | 아이콘 크기 설정 (기본: 64) |
| `/iconexport overwrite <true/false>` | 덮어쓰기 설정 |
| `/iconexport listmods` | 등록된 모드 목록 확인 |
| `/iconexport status` | 현재 상태 확인 |
| `/iconexport cancel` | 진행 중인 작업 취소 |
### 예시
```bash
# Create 모드 아이템 추출 (64x64)
/iconexport mod create
# 128x128 크기로 변경 후 바닐라 아이템 추출
/iconexport size 128
/iconexport mod minecraft
```
---
## 📁 출력 구조
추출된 아이콘은 `.minecraft/icons/` 폴더에 저장됩니다.
```
.minecraft/
└── icons/
├── minecraft/
│ ├── diamond.png
│ ├── iron_ingot.png
│ └── ...
└── create/
├── mechanical_press.png
├── brass_ingot.png
└── ...
```
---
## 🛠️ 기술 스택
| 기술 | 설명 |
| -------------------- | --------------------- |
| **NeoForge** | Minecraft 모딩 플랫폼 |
| **Kotlin** | 주 개발 언어 |
| **Kotlin for Forge** | NeoForge Kotlin 지원 |
| **FBO Rendering** | 오프스크린 렌더링 |
---
## 📁 구조
```
IconExporter/
├── src/main/
│ ├── kotlin/com/beemer/iconexporter/
│ │ ├── command/ # 커맨드 처리
│ │ ├── export/ # 추출 관리자
│ │ ├── render/ # FBO 렌더러
│ │ └── IconExporter.kt # 메인 모드
│ └── resources/
│ └── META-INF/ # 모드 메타데이터
└── build.gradle
```
---
## ⚠️ 주의사항
- **클라이언트 전용** - 서버에서는 동작하지 않습니다
- **월드 필요** - 싱글플레이어 월드에 접속한 상태에서 사용해야 합니다
- **렌더링 부하** - 대량 추출 시 일시적인 FPS 저하가 있을 수 있습니다
---
## 🚀 빌드
```bash
./gradlew build
```
빌드된 JAR: `build/libs/iconexporter-1.0.0.jar`

108
IconExporter/build.gradle Normal file
View file

@ -0,0 +1,108 @@
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
}
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' }
dependencies {
// Kotlin for Forge
implementation 'thedarkcolour:kotlinforforge-neoforge:5.3.0'
}
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 @@
# Gradle 기본 설정
org.gradle.jvmargs=-Xmx2G
org.gradle.daemon=true
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
## 환경 설정
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=[4,)
parchment_minecraft_version=1.21.1
parchment_mappings_version=2024.11.17
## 모드 설정
mod_id=iconexporter
mod_name=IconExporter
mod_license=MIT
mod_version=1.0.0
mod_group_id=com.beemer
mod_authors=beemer
mod_description=Export item icons as PNG files from the game client

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
IconExporter/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
IconExporter/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,13 @@
pluginManagement {
repositories {
mavenLocal()
gradlePluginPortal()
maven { url = 'https://maven.neoforged.net/releases' }
}
}
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.8.0'
}
rootProject.name = 'IconExporter'

View file

@ -0,0 +1,57 @@
package com.beemer.iconexporter
import com.beemer.iconexporter.command.IconExportCommand
import com.beemer.iconexporter.export.IconExportManager
import net.neoforged.bus.api.IEventBus
import net.neoforged.fml.common.Mod
import net.neoforged.fml.loading.FMLEnvironment
import net.neoforged.neoforge.client.event.ClientTickEvent
import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent
import net.neoforged.neoforge.common.NeoForge
import org.slf4j.LoggerFactory
/**
* IconExporter 모드 메인 클래스
*
* 모드는 클라이언트에서만 동작하며, 인벤토리에서 보이는 아이템 아이콘을 PNG 파일로 추출하는 기능을 제공합니다.
*
* 핵심 기능:
* - /iconexport 커맨드로 아이템 아이콘 추출
* - 오프스크린 FBO 렌더링으로 게임 화면에 영향 없이 렌더링
* - 기반 처리로 대량 추출 시에도 게임 프리즈 방지
*/
@Mod(IconExporter.MOD_ID)
class IconExporter(modEventBus: IEventBus) {
companion object {
const val MOD_ID = "iconexporter"
val LOGGER = LoggerFactory.getLogger(MOD_ID)!!
}
init {
// 클라이언트 전용 모드 - 서버에서는 아무 것도 하지 않음
if (!FMLEnvironment.dist.isClient) {
LOGGER.info("IconExporter는 클라이언트 전용 모드입니다. 서버에서는 비활성화됩니다.")
} else {
LOGGER.info("IconExporter 초기화 중...")
// 커맨드 등록 이벤트 구독
NeoForge.EVENT_BUS.addListener(::onRegisterClientCommands)
// 클라이언트 틱 이벤트 구독 (큐 처리용)
NeoForge.EVENT_BUS.addListener(::onClientTick)
LOGGER.info("IconExporter 초기화 완료!")
}
}
/** 클라이언트 커맨드 등록 */
private fun onRegisterClientCommands(event: RegisterClientCommandsEvent) {
IconExportCommand.register(event.dispatcher)
LOGGER.info("IconExporter 커맨드 등록 완료")
}
/** 클라이언트 틱 이벤트 핸들러 */
private fun onClientTick(event: ClientTickEvent.Post) {
IconExportManager.tick()
}
}

View file

@ -0,0 +1,274 @@
package com.beemer.iconexporter.command
import com.beemer.iconexporter.IconExporter
import com.beemer.iconexporter.export.IconExportManager
import com.mojang.brigadier.CommandDispatcher
import com.mojang.brigadier.arguments.IntegerArgumentType
import com.mojang.brigadier.arguments.StringArgumentType
import com.mojang.brigadier.context.CommandContext
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component
import net.minecraft.resources.ResourceLocation
import net.minecraft.world.item.Items
/**
* IconExport 커맨드 시스템
*
* 제공되는 커맨드:
* - /iconexport all : 모든 아이템 추출
* - /iconexport mod <modid> : 특정 모드의 아이템만 추출
* - /iconexport id <namespace:item> : 단일 아이템 추출
* - /iconexport size <32|64|128|256> : 아이콘 크기 설정
* - /iconexport overwrite <true|false> : 덮어쓰기 설정
* - /iconexport listmods : 등록된 모드 목록
* - /iconexport cancel : 진행 중인 작업 취소
* - /iconexport status : 현재 상태 확인
*/
object IconExportCommand {
fun register(dispatcher: CommandDispatcher<CommandSourceStack>) {
dispatcher.register(
Commands.literal("iconexport")
// /iconexport all - 모든 아이템 추출
.then(Commands.literal("all")
.executes(::exportAll))
// /iconexport mod <modid> - 특정 모드 아이템 추출
.then(Commands.literal("mod")
.then(Commands.argument("modid", StringArgumentType.word())
.suggests { _, builder ->
getRegisteredNamespaces().forEach { builder.suggest(it) }
builder.buildFuture()
}
.executes(::exportMod)))
// /iconexport id <namespace:item> - 단일 아이템 추출
.then(Commands.literal("id")
.then(Commands.argument("itemid", StringArgumentType.greedyString())
.executes(::exportSingleItem)))
// /iconexport size <size> - 아이콘 크기 설정
.then(Commands.literal("size")
.then(Commands.argument("size", IntegerArgumentType.integer(16, 512))
.executes(::setSize)))
// /iconexport overwrite <true|false> - 덮어쓰기 설정
.then(Commands.literal("overwrite")
.then(Commands.literal("true")
.executes { ctx -> setOverwrite(ctx, true) })
.then(Commands.literal("false")
.executes { ctx -> setOverwrite(ctx, false) }))
// /iconexport listmods - 모드 목록 출력
.then(Commands.literal("listmods")
.executes(::listMods))
// /iconexport cancel - 작업 취소
.then(Commands.literal("cancel")
.executes(::cancelExport))
// /iconexport status - 상태 확인
.then(Commands.literal("status")
.executes(::showStatus))
// /iconexport (기본) - 도움말 표시
.executes(::showHelp)
)
}
/**
* 모든 아이템 추출
*/
private fun exportAll(context: CommandContext<CommandSourceStack>): Int {
if (IconExportManager.isRunning) {
sendMessage(context, "§c이미 추출 작업이 진행 중입니다. /iconexport cancel로 취소하거나 완료를 기다려주세요.")
return 0
}
// 모든 아이템 수집 (air 제외)
val items = BuiltInRegistries.ITEM.filter { it != Items.AIR }
IconExportManager.startExport(items, null)
sendMessage(context, "§a총 ${items.size}개 아이템 추출을 시작합니다...")
return 1
}
/**
* 특정 모드의 아이템만 추출
*/
private fun exportMod(context: CommandContext<CommandSourceStack>): Int {
val modId = StringArgumentType.getString(context, "modid")
if (IconExportManager.isRunning) {
sendMessage(context, "§c이미 추출 작업이 진행 중입니다.")
return 0
}
// 해당 모드의 아이템만 필터링
val items = BuiltInRegistries.ITEM.filter { item ->
val key = BuiltInRegistries.ITEM.getKey(item)
key != null && key.namespace == modId && item != Items.AIR
}
if (items.isEmpty()) {
sendMessage(context, "§c'$modId' 네임스페이스에 해당하는 아이템이 없습니다.")
sendMessage(context, "§7/iconexport listmods 로 사용 가능한 모드 목록을 확인하세요.")
return 0
}
IconExportManager.startExport(items, modId)
sendMessage(context, "§a[$modId] 총 ${items.size}개 아이템 추출을 시작합니다...")
return 1
}
/**
* 단일 아이템 추출
*/
private fun exportSingleItem(context: CommandContext<CommandSourceStack>): Int {
val itemIdStr = StringArgumentType.getString(context, "itemid")
if (IconExportManager.isRunning) {
sendMessage(context, "§c이미 추출 작업이 진행 중입니다.")
return 0
}
// 아이템 ID 파싱
val itemId = try {
ResourceLocation.parse(itemIdStr)
} catch (e: Exception) {
sendMessage(context, "§c잘못된 아이템 ID 형식입니다: $itemIdStr")
sendMessage(context, "§7올바른 형식: minecraft:diamond, create:mechanical_press")
return 0
}
// 아이템 조회
if (!BuiltInRegistries.ITEM.containsKey(itemId)) {
sendMessage(context, "§c아이템을 찾을 수 없습니다: $itemId")
return 0
}
val item = BuiltInRegistries.ITEM.get(itemId)
if (item == Items.AIR) {
sendMessage(context, "§cair 아이템은 추출할 수 없습니다.")
return 0
}
IconExportManager.startExport(listOf(item), null)
sendMessage(context, "§a아이템 추출 중: $itemId")
return 1
}
/**
* 아이콘 크기 설정
*/
private fun setSize(context: CommandContext<CommandSourceStack>): Int {
val size = IntegerArgumentType.getInteger(context, "size")
IconExportManager.iconSize = size
sendMessage(context, "§a아이콘 크기가 ${size}x${size}로 설정되었습니다.")
return 1
}
/**
* 덮어쓰기 설정
*/
private fun setOverwrite(context: CommandContext<CommandSourceStack>, overwrite: Boolean): Int {
IconExportManager.overwrite = overwrite
val status = if (overwrite) "§a활성화" else "§c비활성화"
sendMessage(context, "§f덮어쓰기가 $status§f되었습니다.")
return 1
}
/**
* 등록된 모드 목록 출력
*/
private fun listMods(context: CommandContext<CommandSourceStack>): Int {
val namespaces = getRegisteredNamespaces()
sendMessage(context, "§6=== 등록된 네임스페이스 (${namespaces.size}개) ===")
// 네임스페이스별 아이템 수 계산
for (ns in namespaces) {
val count = BuiltInRegistries.ITEM.count { item ->
val key = BuiltInRegistries.ITEM.getKey(item)
key != null && key.namespace == ns && item != Items.AIR
}
sendMessage(context, "§7- §f$ns §7(${count}개)")
}
sendMessage(context, "§6=================================")
sendMessage(context, "§7사용법: /iconexport mod <modid>")
return 1
}
/**
* 작업 취소
*/
private fun cancelExport(context: CommandContext<CommandSourceStack>): Int {
if (!IconExportManager.isRunning) {
sendMessage(context, "§c진행 중인 작업이 없습니다.")
return 0
}
IconExportManager.cancel()
sendMessage(context, "§e추출 작업이 취소되었습니다.")
return 1
}
/**
* 현재 상태 확인
*/
private fun showStatus(context: CommandContext<CommandSourceStack>): Int {
sendMessage(context, "§6=== IconExporter 상태 ===")
sendMessage(context, "§7아이콘 크기: §f${IconExportManager.iconSize}x${IconExportManager.iconSize}")
sendMessage(context, "§7덮어쓰기: ${if (IconExportManager.overwrite) "§a활성화" else "§c비활성화"}")
sendMessage(context, "§7진행 중: ${if (IconExportManager.isRunning) "§a예" else "§c아니오"}")
if (IconExportManager.isRunning) {
sendMessage(context, "§7진행률: §f${IconExportManager.processedCount}/${IconExportManager.totalCount} (${String.format("%.1f", IconExportManager.progress * 100)}%)")
}
sendMessage(context, "§6========================")
return 1
}
/**
* 도움말 표시
*/
private fun showHelp(context: CommandContext<CommandSourceStack>): Int {
sendMessage(context, "§6=== IconExporter 도움말 ===")
sendMessage(context, "§e/iconexport all §7- 모든 아이템 추출")
sendMessage(context, "§e/iconexport mod <modid> §7- 특정 모드 아이템 추출")
sendMessage(context, "§e/iconexport id <namespace:item> §7- 단일 아이템 추출")
sendMessage(context, "§e/iconexport size <16-512> §7- 아이콘 크기 설정 (기본: 64)")
sendMessage(context, "§e/iconexport overwrite <true|false> §7- 덮어쓰기 설정")
sendMessage(context, "§e/iconexport listmods §7- 모드 목록 확인")
sendMessage(context, "§e/iconexport status §7- 현재 상태 확인")
sendMessage(context, "§e/iconexport cancel §7- 진행 중인 작업 취소")
sendMessage(context, "§6============================")
return 1
}
/**
* 등록된 모든 네임스페이스 조회
*/
private fun getRegisteredNamespaces(): Set<String> {
return BuiltInRegistries.ITEM.keySet()
.map { it.namespace }
.toSortedSet()
}
/**
* 채팅 메시지 전송 헬퍼
*/
private fun sendMessage(context: CommandContext<CommandSourceStack>, message: String) {
context.source.sendSystemMessage(Component.literal(message))
}
}

View file

@ -0,0 +1,259 @@
package com.beemer.iconexporter.export
import com.beemer.iconexporter.IconExporter
import com.beemer.iconexporter.render.IconRenderer
import java.io.IOException
import java.nio.file.Files
import java.util.*
import kotlin.io.path.exists
import net.minecraft.client.Minecraft
import net.minecraft.core.registries.BuiltInRegistries
import net.minecraft.network.chat.Component
import net.minecraft.world.item.Item
import net.minecraft.world.item.ItemStack
/**
* 아이콘 추출 관리자
*
* 대량의 아이템을 처리할 게임 프리즈를 방지하기 위해 기반 시스템으로 아이템을 순차적으로 처리합니다.
*/
object IconExportManager {
// 틱당 처리할 아이템 수 (너무 높으면 랙, 너무 낮으면 느림)
private const val ITEMS_PER_TICK = 5
// 설정 값
var iconSize: Int = 64
var overwrite: Boolean = false
// 작업 상태
private val itemQueue: Queue<Item> = LinkedList()
private var currentModFilter: String? = null
var totalCount: Int = 0
private set
var processedCount: Int = 0
private set
private var successCount: Int = 0
private var skipCount: Int = 0
private val failedItems: MutableList<String> = mutableListOf()
private var startTime: Long = 0
var isRunning: Boolean = false
private set
val progress: Double
get() = if (totalCount > 0) processedCount.toDouble() / totalCount else 0.0
/**
* 추출 작업 시작
*
* @param items 추출할 아이템 목록
* @param modFilter 모드 필터 (로그용, null이면 전체)
*/
fun startExport(items: List<Item>, modFilter: String?) {
if (isRunning) {
IconExporter.LOGGER.warn("이미 추출 작업이 진행 중입니다.")
return
}
// 상태 초기화
itemQueue.clear()
itemQueue.addAll(items)
currentModFilter = modFilter
totalCount = items.size
processedCount = 0
successCount = 0
skipCount = 0
failedItems.clear()
startTime = System.currentTimeMillis()
isRunning = true
val target = modFilter ?: "전체"
IconExporter.LOGGER.info(
"아이콘 추출 시작: {} ({} 개 아이템, {}x{} 크기)",
target,
totalCount,
iconSize,
iconSize
)
}
/** 매 틱마다 호출되어 큐에서 아이템을 처리 */
fun tick() {
if (!isRunning || itemQueue.isEmpty()) {
return
}
val mc = Minecraft.getInstance()
// 렌더링은 메인 스레드에서만 가능
if (!mc.isSameThread) {
return
}
// 월드가 로드되지 않았으면 대기
if (mc.level == null) {
return
}
// 이번 틱에 처리할 아이템 수
var processed = 0
while (itemQueue.isNotEmpty() && processed < ITEMS_PER_TICK) {
val item = itemQueue.poll()
if (item != null) {
processItem(item)
processed++
processedCount++
}
}
// 진행률 로그 (10% 단위로 출력)
val progressPercent = (progress * 100).toInt()
if (processedCount == 1 || progressPercent % 10 == 0 || itemQueue.isEmpty()) {
logProgress()
}
// 모든 작업 완료
if (itemQueue.isEmpty()) {
finishExport()
}
}
/** 단일 아이템 처리 */
private fun processItem(item: Item) {
val itemId = BuiltInRegistries.ITEM.getKey(item)
if (itemId == null) {
failedItems.add("unknown_item")
return
}
try {
// 저장 경로 생성
val outputPath = getOutputPath(itemId.namespace, itemId.path)
// 이미 파일이 존재하고 덮어쓰기가 비활성화된 경우 스킵
if (outputPath.exists() && !overwrite) {
skipCount++
return
}
// 부모 디렉토리 생성
Files.createDirectories(outputPath.parent)
// 아이템 스택 생성 (기본 NBT)
val stack = ItemStack(item)
// 오프스크린 렌더링
val image = IconRenderer.renderItem(stack, iconSize)
if (image != null) {
// PNG로 저장
image.writeToFile(outputPath)
image.close()
successCount++
} else {
failedItems.add(itemId.toString())
}
} catch (e: IOException) {
IconExporter.LOGGER.error("아이템 저장 실패: {} - {}", itemId, e.message)
failedItems.add(itemId.toString())
} catch (e: Exception) {
IconExporter.LOGGER.error("아이템 처리 중 예외 발생: {} - {}", itemId, e.message)
failedItems.add(itemId.toString())
}
}
/** 아이템 ID로 출력 경로 생성 */
private fun getOutputPath(namespace: String, path: String): java.nio.file.Path {
val mc = Minecraft.getInstance()
val gameDir = mc.gameDirectory.toPath()
// icons/<namespace>/<path>.png
return gameDir.resolve("icons").resolve(namespace).resolve("$path.png")
}
/** 진행률 로그 출력 */
private fun logProgress() {
val elapsed = System.currentTimeMillis() - startTime
// 예상 남은 시간 계산
val eta =
if (progress > 0.01) {
val estimatedTotal = (elapsed / progress).toLong()
val remaining = estimatedTotal - elapsed
formatTime(remaining)
} else {
"계산 중..."
}
val message =
"§7[IconExporter] §f$processedCount§7/§f$totalCount §7(§a${String.format("%.1f", progress * 100)}%%§7) - 경과: ${formatTime(elapsed)}, 남은 시간: $eta"
// 게임 내 채팅에 표시
Minecraft.getInstance().player?.displayClientMessage(Component.literal(message), true)
IconExporter.LOGGER.info("진행률: {}/{} ({:.1f}%)", processedCount, totalCount, progress * 100)
}
/** 추출 작업 완료 처리 */
private fun finishExport() {
isRunning = false
val elapsed = System.currentTimeMillis() - startTime
val summary =
"""
§a=== 추출 완료 ===
§7 소요 시간: §f${formatTime(elapsed)}
§7성공: §a${successCount}§7 / 스킵: §e${skipCount}§7 / 실패: §c${failedItems.size}§7
""".trimIndent()
IconExporter.LOGGER.info(
"추출 완료: 성공 {}, 스킵 {}, 실패 {}, 소요 시간 {}",
successCount,
skipCount,
failedItems.size,
formatTime(elapsed)
)
// 실패 목록 로깅
if (failedItems.isNotEmpty()) {
IconExporter.LOGGER.warn("실패한 아이템 목록:")
failedItems.forEach { IconExporter.LOGGER.warn(" - {}", it) }
}
// 게임 내 메시지
val mc = Minecraft.getInstance()
mc.player?.let { player ->
player.displayClientMessage(Component.literal(summary), false)
// 저장 위치 안내
val outputDir = mc.gameDirectory.toPath().resolve("icons")
player.displayClientMessage(
Component.literal("§7저장 위치: §f${outputDir.toAbsolutePath()}"),
false
)
}
}
/** 작업 취소 */
fun cancel() {
if (!isRunning) return
itemQueue.clear()
isRunning = false
IconExporter.LOGGER.info("추출 작업 취소됨 (처리됨: {}/{})", processedCount, totalCount)
}
/** 밀리초를 읽기 쉬운 형식으로 변환 */
private fun formatTime(ms: Long): String {
return when {
ms < 1000 -> "${ms}ms"
ms < 60000 -> "${String.format("%.1f", ms / 1000.0)}"
else -> {
val minutes = ms / 60000
val seconds = (ms % 60000) / 1000
"${minutes}${seconds}"
}
}
}
}

View file

@ -0,0 +1,139 @@
package com.beemer.iconexporter.render
import com.beemer.iconexporter.IconExporter
import com.mojang.blaze3d.pipeline.TextureTarget
import com.mojang.blaze3d.platform.GlStateManager
import com.mojang.blaze3d.platform.Lighting
import com.mojang.blaze3d.platform.NativeImage
import com.mojang.blaze3d.systems.RenderSystem
import com.mojang.blaze3d.vertex.VertexSorting
import net.minecraft.client.Minecraft
import net.minecraft.client.gui.GuiGraphics
import net.minecraft.world.item.ItemStack
import org.joml.Matrix4f
import org.lwjgl.opengl.GL11
/**
* 아이템 아이콘 오프스크린 렌더러
*
* FBO(Frame Buffer Object) 사용하여 화면에 표시하지 않고 아이템을 렌더링한 NativeImage로 변환합니다.
*
* 렌더링 파이프라인:
* 1. TextureTarget(FBO) 생성 바인딩
* 2. 투명 배경으로 클리어
* 3. GUI Projection 설정
* 4. GUI 라이팅 설정 (인벤토리와 동일)
* 5. ItemRenderer로 아이템 렌더링
* 6. 픽셀 데이터를 NativeImage로 복사
* 7. FBO 자원 정리
*/
object IconRenderer {
/**
* 아이템을 오프스크린으로 렌더링하여 NativeImage 반환
*
* @param stack 렌더링할 아이템 스택
* @param size 출력 이미지 크기 (정사각형)
* @return 렌더링된 이미지 (호출자가 close() 해야 )
*/
fun renderItem(stack: ItemStack, size: Int): NativeImage? {
val mc = Minecraft.getInstance()
// 렌더 스레드에서만 실행 가능
if (!RenderSystem.isOnRenderThread()) {
IconExporter.LOGGER.error("렌더 스레드가 아닌 곳에서 renderItem 호출됨")
return null
}
var renderTarget: TextureTarget? = null
var image: NativeImage? = null
try {
// 1. FBO 생성 (TextureTarget = RenderTarget 구현)
renderTarget = TextureTarget(size, size, true, false)
renderTarget.setClearColor(0f, 0f, 0f, 0f)
renderTarget.clear(false)
// 2. FBO 바인딩
renderTarget.bindWrite(true)
// 3. 뷰포트 설정
RenderSystem.viewport(0, 0, size, size)
// 4. 투명 배경 클리어
RenderSystem.clearColor(0f, 0f, 0f, 0f)
RenderSystem.clear(GL11.GL_COLOR_BUFFER_BIT or GL11.GL_DEPTH_BUFFER_BIT, false)
// 5. 블렌딩 활성화 (투명도 지원)
RenderSystem.enableBlend()
RenderSystem.blendFuncSeparate(
GlStateManager.SourceFactor.SRC_ALPHA,
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA,
GlStateManager.SourceFactor.ONE,
GlStateManager.DestFactor.ONE_MINUS_SRC_ALPHA
)
// 6. GUI Projection 설정
val projectionMatrix =
Matrix4f().ortho(0f, size.toFloat(), size.toFloat(), 0f, -1000f, 3000f)
RenderSystem.setProjectionMatrix(projectionMatrix, VertexSorting.ORTHOGRAPHIC_Z)
// 7. GUI 조명 설정 (인벤토리 슬롯과 동일한 조명)
Lighting.setupForFlatItems()
// 8. GuiGraphics 생성 및 아이템 렌더링
val bufferSource = mc.renderBuffers().bufferSource()
val graphics = GuiGraphics(mc, bufferSource)
// 아이템 스케일 및 위치 조정
val scale = size / 16.0f
// GuiGraphics의 자체 PoseStack을 사용해서 변환 적용
graphics.pose().pushPose()
graphics.pose().scale(scale, scale, 1.0f)
// (0, 0)에 렌더링하면 스케일 적용 후 전체 이미지 채움
graphics.renderItem(stack, 0, 0)
graphics.pose().popPose()
// 버퍼 플러시
graphics.flush()
bufferSource.endBatch()
// 10. 조명 복원
Lighting.setupFor3DItems()
// 11. 블렌딩 비활성화
RenderSystem.disableBlend()
// 12. FBO 언바인딩
renderTarget.unbindWrite()
// 13. NativeImage 생성 및 픽셀 읽기
image = NativeImage(size, size, false)
// FBO 텍스처에서 픽셀 읽기
RenderSystem.bindTexture(renderTarget.colorTextureId)
image.downloadTexture(0, false)
// OpenGL은 좌표계가 뒤집혀 있으므로 Y축 반전
image.flipY()
// 주 프레임버퍼로 복원
mc.mainRenderTarget.bindWrite(true)
return image
} catch (e: Exception) {
IconExporter.LOGGER.error("아이템 렌더링 중 예외: {}", e.message, e)
image?.close()
return null
} finally {
// FBO 자원 정리
renderTarget?.destroyBuffers()
// 메인 렌더 타겟 복원
mc.mainRenderTarget.bindWrite(true)
}
}
}

View file

@ -0,0 +1,25 @@
modLoader="javafml"
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="CLIENT"
[[dependencies.${mod_id}]]
modId="minecraft"
type="required"
versionRange="${minecraft_version_range}"
ordering="NONE"
side="CLIENT"

View file

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

View file

@ -7,7 +7,10 @@ import co.caadiq.serverstatus.data.PlayerStatsCollector
import co.caadiq.serverstatus.data.PlayerTracker
import co.caadiq.serverstatus.data.ServerDataCollector
import co.caadiq.serverstatus.data.WorldDataCollector
import co.caadiq.serverstatus.log.LogCaptureAppender
import co.caadiq.serverstatus.log.LogUploadService
import co.caadiq.serverstatus.network.HttpApiServer
import net.minecraft.server.MinecraftServer
import net.neoforged.bus.api.IEventBus
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.fml.ModContainer
@ -15,6 +18,8 @@ import net.neoforged.fml.common.Mod
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.RegisterCommandsEvent
import net.neoforged.neoforge.event.server.ServerStartedEvent
import net.neoforged.neoforge.event.server.ServerStoppedEvent
import org.apache.logging.log4j.LogManager
/** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */
@ -37,6 +42,10 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
private set
lateinit var httpApiServer: HttpApiServer
private set
// 마인크래프트 서버 인스턴스 (명령어 실행에 사용)
var minecraftServer: MinecraftServer? = null
private set
}
init {
@ -62,6 +71,29 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
AuthCommand.register(event.dispatcher)
}
/** 서버 시작 완료 이벤트 */
@SubscribeEvent
fun onServerStarted(event: ServerStartedEvent) {
minecraftServer = event.server
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 저장됨")
// 서버 시작 시 이전 로그 파일 업로드 (비동기)
try {
val logsDir = event.server.serverDirectory.resolve("logs").toFile()
LogUploadService.uploadPreviousLogs(logsDir)
} catch (e: Exception) {
LOGGER.error("[$MOD_ID] 로그 업로드 시작 중 오류: ${e.message}")
}
}
/** 서버 종료 이벤트 */
@SubscribeEvent
fun onServerStopped(event: ServerStoppedEvent) {
minecraftServer = null
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨")
LogCaptureAppender.uninstall()
}
/** 전용 서버 설정 이벤트 */
private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
LOGGER.info("[$MOD_ID] 서버 설정 중...")
@ -75,6 +107,9 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
httpApiServer = HttpApiServer(config.httpPort)
httpApiServer.start()
// 로그 캡쳐 Appender 설치
LogCaptureAppender.install()
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
}
}

View file

@ -1,21 +1,21 @@
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 java.util.UUID
import kotlin.io.path.exists
import kotlin.io.path.readText
import kotlin.io.path.writeText
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
/**
* 모드 설정 클래스
* config/serverstatus/ 폴더에 저장
*/
/** 모드 설정 클래스 config/serverstatus/ 폴더에 저장 */
@Serializable
data class ModConfig(
val httpPort: Int = 8080
val httpPort: Int = 8080,
val serverId: String = UUID.randomUUID().toString().take(8), // 서버 고유 ID (기본값: 랜덤 8자)
val backendUrl: String = "http://minecraft-status:80" // 백엔드 API URL
) {
companion object {
private val json = Json {
@ -27,9 +27,7 @@ data class ModConfig(
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()) {
@ -46,9 +44,7 @@ data class ModConfig(
}
}
/**
* 설정 저장
*/
/** 설정 저장 */
fun save(config: ModConfig) {
try {
Files.createDirectories(configDir)

View file

@ -2,36 +2,29 @@ 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
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
/**
* 플레이어 데이터 저장소
* 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리
* config/serverstatus/ 폴더에 저장
*/
/** 플레이어 데이터 저장소 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리 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 // 현재 접속 상태 (저장 안 함)
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) - 접속 중일 때만 계산
*/
/** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */
fun getCurrentSessionMs(): Long {
return if (isOnline) {
System.currentTimeMillis() - lastJoin
@ -40,18 +33,14 @@ data class PlayerData(
}
}
/**
* 실시간 플레이타임 (ms) - 누적 + 현재 세션
*/
/** 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 */
fun getRealTimeTotalMs(): Long {
return totalPlayTimeMs + getCurrentSessionMs()
}
}
@Serializable
data class PlayerDataStore(
val players: MutableMap<String, PlayerData> = mutableMapOf()
) {
data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutableMapOf()) {
companion object {
private val json = Json {
prettyPrint = true
@ -63,15 +52,24 @@ data class PlayerDataStore(
private val dataPath = configDir.resolve("players.json")
// Essentials 닉네임 파일 경로
private val essentialsNicknamePath = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
private val essentialsNicknamePath =
FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
/**
* Essentials 닉네임 조회
*/
/** Essentials 닉네임 조회 - UUID -> nickname 매핑 반환 */
private fun loadEssentialsNicknames(): Map<String, String> {
return try {
if (essentialsNicknamePath.exists()) {
json.decodeFromString<Map<String, String>>(essentialsNicknamePath.readText())
val content = essentialsNicknamePath.readText()
// 먼저 새 형식(NicknameEntry) 시도
try {
@kotlinx.serialization.Serializable
data class NicknameEntry(val originalName: String, val nickname: String)
val entries = json.decodeFromString<Map<String, NicknameEntry>>(content)
entries.mapValues { it.value.nickname }
} catch (e: Exception) {
// 기존 형식 (UUID -> String) 시도
json.decodeFromString<Map<String, String>>(content)
}
} else {
emptyMap()
}
@ -80,9 +78,28 @@ data class PlayerDataStore(
}
}
/**
* 플레이어 데이터 로드
*/
/** Essentials 실제 이름 조회 - UUID -> originalName 매핑 반환 */
private fun loadEssentialsOriginalNames(): Map<String, String> {
return try {
if (essentialsNicknamePath.exists()) {
val content = essentialsNicknamePath.readText()
try {
@kotlinx.serialization.Serializable
data class NicknameEntry(val originalName: String, val nickname: String)
val entries = json.decodeFromString<Map<String, NicknameEntry>>(content)
entries.mapValues { it.value.originalName }
} catch (e: Exception) {
emptyMap()
}
} else {
emptyMap()
}
} catch (e: Exception) {
emptyMap()
}
}
/** 플레이어 데이터 로드 */
fun load(): PlayerDataStore {
return try {
if (dataPath.exists()) {
@ -97,17 +114,19 @@ data class PlayerDataStore(
}
}
/**
* 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름)
*/
/** 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) */
fun getDisplayName(uuid: String): String {
val essentialsNicks = loadEssentialsNicknames()
return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown"
}
/**
* 모든 플레이어 닉네임 Essentials와 동기화
*/
/** 특정 플레이어의 실제 마인크래프트 이름 가져오기 (Essentials에서 저장된 originalName 우선) */
fun getActualName(uuid: String): String {
val essentialsOriginalNames = loadEssentialsOriginalNames()
return essentialsOriginalNames[uuid] ?: players[uuid]?.name ?: "Unknown"
}
/** 모든 플레이어 닉네임 Essentials와 동기화 */
fun syncNicknamesFromEssentials() {
val essentialsNicks = loadEssentialsNicknames()
var synced = 0
@ -121,13 +140,13 @@ data class PlayerDataStore(
}
if (synced > 0) {
save()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}")
ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}"
)
}
}
/**
* 데이터 저장
*/
/** 데이터 저장 */
fun save() {
try {
Files.createDirectories(configDir)
@ -137,23 +156,18 @@ data class PlayerDataStore(
}
}
/**
* 플레이어 입장 처리
*/
/** 플레이어 입장 처리 */
fun onPlayerJoin(uuid: String, name: String) {
val now = System.currentTimeMillis()
val player = players.getOrPut(uuid) {
PlayerData(uuid = uuid, name = name, firstJoin = now)
}
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 ->
@ -164,29 +178,23 @@ data class PlayerDataStore(
// 통계 저장
if (stats != null) {
player.savedStats = stats
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}")
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,46 @@
package co.caadiq.serverstatus.data
import java.util.concurrent.ConcurrentLinkedDeque
import kotlinx.serialization.Serializable
/** 서버 로그 수집기 최근 로그를 메모리에 저장하고 API로 제공 */
object LogCollector {
// 최대 저장할 로그 수
private const val MAX_LOGS = 500
// 로그 저장소 (thread-safe)
private val logs = ConcurrentLinkedDeque<LogEntry>()
/** 로그 추가 */
fun addLog(level: String, message: String) {
val entry =
LogEntry(
time = java.time.LocalTime.now().toString().substring(0, 8),
level = level,
message = message
)
logs.addLast(entry)
// 최대 개수 초과 시 오래된 로그 제거
while (logs.size > MAX_LOGS) {
logs.pollFirst()
}
}
/** 모든 로그 조회 */
fun getLogs(): List<LogEntry> = logs.toList()
/** 최근 N개 로그 조회 */
fun getRecentLogs(count: Int): List<LogEntry> {
val allLogs = logs.toList()
return if (allLogs.size <= count) allLogs else allLogs.takeLast(count)
}
/** 로그 초기화 */
fun clear() {
logs.clear()
}
}
@Serializable data class LogEntry(val time: String, val level: String, val message: String)

View file

@ -4,15 +4,10 @@ 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
@ -25,9 +20,7 @@ object PlayerTracker {
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
}
/**
* 플레이어 퇴장 이벤트
*/
/** 플레이어 퇴장 이벤트 */
@SubscribeEvent
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
val player = event.entity
@ -37,12 +30,15 @@ object PlayerTracker {
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
}
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

@ -22,6 +22,10 @@ class ServerDataCollector {
/** 전체 서버 상태 수집 */
fun collectStatus(): ServerStatus {
val server = getServer()
val runtime = Runtime.getRuntime()
val memoryUsedMb = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024
val memoryMaxMb = runtime.maxMemory() / 1024 / 1024
val mspt = getMspt(server)
return ServerStatus(
online = server != null,
@ -29,12 +33,40 @@ class ServerDataCollector {
modLoader = getModLoaderInfo(),
difficulty = getDifficulty(),
uptimeMinutes = getUptimeMinutes(),
tps = getTps(mspt),
mspt = mspt,
memoryUsedMb = memoryUsedMb,
memoryMaxMb = memoryMaxMb,
players = getPlayersInfo(),
gameRules = getGameRules(),
mods = getModsList()
)
}
/** MSPT (Milliseconds Per Tick) 계산 */
private fun getMspt(server: MinecraftServer?): Double {
if (server == null) return 0.0
return try {
val averageTickTimeNs = server.averageTickTimeNanos
val mspt = averageTickTimeNs / 1_000_000.0
"%.2f".format(mspt).toDouble()
} catch (e: Exception) {
0.0
}
}
/** TPS (Ticks Per Second) 계산 */
private fun getTps(mspt: Double): Double {
if (mspt <= 0) return 20.0
return try {
// TPS = 1000ms / msPerTick, 최대 20
val tps = 1000.0 / mspt
minOf(tps, 20.0).let { "%.1f".format(it).toDouble() }
} catch (e: Exception) {
20.0
}
}
/** 마인크래프트 버전 */
private fun getMinecraftVersion(): String {
return try {

View file

@ -0,0 +1,119 @@
package co.caadiq.serverstatus.log
import co.caadiq.serverstatus.ServerStatusMod
import java.io.DataOutputStream
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.util.concurrent.Executors
/** 서버 시작 시 이전 로그 파일을 백엔드로 업로드하는 서비스 */
object LogUploadService {
private val executor = Executors.newSingleThreadExecutor()
/** 서버 시작 시 이전 로그 파일 업로드 (비동기) */
fun uploadPreviousLogs(logsDir: File) {
executor.submit {
try {
doUpload(logsDir)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[LogUpload] 업로드 중 예외 발생: ${e.message}")
}
}
}
private fun doUpload(logsDir: File) {
val config = ServerStatusMod.config
val serverId = config.serverId
val backendUrl = config.backendUrl
ServerStatusMod.LOGGER.info("[LogUpload] 이전 로그 파일 업로드 시작... (serverId: $serverId)")
if (!logsDir.exists() || !logsDir.isDirectory) {
ServerStatusMod.LOGGER.warn("[LogUpload] logs 폴더가 존재하지 않습니다: ${logsDir.absolutePath}")
return
}
// 모든 로그 파일 업로드 (.log, .log.gz)
val logFiles =
logsDir.listFiles()?.filter {
it.isFile && (it.name.endsWith(".log") || it.name.endsWith(".log.gz"))
}
?: emptyList()
if (logFiles.isEmpty()) {
ServerStatusMod.LOGGER.info("[LogUpload] 업로드할 로그 파일이 없습니다")
return
}
ServerStatusMod.LOGGER.info("[LogUpload] ${logFiles.size}개 파일 업로드 예정")
var successCount = 0
logFiles.forEach { file ->
try {
uploadFile(backendUrl, serverId, file)
ServerStatusMod.LOGGER.info("[LogUpload] 업로드 성공: ${file.name}")
// 업로드 성공 시 파일 삭제 (중복 업로드 방지)
if (file.delete()) {
ServerStatusMod.LOGGER.info("[LogUpload] 파일 삭제됨: ${file.name}")
}
successCount++
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[LogUpload] 업로드 실패: ${file.name} - ${e.message}")
}
}
ServerStatusMod.LOGGER.info("[LogUpload] 업로드 완료: $successCount/${logFiles.size}개 성공")
}
/** 파일 업로드 (multipart/form-data) */
private fun uploadFile(backendUrl: String, serverId: String, file: File) {
val url = URL("$backendUrl/api/admin/logs/upload")
val boundary = "----FormBoundary${System.currentTimeMillis()}"
val connection = url.openConnection() as HttpURLConnection
connection.doOutput = true
connection.requestMethod = "POST"
connection.setRequestProperty("Content-Type", "multipart/form-data; boundary=$boundary")
connection.connectTimeout = 30000
connection.readTimeout = 60000
DataOutputStream(connection.outputStream).use { out ->
// serverId 필드
out.writeBytes("--$boundary\r\n")
out.writeBytes("Content-Disposition: form-data; name=\"serverId\"\r\n\r\n")
out.writeBytes("$serverId\r\n")
// fileType 필드 (파일명으로 타입 결정)
val fileType =
when {
file.name.startsWith("debug") -> "debug"
file.name.startsWith("latest") -> "latest"
else -> "dated"
}
out.writeBytes("--$boundary\r\n")
out.writeBytes("Content-Disposition: form-data; name=\"fileType\"\r\n\r\n")
out.writeBytes("$fileType\r\n")
// 파일 필드
out.writeBytes("--$boundary\r\n")
out.writeBytes(
"Content-Disposition: form-data; name=\"file\"; filename=\"${file.name}\"\r\n"
)
val contentType = if (file.name.endsWith(".gz")) "application/gzip" else "text/plain"
out.writeBytes("Content-Type: $contentType\r\n\r\n")
out.write(file.readBytes())
out.writeBytes("\r\n")
out.writeBytes("--$boundary--\r\n")
}
val responseCode = connection.responseCode
if (responseCode !in 200..299) {
throw Exception("HTTP $responseCode: ${connection.responseMessage}")
}
connection.disconnect()
}
}

View file

@ -0,0 +1,91 @@
package co.caadiq.serverstatus.log
import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.LogCollector
import java.time.LocalTime
import java.time.format.DateTimeFormatter
import org.apache.logging.log4j.Level
import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.core.LogEvent
import org.apache.logging.log4j.core.LoggerContext
import org.apache.logging.log4j.core.appender.AbstractAppender
import org.apache.logging.log4j.core.config.Property
/** 커스텀 Log4j Appender 모든 서버 로그를 LogCollector로 전달 */
class LogCaptureAppender private constructor(name: String) :
AbstractAppender(name, null, null, true, Property.EMPTY_ARRAY) {
companion object {
private val timeFormatter = DateTimeFormatter.ofPattern("HH:mm:ss")
private var instance: LogCaptureAppender? = null
/** Appender 초기화 및 등록 */
fun install() {
try {
val ctx = LogManager.getContext(false) as LoggerContext
val config = ctx.configuration
val rootLogger = config.rootLogger
// 이미 등록되어 있으면 스킵
if (instance != null) return
// Appender 생성 및 시작
instance = LogCaptureAppender("ServerStatusLogCapture")
instance!!.start()
// Root Logger에 추가
rootLogger.addAppender(instance, Level.INFO, null)
ctx.updateLoggers()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 로그 캡처 Appender 설치됨")
} catch (e: Exception) {
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] Appender 설치 실패: ${e.message}"
)
}
}
/** Appender 제거 */
fun uninstall() {
try {
instance?.let { appender ->
val ctx = LogManager.getContext(false) as LoggerContext
val config = ctx.configuration
val rootLogger = config.rootLogger
rootLogger.removeAppender("ServerStatusLogCapture")
appender.stop()
ctx.updateLoggers()
instance = null
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 로그 캡처 Appender 제거됨")
}
} catch (e: Exception) {
// 무시
}
}
}
override fun append(event: LogEvent) {
try {
val time = LocalTime.now().format(timeFormatter)
val level = event.level.name()
val loggerName = event.loggerName?.substringAfterLast('.') ?: "Unknown"
val message = event.message?.formattedMessage ?: ""
// 빈 메시지 무시
if (message.isBlank()) return
// 자기 자신의 로그 무시 (무한 루프 방지)
if (loggerName == "ServerStatusLogCapture") return
// 로그 메시지 포맷
val formattedMessage = "[$loggerName] $message"
// LogCollector에 추가
LogCollector.addLog(level, formattedMessage)
} catch (e: Exception) {
// 로그 처리 중 오류 무시
}
}
}

View file

@ -1,6 +1,8 @@
package co.caadiq.serverstatus.network
import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.LogCollector
import co.caadiq.serverstatus.data.LogEntry
import co.caadiq.serverstatus.data.WorldInfo
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
@ -34,6 +36,12 @@ class HttpApiServer(private val port: Int) {
server?.createContext("/players", PlayersHandler())
server?.createContext("/player", PlayerHandler())
server?.createContext("/worlds", WorldsHandler())
server?.createContext("/command", CommandHandler())
server?.createContext("/logs", LogsHandler())
server?.createContext("/logfiles", LogFilesHandler())
server?.createContext("/logfile", LogFileDownloadHandler())
server?.createContext("/banlist", BanlistHandler())
server?.createContext("/whitelist", WhitelistHandler())
server?.start()
ServerStatusMod.LOGGER.info(
@ -53,8 +61,8 @@ class HttpApiServer(private val port: Int) {
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")
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type, Authorization")
val bytes = response.toByteArray(StandardCharsets.UTF_8)
exchange.sendResponseHeaders(statusCode, bytes.size.toLong())
@ -65,13 +73,43 @@ class HttpApiServer(private val port: Int) {
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid)
val actualName = ServerStatusMod.playerDataStore.getActualName(player.uuid)
// OP 여부 확인
val isOp: Boolean =
try {
val server =
net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer()
if (server != null) {
val playerList = server.playerList
if (player.isOnline) {
// 온라인 플레이어: 서버에서 직접 확인
val serverPlayer =
playerList.players.find { it.stringUUID == player.uuid }
if (serverPlayer != null) playerList.isOp(serverPlayer.gameProfile)
else false
} else {
// 오프라인 플레이어: GameProfile로 확인
val uuid = java.util.UUID.fromString(player.uuid)
val profile = com.mojang.authlib.GameProfile(uuid, actualName)
playerList.isOp(profile)
}
} else {
false
}
} catch (e: Exception) {
false
}
return PlayerDetail(
uuid = player.uuid,
name = displayName,
name = actualName,
displayName = displayName,
firstJoin = player.firstJoin,
lastJoin = player.lastJoin,
lastLeave = player.lastLeave,
isOnline = player.isOnline,
isOp = isOp,
currentSessionMs = player.getCurrentSessionMs(),
totalPlayTimeMs = player.totalPlayTimeMs
)
@ -206,6 +244,327 @@ class HttpApiServer(private val port: Int) {
}
}
}
/** POST /command - 서버 명령어 실행 */
private inner class CommandHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
if (exchange.requestMethod != "POST") {
sendJsonResponse(exchange, """{"error": "Method not allowed"}""", 405)
return
}
try {
val body = exchange.requestBody.bufferedReader().readText()
val request = json.decodeFromString<CommandRequest>(body)
val command = request.command.trim()
if (command.isEmpty()) {
sendJsonResponse(
exchange,
"""{"success": false, "message": "명령어가 비어있습니다"}""",
400
)
return
}
// 메인 스레드에서 명령어 실행
val server = ServerStatusMod.minecraftServer
if (server == null) {
sendJsonResponse(
exchange,
"""{"success": false, "message": "서버가 실행 중이 아닙니다"}""",
503
)
return
}
// 슬래시 제거 (있으면)
val cleanCommand = if (command.startsWith("/")) command.substring(1) else command
server.execute {
try {
val commandSource = server.createCommandSourceStack()
server.commands.performPrefixedCommand(commandSource, cleanCommand)
} catch (e: Exception) {
// 명령어 실행 실패는 무시 (콘솔에 이미 표시됨)
}
}
sendJsonResponse(
exchange,
"""{"success": true, "message": "명령어가 실행되었습니다: $cleanCommand"}"""
)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("명령어 실행 오류: ${e.message}")
sendJsonResponse(
exchange,
"""{"success": false, "message": "명령어 실행 중 오류 발생"}""",
500
)
}
}
}
/** GET /logs - 서버 로그 조회 */
private inner class LogsHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
try {
val logs = LogCollector.getLogs()
val response = json.encodeToString(LogsResponse(logs))
sendJsonResponse(exchange, response)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("로그 조회 오류: ${e.message}")
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
}
}
}
/** 로그 파일 목록 핸들러 */
private inner class LogFilesHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
try {
val logsDir =
ServerStatusMod.minecraftServer?.serverDirectory?.resolve("logs")?.toFile()
if (logsDir == null || !logsDir.exists()) {
sendJsonResponse(exchange, """{"files": []}""")
return
}
val files =
logsDir.listFiles()
?.filter {
it.isFile &&
(it.name.endsWith(".log") ||
it.name.endsWith(".log.gz"))
}
?.sortedByDescending { it.lastModified() }
?.take(30) // 최근 30개만
?.map { file ->
LogFileInfo(
name = file.name,
size = formatFileSize(file.length()),
date =
java.text.SimpleDateFormat("yyyy-MM-dd HH:mm")
.format(
java.util.Date(
file.lastModified()
)
)
)
}
?: emptyList()
val response = json.encodeToString(LogFilesResponse(files))
sendJsonResponse(exchange, response)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("로그 파일 목록 조회 오류: ${e.message}")
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
}
}
private fun formatFileSize(bytes: Long): String {
return when {
bytes >= 1024 * 1024 -> String.format("%.1f MB", bytes / (1024.0 * 1024.0))
bytes >= 1024 -> String.format("%.1f KB", bytes / 1024.0)
else -> "$bytes B"
}
}
}
/** 로그 파일 다운로드 핸들러 */
private inner class LogFileDownloadHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "", 204)
return
}
try {
// URL에서 파일명 추출 (/logfile?name=xxx.log.gz)
val query = exchange.requestURI.query ?: ""
val params =
query.split("&").associate {
val parts = it.split("=", limit = 2)
if (parts.size == 2)
parts[0] to java.net.URLDecoder.decode(parts[1], "UTF-8")
else parts[0] to ""
}
val fileName = params["name"]
if (fileName.isNullOrBlank()) {
sendJsonResponse(exchange, """{"error": "File name required"}""", 400)
return
}
// 보안: 파일명에 경로 구분자가 포함되면 거부
if (fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
sendJsonResponse(exchange, """{"error": "Invalid file name"}""", 400)
return
}
val logsDir =
ServerStatusMod.minecraftServer?.serverDirectory?.resolve("logs")?.toFile()
val file = logsDir?.resolve(fileName)
if (file == null || !file.exists() || !file.isFile) {
sendJsonResponse(exchange, """{"error": "File not found"}""", 404)
return
}
// 파일 다운로드 응답
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
exchange.responseHeaders.add("Content-Type", "application/octet-stream")
exchange.responseHeaders.add(
"Content-Disposition",
"attachment; filename=\"$fileName\""
)
exchange.sendResponseHeaders(200, file.length())
exchange.responseBody.use { output ->
file.inputStream().use { input -> input.copyTo(output) }
}
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("로그 파일 다운로드 오류: ${e.message}")
sendJsonResponse(exchange, """{"error": "Internal server error"}""", 500)
}
}
}
/** GET /banlist - 밴 목록 조회 (banned-players.json 읽기) */
inner class BanlistHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "")
return
}
try {
// 서버 루트 디렉토리에서 banned-players.json 파일 읽기
val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer()
val bannedPlayersFile =
server?.serverDirectory?.resolve("banned-players.json")?.toFile()
?: java.io.File("banned-players.json")
val banList =
if (bannedPlayersFile.exists()) {
try {
val content = bannedPlayersFile.readText(Charsets.UTF_8)
Json.decodeFromString<List<BannedPlayer>>(content)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] 밴 목록 파싱 오류: ${e.message}"
)
emptyList()
}
} else {
emptyList()
}
val response = BanlistResponse(banList)
sendJsonResponse(exchange, Json.encodeToString(response))
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 밴 목록 조회 오류: ${e.message}")
sendJsonResponse(exchange, """{"banList": [], "error": "${e.message}"}""", 500)
}
}
}
/** GET /whitelist - 화이트리스트 조회 (whitelist.json 읽기) */
inner class WhitelistHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
sendJsonResponse(exchange, "")
return
}
try {
val server = net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer()
val whitelistFile =
server?.serverDirectory?.resolve("whitelist.json")?.toFile()
?: java.io.File("whitelist.json")
// 화이트리스트 활성화 여부 확인 (server.properties의 white-list 값)
val propsFile =
server?.serverDirectory?.resolve("server.properties")?.toFile()
?: java.io.File("server.properties")
var enabled = false
if (propsFile.exists()) {
propsFile.readLines().forEach { line ->
if (line.startsWith("white-list=")) {
enabled = line.substringAfter("=").trim().lowercase() == "true"
}
}
}
val players =
if (whitelistFile.exists()) {
try {
val content = whitelistFile.readText(Charsets.UTF_8)
Json.decodeFromString<List<WhitelistPlayer>>(content)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] 화이트리스트 파싱 오류: ${e.message}"
)
emptyList()
}
} else {
emptyList()
}
val response = WhitelistResponse(enabled, players)
sendJsonResponse(exchange, Json.encodeToString(response))
} catch (e: Exception) {
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] 화이트리스트 조회 오류: ${e.message}"
)
sendJsonResponse(
exchange,
"""{"enabled": false, "players": [], "error": "${e.message}"}""",
500
)
}
}
}
}
@Serializable data class WorldsResponse(val worlds: List<WorldInfo>)
@Serializable data class CommandRequest(val command: String)
@Serializable data class LogsResponse(val logs: List<LogEntry>)
@Serializable data class LogFileInfo(val name: String, val size: String, val date: String)
@Serializable data class LogFilesResponse(val files: List<LogFileInfo>)
@Serializable
data class BannedPlayer(
val uuid: String,
val name: String,
val created: String,
val source: String,
val expires: String,
val reason: String
)
@Serializable data class BanlistResponse(val banList: List<BannedPlayer>)
@Serializable data class WhitelistPlayer(val uuid: String, val name: String)
@Serializable
data class WhitelistResponse(val enabled: Boolean, val players: List<WhitelistPlayer>)

View file

@ -2,87 +2,53 @@ 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>
val online: Boolean,
val version: String,
val modLoader: String,
val difficulty: String,
val uptimeMinutes: Long,
val tps: Double, // TPS (Ticks Per Second)
val mspt: Double, // MSPT (Milliseconds Per Tick)
val memoryUsedMb: Long, // 사용 중인 메모리 (MB)
val memoryMaxMb: Long, // 최대 메모리 (MB)
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>
)
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 OnlinePlayer(val name: String, val uuid: String, val isOp: Boolean)
/**
* 모드 정보
*/
@Serializable
data class ModInfo(
val id: String,
val version: String
)
/** 모드 정보 */
@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 // 누적 플레이타임 (저장된 값)
val uuid: String,
val name: String,
val displayName: String, // Essentials 닉네임 (없으면 name과 동일)
val firstJoin: Long,
val lastJoin: Long,
val lastLeave: Long,
val isOnline: Boolean,
val isOp: Boolean, // OP 여부
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
)
/**
* 전체 플레이어 목록 응답
*/
@Serializable
data class AllPlayersResponse(
val players: List<PlayerDetail>
)
/** 전체 플레이어 목록 응답 */
@Serializable data class AllPlayersResponse(val players: List<PlayerDetail>)
/**
* 플레이어 입장 이벤트
*/
@Serializable
data class PlayerJoinEvent(
val uuid: String,
val name: String
)
/** 플레이어 입장 이벤트 */
@Serializable data class PlayerJoinEvent(val uuid: String, val name: String)
/**
* 플레이어 퇴장 이벤트
*/
@Serializable
data class PlayerLeaveEvent(
val uuid: String,
val name: String
)
/** 플레이어 퇴장 이벤트 */
@Serializable data class PlayerLeaveEvent(val uuid: String, val name: String)