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 package com.beemer.essentials.nickname
import com.mojang.brigadier.arguments.StringArgumentType 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.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.RegisterCommandsEvent import net.neoforged.neoforge.event.RegisterCommandsEvent
/** /** 닉네임 명령어 /닉네임 변경 <닉네임> /닉네임 초기화 */
* 닉네임 명령어
* /닉네임 변경 <닉네임>
* /닉네임 초기화
*/
object NicknameCommand { object NicknameCommand {
@SubscribeEvent @SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) { fun onRegisterCommands(event: RegisterCommandsEvent) {
// 한글 명령어 // 한글 명령어
event.dispatcher.register( event.dispatcher.register(
Commands.literal("닉네임") Commands.literal("닉네임")
.then( .then(
Commands.literal("변경") Commands.literal("변경")
.then( .then(
Commands.argument("닉네임", StringArgumentType.greedyString()) Commands.argument(
.executes { context -> "닉네임",
val player = context.source.entity as? ServerPlayer StringArgumentType
?: return@executes 0 .greedyString()
)
.executes { context ->
val player =
context.source
.entity as?
ServerPlayer
?: return@executes 0
val nickname = StringArgumentType.getString(context, "닉네임").trim() val nickname =
executeSet(player, nickname) StringArgumentType
.getString(
context,
"닉네임"
)
.trim()
executeSet(player, nickname)
}
)
)
.then(
Commands.literal("초기화").executes { context ->
val player =
context.source.entity as? ServerPlayer
?: return@executes 0
executeReset(player)
}
)
)
// 영어 명령어
event.dispatcher.register(
Commands.literal("nickname")
.then(
Commands.literal("set")
.then(
Commands.argument(
"name",
StringArgumentType
.greedyString()
)
.executes { context ->
val player =
context.source
.entity as?
ServerPlayer
?: return@executes 0
val nickname =
StringArgumentType
.getString(
context,
"name"
)
.trim()
executeSet(player, nickname)
}
)
)
.then(
Commands.literal("reset").executes { context ->
val player =
context.source.entity as? ServerPlayer
?: return@executes 0
executeReset(player)
}
)
)
}
private fun executeSet(player: ServerPlayer, nickname: String): Int {
// 유효성 검사: 길이
if (nickname.length < 2 || nickname.length > 16) {
player.sendSystemMessage(
Component.literal("닉네임은 2~16자 사이여야 합니다.").withStyle {
it.withColor(ChatFormatting.RED)
} }
) )
) return 0
.then( }
Commands.literal("초기화")
.executes { context ->
val player = context.source.entity as? ServerPlayer
?: return@executes 0
executeReset(player) // 유효성 검사: 중복
} if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
) player.sendSystemMessage(
) Component.literal("이미 사용 중인 닉네임입니다.").withStyle {
it.withColor(ChatFormatting.RED)
// 영어 명령어
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)
} }
) )
) return 0
.then( }
Commands.literal("reset")
.executes { context ->
val player = context.source.entity as? ServerPlayer
?: return@executes 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 { return 1
// 유효성 검사: 길이
if (nickname.length < 2 || nickname.length > 16) {
player.sendSystemMessage(
Component.literal("닉네임은 2~16자 사이여야 합니다.")
.withStyle { it.withColor(ChatFormatting.RED) }
)
return 0
} }
// 유효성 검사: 중복
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
player.sendSystemMessage(
Component.literal("이미 사용 중인 닉네임입니다.")
.withStyle { it.withColor(ChatFormatting.RED) }
)
return 0
}
// 닉네임 저장 및 적용
NicknameDataStore.setNickname(player.uuid, nickname)
NicknameManager.applyNickname(player, nickname)
player.sendSystemMessage(
Component.literal("닉네임이 ")
.withStyle { it.withColor(ChatFormatting.GOLD) }
.append(Component.literal(nickname).withStyle { it.withColor(ChatFormatting.AQUA) })
.append(Component.literal("(으)로 변경되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
)
return 1
}
private fun executeReset(player: ServerPlayer): Int {
if (!NicknameDataStore.hasNickname(player.uuid)) {
player.sendSystemMessage(
Component.literal("설정된 닉네임이 없습니다.")
.withStyle { it.withColor(ChatFormatting.RED) }
)
return 0
}
NicknameDataStore.removeNickname(player.uuid)
NicknameManager.removeNickname(player)
player.sendSystemMessage(
Component.literal("닉네임이 초기화되었습니다.")
.withStyle { it.withColor(ChatFormatting.GOLD) }
)
return 1
}
} }

View file

@ -11,6 +11,12 @@ import net.neoforged.fml.loading.FMLPaths
import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.LogManager
import org.apache.logging.log4j.Logger import org.apache.logging.log4j.Logger
/** 닉네임 데이터 엔트리 - 실제 이름과 닉네임을 함께 저장 */
data class NicknameEntry(
val originalName: String, // 실제 마인크래프트 이름
val nickname: String // 설정된 닉네임
)
/** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */ /** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */
object NicknameDataStore { object NicknameDataStore {
private const val MOD_ID = "essentials" private const val MOD_ID = "essentials"
@ -22,8 +28,8 @@ object NicknameDataStore {
private val gson: Gson = GsonBuilder().setPrettyPrinting().create() private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
// UUID -> 닉네임 매핑 // UUID -> NicknameEntry 매핑
private val nicknames: MutableMap<String, String> = ConcurrentHashMap() private val nicknames: MutableMap<String, NicknameEntry> = ConcurrentHashMap()
/** 닉네임 데이터 로드 */ /** 닉네임 데이터 로드 */
fun load() { fun load() {
@ -32,11 +38,26 @@ object NicknameDataStore {
if (Files.exists(FILE_PATH)) { if (Files.exists(FILE_PATH)) {
val json = Files.readString(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() try {
nicknames.putAll(loaded) val type = object : TypeToken<Map<String, NicknameEntry>>() {}.type
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}") 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) { } catch (e: Exception) {
LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e) LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e)
@ -54,9 +75,9 @@ object NicknameDataStore {
} }
} }
/** 닉네임 설정 */ /** 닉네임 설정 (실제 이름과 함께) */
fun setNickname(uuid: UUID, nickname: String) { fun setNickname(uuid: UUID, originalName: String, nickname: String) {
nicknames[uuid.toString()] = nickname nicknames[uuid.toString()] = NicknameEntry(originalName, nickname)
save() save()
} }
@ -69,6 +90,18 @@ object NicknameDataStore {
/** 닉네임 조회 */ /** 닉네임 조회 */
@JvmStatic @JvmStatic
fun getNickname(uuid: UUID): String? { 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()] return nicknames[uuid.toString()]
} }
@ -77,7 +110,7 @@ object NicknameDataStore {
val target = nickname.trim() val target = nickname.trim()
if (target.isEmpty()) return false if (target.isEmpty()) return false
return nicknames.entries.any { return nicknames.entries.any {
it.value.equals(target, ignoreCase = true) && it.value.nickname.equals(target, ignoreCase = true) &&
(excludeUUID == null || it.key != excludeUUID.toString()) (excludeUUID == null || it.key != excludeUUID.toString())
} }
} }
@ -91,7 +124,7 @@ object NicknameDataStore {
fun getUuidByNickname(nickname: String): UUID? { fun getUuidByNickname(nickname: String): UUID? {
val target = nickname.trim() val target = nickname.trim()
if (target.isEmpty()) return null 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) } 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/) | 서버 필수 기능 (좌표 관리, 닉네임 등) | | [Essentials](./Essentials/) | 서버 필수 기능 (좌표 관리, 닉네임 등) |
| [ServerStatus](./ServerStatus/) | HTTP API로 서버 상태 제공 | | [ServerStatus](./ServerStatus/) | HTTP API로 서버 상태 제공 |
| [DiscordBot](./DiscordBot/) | Discord 웹훅으로 서버 이벤트 전송 | | [DiscordBot](./DiscordBot/) | Discord 웹훅으로 서버 이벤트 전송 |
| [IconExporter](./IconExporter/) | 아이템 아이콘 PNG 추출 (클라이언트) |
--- ---
@ -47,6 +48,7 @@ minecraft-mod/
├── Essentials/ # 서버 필수 기능 모드 ├── Essentials/ # 서버 필수 기능 모드
├── ServerStatus/ # 서버 상태 API 모드 ├── ServerStatus/ # 서버 상태 API 모드
├── DiscordBot/ # Discord 연동 모드 ├── DiscordBot/ # Discord 연동 모드
├── IconExporter/ # 아이콘 추출 모드 (클라이언트)
└── .gitignore └── .gitignore
``` ```

View file

@ -7,7 +7,10 @@ import co.caadiq.serverstatus.data.PlayerStatsCollector
import co.caadiq.serverstatus.data.PlayerTracker import co.caadiq.serverstatus.data.PlayerTracker
import co.caadiq.serverstatus.data.ServerDataCollector import co.caadiq.serverstatus.data.ServerDataCollector
import co.caadiq.serverstatus.data.WorldDataCollector 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 co.caadiq.serverstatus.network.HttpApiServer
import net.minecraft.server.MinecraftServer
import net.neoforged.bus.api.IEventBus import net.neoforged.bus.api.IEventBus
import net.neoforged.bus.api.SubscribeEvent import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.fml.ModContainer 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.fml.event.lifecycle.FMLDedicatedServerSetupEvent
import net.neoforged.neoforge.common.NeoForge import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.RegisterCommandsEvent 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 import org.apache.logging.log4j.LogManager
/** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */ /** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */
@ -37,6 +42,10 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
private set private set
lateinit var httpApiServer: HttpApiServer lateinit var httpApiServer: HttpApiServer
private set private set
// 마인크래프트 서버 인스턴스 (명령어 실행에 사용)
var minecraftServer: MinecraftServer? = null
private set
} }
init { init {
@ -62,6 +71,29 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
AuthCommand.register(event.dispatcher) 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) { private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
LOGGER.info("[$MOD_ID] 서버 설정 중...") LOGGER.info("[$MOD_ID] 서버 설정 중...")
@ -75,6 +107,9 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
httpApiServer = HttpApiServer(config.httpPort) httpApiServer = HttpApiServer(config.httpPort)
httpApiServer.start() httpApiServer.start()
// 로그 캡쳐 Appender 설치
LogCaptureAppender.install()
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})") LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
} }
} }

View file

@ -1,21 +1,21 @@
package co.caadiq.serverstatus.config package co.caadiq.serverstatus.config
import co.caadiq.serverstatus.ServerStatusMod 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.nio.file.Files
import java.util.UUID
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.readText import kotlin.io.path.readText
import kotlin.io.path.writeText 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 @Serializable
data class ModConfig( 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 { companion object {
private val json = Json { private val json = Json {
@ -27,9 +27,7 @@ data class ModConfig(
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID) private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
private val configPath = configDir.resolve("config.json") private val configPath = configDir.resolve("config.json")
/** /** 설정 파일 로드 (없으면 기본값으로 생성) */
* 설정 파일 로드 (없으면 기본값으로 생성)
*/
fun load(): ModConfig { fun load(): ModConfig {
return try { return try {
if (configPath.exists()) { if (configPath.exists()) {
@ -46,9 +44,7 @@ data class ModConfig(
} }
} }
/** /** 설정 저장 */
* 설정 저장
*/
fun save(config: ModConfig) { fun save(config: ModConfig) {
try { try {
Files.createDirectories(configDir) Files.createDirectories(configDir)

View file

@ -2,36 +2,29 @@ package co.caadiq.serverstatus.config
import co.caadiq.serverstatus.ServerStatusMod import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.PlayerStats 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 java.nio.file.Files
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.readText import kotlin.io.path.readText
import kotlin.io.path.writeText 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 @Serializable
data class PlayerData( data class PlayerData(
val uuid: String, val uuid: String,
var name: String, var name: String,
var firstJoin: Long = System.currentTimeMillis(), var firstJoin: Long = System.currentTimeMillis(),
var lastJoin: Long = System.currentTimeMillis(), var lastJoin: Long = System.currentTimeMillis(),
var lastLeave: Long = 0L, var lastLeave: Long = 0L,
var totalPlayTimeMs: Long = 0L, var totalPlayTimeMs: Long = 0L,
// 마지막 로그아웃 시 저장된 통계 // 마지막 로그아웃 시 저장된 통계
var savedStats: PlayerStats? = null, var savedStats: PlayerStats? = null,
@Transient @Transient var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
) { ) {
/** /** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
*/
fun getCurrentSessionMs(): Long { fun getCurrentSessionMs(): Long {
return if (isOnline) { return if (isOnline) {
System.currentTimeMillis() - lastJoin System.currentTimeMillis() - lastJoin
@ -40,18 +33,14 @@ data class PlayerData(
} }
} }
/** /** 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 */
* 실시간 플레이타임 (ms) - 누적 + 현재 세션
*/
fun getRealTimeTotalMs(): Long { fun getRealTimeTotalMs(): Long {
return totalPlayTimeMs + getCurrentSessionMs() return totalPlayTimeMs + getCurrentSessionMs()
} }
} }
@Serializable @Serializable
data class PlayerDataStore( data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutableMapOf()) {
val players: MutableMap<String, PlayerData> = mutableMapOf()
) {
companion object { companion object {
private val json = Json { private val json = Json {
prettyPrint = true prettyPrint = true
@ -63,15 +52,24 @@ data class PlayerDataStore(
private val dataPath = configDir.resolve("players.json") private val dataPath = configDir.resolve("players.json")
// Essentials 닉네임 파일 경로 // Essentials 닉네임 파일 경로
private val essentialsNicknamePath = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json") private val essentialsNicknamePath =
FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
/** /** Essentials 닉네임 조회 - UUID -> nickname 매핑 반환 */
* Essentials 닉네임 조회
*/
private fun loadEssentialsNicknames(): Map<String, String> { private fun loadEssentialsNicknames(): Map<String, String> {
return try { return try {
if (essentialsNicknamePath.exists()) { 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 { } else {
emptyMap() 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 { fun load(): PlayerDataStore {
return try { return try {
if (dataPath.exists()) { if (dataPath.exists()) {
@ -97,17 +114,19 @@ data class PlayerDataStore(
} }
} }
/** /** 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) */
* 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름)
*/
fun getDisplayName(uuid: String): String { fun getDisplayName(uuid: String): String {
val essentialsNicks = loadEssentialsNicknames() val essentialsNicks = loadEssentialsNicknames()
return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown" return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown"
} }
/** /** 특정 플레이어의 실제 마인크래프트 이름 가져오기 (Essentials에서 저장된 originalName 우선) */
* 모든 플레이어 닉네임 Essentials와 동기화 fun getActualName(uuid: String): String {
*/ val essentialsOriginalNames = loadEssentialsOriginalNames()
return essentialsOriginalNames[uuid] ?: players[uuid]?.name ?: "Unknown"
}
/** 모든 플레이어 닉네임 Essentials와 동기화 */
fun syncNicknamesFromEssentials() { fun syncNicknamesFromEssentials() {
val essentialsNicks = loadEssentialsNicknames() val essentialsNicks = loadEssentialsNicknames()
var synced = 0 var synced = 0
@ -121,13 +140,13 @@ data class PlayerDataStore(
} }
if (synced > 0) { if (synced > 0) {
save() save()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}") ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}"
)
} }
} }
/** /** 데이터 저장 */
* 데이터 저장
*/
fun save() { fun save() {
try { try {
Files.createDirectories(configDir) Files.createDirectories(configDir)
@ -137,23 +156,18 @@ data class PlayerDataStore(
} }
} }
/** /** 플레이어 입장 처리 */
* 플레이어 입장 처리
*/
fun onPlayerJoin(uuid: String, name: String) { fun onPlayerJoin(uuid: String, name: String) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
val player = players.getOrPut(uuid) { val player =
PlayerData(uuid = uuid, name = name, firstJoin = now) players.getOrPut(uuid) { PlayerData(uuid = uuid, name = name, firstJoin = now) }
}
player.name = name player.name = name
player.lastJoin = now player.lastJoin = now
player.isOnline = true player.isOnline = true
save() save()
} }
/** /** 플레이어 퇴장 처리 (통계 저장 포함) */
* 플레이어 퇴장 처리 (통계 저장 포함)
*/
fun onPlayerLeave(uuid: String, stats: PlayerStats?) { fun onPlayerLeave(uuid: String, stats: PlayerStats?) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
players[uuid]?.let { player -> players[uuid]?.let { player ->
@ -164,29 +178,23 @@ data class PlayerDataStore(
// 통계 저장 // 통계 저장
if (stats != null) { if (stats != null) {
player.savedStats = stats player.savedStats = stats
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}") ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}"
)
} }
save() save()
} }
} }
/** /** 플레이어 정보 조회 */
* 플레이어 정보 조회
*/
fun getPlayer(uuid: String): PlayerData? = players[uuid] fun getPlayer(uuid: String): PlayerData? = players[uuid]
/** /** 전체 플레이어 목록 조회 */
* 전체 플레이어 목록 조회
*/
fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name } fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name }
/** /** 플레이어 온라인 상태 확인 */
* 플레이어 온라인 상태 확인
*/
fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false
/** /** 저장된 통계 조회 */
* 저장된 통계 조회
*/
fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats 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.bus.api.SubscribeEvent
import net.neoforged.neoforge.event.entity.player.PlayerEvent import net.neoforged.neoforge.event.entity.player.PlayerEvent
/** /** 플레이어 이벤트 추적기 입장/퇴장 이벤트를 감지하여 데이터 저장 */
* 플레이어 이벤트 추적기
* 입장/퇴장 이벤트를 감지하여 데이터 저장
*/
object PlayerTracker { object PlayerTracker {
/** /** 플레이어 입장 이벤트 */
* 플레이어 입장 이벤트
*/
@SubscribeEvent @SubscribeEvent
fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) { fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) {
val player = event.entity val player = event.entity
@ -25,9 +20,7 @@ object PlayerTracker {
ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name) ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name)
} }
/** /** 플레이어 퇴장 이벤트 */
* 플레이어 퇴장 이벤트
*/
@SubscribeEvent @SubscribeEvent
fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) { fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) {
val player = event.entity val player = event.entity
@ -37,12 +30,15 @@ object PlayerTracker {
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)") ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
// 통계 수집 후 저장 // 통계 수집 후 저장
val stats = try { val stats =
ServerStatusMod.playerStatsCollector.collectStats(uuid) try {
} catch (e: Exception) { ServerStatusMod.playerStatsCollector.collectStats(uuid)
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}") } catch (e: Exception) {
null ServerStatusMod.LOGGER.error(
} "[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}"
)
null
}
// 플레이어 데이터 및 통계 저장 // 플레이어 데이터 및 통계 저장
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats) ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)

View file

@ -22,6 +22,10 @@ class ServerDataCollector {
/** 전체 서버 상태 수집 */ /** 전체 서버 상태 수집 */
fun collectStatus(): ServerStatus { fun collectStatus(): ServerStatus {
val server = getServer() 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( return ServerStatus(
online = server != null, online = server != null,
@ -29,12 +33,40 @@ class ServerDataCollector {
modLoader = getModLoaderInfo(), modLoader = getModLoaderInfo(),
difficulty = getDifficulty(), difficulty = getDifficulty(),
uptimeMinutes = getUptimeMinutes(), uptimeMinutes = getUptimeMinutes(),
tps = getTps(mspt),
mspt = mspt,
memoryUsedMb = memoryUsedMb,
memoryMaxMb = memoryMaxMb,
players = getPlayersInfo(), players = getPlayersInfo(),
gameRules = getGameRules(), gameRules = getGameRules(),
mods = getModsList() 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 { private fun getMinecraftVersion(): String {
return try { 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 package co.caadiq.serverstatus.network
import co.caadiq.serverstatus.ServerStatusMod import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.LogCollector
import co.caadiq.serverstatus.data.LogEntry
import co.caadiq.serverstatus.data.WorldInfo import co.caadiq.serverstatus.data.WorldInfo
import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpHandler
@ -34,6 +36,12 @@ class HttpApiServer(private val port: Int) {
server?.createContext("/players", PlayersHandler()) server?.createContext("/players", PlayersHandler())
server?.createContext("/player", PlayerHandler()) server?.createContext("/player", PlayerHandler())
server?.createContext("/worlds", WorldsHandler()) 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() server?.start()
ServerStatusMod.LOGGER.info( ServerStatusMod.LOGGER.info(
@ -53,8 +61,8 @@ class HttpApiServer(private val port: Int) {
private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) { private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) {
exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8") exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8")
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*") exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS") exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type") exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type, Authorization")
val bytes = response.toByteArray(StandardCharsets.UTF_8) val bytes = response.toByteArray(StandardCharsets.UTF_8)
exchange.sendResponseHeaders(statusCode, bytes.size.toLong()) 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 { private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용) // Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid) 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( return PlayerDetail(
uuid = player.uuid, uuid = player.uuid,
name = displayName, name = actualName,
displayName = displayName,
firstJoin = player.firstJoin, firstJoin = player.firstJoin,
lastJoin = player.lastJoin, lastJoin = player.lastJoin,
lastLeave = player.lastLeave, lastLeave = player.lastLeave,
isOnline = player.isOnline, isOnline = player.isOnline,
isOp = isOp,
currentSessionMs = player.getCurrentSessionMs(), currentSessionMs = player.getCurrentSessionMs(),
totalPlayTimeMs = player.totalPlayTimeMs 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 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 import kotlinx.serialization.Serializable
/** /** 서버 상태 */
* 서버 상태
*/
@Serializable @Serializable
data class ServerStatus( data class ServerStatus(
val online: Boolean, val online: Boolean,
val version: String, val version: String,
val modLoader: String, val modLoader: String,
val difficulty: String, val difficulty: String,
val uptimeMinutes: Long, val uptimeMinutes: Long,
val players: PlayersInfo, val tps: Double, // TPS (Ticks Per Second)
val gameRules: Map<String, Boolean>, val mspt: Double, // MSPT (Milliseconds Per Tick)
val mods: List<ModInfo> val memoryUsedMb: Long, // 사용 중인 메모리 (MB)
val memoryMaxMb: Long, // 최대 메모리 (MB)
val players: PlayersInfo,
val gameRules: Map<String, Boolean>,
val mods: List<ModInfo>
) )
/** /** 플레이어 정보 */
* 플레이어 정보
*/
@Serializable @Serializable
data class PlayersInfo( data class PlayersInfo(val current: Int, val max: Int, val online: List<OnlinePlayer>)
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 @Serializable
data class PlayerDetail( data class PlayerDetail(
val uuid: String, val uuid: String,
val name: String, val name: String,
val firstJoin: Long, val displayName: String, // Essentials 닉네임 (없으면 name과 동일)
val lastJoin: Long, val firstJoin: Long,
val lastLeave: Long, val lastJoin: Long,
val isOnline: Boolean, val lastLeave: Long,
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만) val isOnline: Boolean,
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값) 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
)