Compare commits
No commits in common. "47b5064790fa3c6c2106040d085135f8e1fb3099" and "67f9cac26fa4a0a16916057cb5cabe9469bf30da" have entirely different histories.
47b5064790
...
67f9cac26f
27 changed files with 321 additions and 2413 deletions
|
|
@ -1,3 +1,4 @@
|
||||||
|
|
||||||
package com.beemer.essentials.nickname
|
package com.beemer.essentials.nickname
|
||||||
|
|
||||||
import com.mojang.brigadier.arguments.StringArgumentType
|
import com.mojang.brigadier.arguments.StringArgumentType
|
||||||
|
|
@ -8,153 +9,119 @@ 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(
|
Commands.argument("닉네임", StringArgumentType.greedyString())
|
||||||
"닉네임",
|
.executes { context ->
|
||||||
StringArgumentType
|
val player = context.source.entity as? ServerPlayer
|
||||||
.greedyString()
|
?: return@executes 0
|
||||||
)
|
|
||||||
.executes { context ->
|
val nickname = StringArgumentType.getString(context, "닉네임").trim()
|
||||||
val player =
|
executeSet(player, nickname)
|
||||||
context.source
|
|
||||||
.entity as?
|
|
||||||
ServerPlayer
|
|
||||||
?: return@executes 0
|
|
||||||
|
|
||||||
val nickname =
|
|
||||||
StringArgumentType
|
|
||||||
.getString(
|
|
||||||
context,
|
|
||||||
"닉네임"
|
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
executeSet(player, nickname)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
Commands.literal("초기화").executes { context ->
|
|
||||||
val player =
|
|
||||||
context.source.entity as? ServerPlayer
|
|
||||||
?: return@executes 0
|
|
||||||
|
|
||||||
executeReset(player)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
// 영어 명령어
|
|
||||||
event.dispatcher.register(
|
|
||||||
Commands.literal("nickname")
|
|
||||||
.then(
|
|
||||||
Commands.literal("set")
|
|
||||||
.then(
|
|
||||||
Commands.argument(
|
|
||||||
"name",
|
|
||||||
StringArgumentType
|
|
||||||
.greedyString()
|
|
||||||
)
|
|
||||||
.executes { context ->
|
|
||||||
val player =
|
|
||||||
context.source
|
|
||||||
.entity as?
|
|
||||||
ServerPlayer
|
|
||||||
?: return@executes 0
|
|
||||||
|
|
||||||
val nickname =
|
|
||||||
StringArgumentType
|
|
||||||
.getString(
|
|
||||||
context,
|
|
||||||
"name"
|
|
||||||
)
|
|
||||||
.trim()
|
|
||||||
executeSet(player, nickname)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
Commands.literal("reset").executes { context ->
|
|
||||||
val player =
|
|
||||||
context.source.entity as? ServerPlayer
|
|
||||||
?: return@executes 0
|
|
||||||
|
|
||||||
executeReset(player)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun executeSet(player: ServerPlayer, nickname: String): Int {
|
|
||||||
// 유효성 검사: 길이
|
|
||||||
if (nickname.length < 2 || nickname.length > 16) {
|
|
||||||
player.sendSystemMessage(
|
|
||||||
Component.literal("닉네임은 2~16자 사이여야 합니다.").withStyle {
|
|
||||||
it.withColor(ChatFormatting.RED)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 유효성 검사: 중복
|
|
||||||
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
|
|
||||||
player.sendSystemMessage(
|
|
||||||
Component.literal("이미 사용 중인 닉네임입니다.").withStyle {
|
|
||||||
it.withColor(ChatFormatting.RED)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
// 닉네임 저장 및 적용 (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)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.then(
|
||||||
return 1
|
Commands.literal("초기화")
|
||||||
}
|
.executes { context ->
|
||||||
|
val player = context.source.entity as? ServerPlayer
|
||||||
private fun executeReset(player: ServerPlayer): Int {
|
?: return@executes 0
|
||||||
if (!NicknameDataStore.hasNickname(player.uuid)) {
|
|
||||||
player.sendSystemMessage(
|
executeReset(player)
|
||||||
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
|
|
||||||
|
// 영어 명령어
|
||||||
|
event.dispatcher.register(
|
||||||
|
Commands.literal("nickname")
|
||||||
|
.then(
|
||||||
|
Commands.literal("set")
|
||||||
|
.then(
|
||||||
|
Commands.argument("name", StringArgumentType.greedyString())
|
||||||
|
.executes { context ->
|
||||||
|
val player = context.source.entity as? ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
val nickname = StringArgumentType.getString(context, "name").trim()
|
||||||
|
executeSet(player, nickname)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.then(
|
||||||
|
Commands.literal("reset")
|
||||||
|
.executes { context ->
|
||||||
|
val player = context.source.entity as? ServerPlayer
|
||||||
|
?: return@executes 0
|
||||||
|
|
||||||
|
executeReset(player)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeSet(player: ServerPlayer, nickname: String): Int {
|
||||||
|
// 유효성 검사: 길이
|
||||||
|
if (nickname.length < 2 || nickname.length > 16) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("닉네임은 2~16자 사이여야 합니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||||
|
)
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 유효성 검사: 중복
|
||||||
|
if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("이미 사용 중인 닉네임입니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// 닉네임 저장 및 적용
|
||||||
|
NicknameDataStore.setNickname(player.uuid, nickname)
|
||||||
|
NicknameManager.applyNickname(player, nickname)
|
||||||
|
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("닉네임이 ")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||||
|
.append(Component.literal(nickname).withStyle { it.withColor(ChatFormatting.AQUA) })
|
||||||
|
.append(Component.literal("(으)로 변경되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) })
|
||||||
|
)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun executeReset(player: ServerPlayer): Int {
|
||||||
|
if (!NicknameDataStore.hasNickname(player.uuid)) {
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("설정된 닉네임이 없습니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.RED) }
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
NicknameDataStore.removeNickname(player.uuid)
|
||||||
|
NicknameManager.removeNickname(player)
|
||||||
|
|
||||||
|
player.sendSystemMessage(
|
||||||
|
Component.literal("닉네임이 초기화되었습니다.")
|
||||||
|
.withStyle { it.withColor(ChatFormatting.GOLD) }
|
||||||
|
)
|
||||||
|
|
||||||
|
return 1
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -11,12 +11,6 @@ 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"
|
||||||
|
|
@ -28,8 +22,8 @@ object NicknameDataStore {
|
||||||
|
|
||||||
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
private val gson: Gson = GsonBuilder().setPrettyPrinting().create()
|
||||||
|
|
||||||
// UUID -> NicknameEntry 매핑
|
// UUID -> 닉네임 매핑
|
||||||
private val nicknames: MutableMap<String, NicknameEntry> = ConcurrentHashMap()
|
private val nicknames: MutableMap<String, String> = ConcurrentHashMap()
|
||||||
|
|
||||||
/** 닉네임 데이터 로드 */
|
/** 닉네임 데이터 로드 */
|
||||||
fun load() {
|
fun load() {
|
||||||
|
|
@ -38,26 +32,11 @@ 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()
|
||||||
try {
|
nicknames.clear()
|
||||||
val type = object : TypeToken<Map<String, NicknameEntry>>() {}.type
|
nicknames.putAll(loaded)
|
||||||
val loaded: Map<String, NicknameEntry> = gson.fromJson(json, type) ?: emptyMap()
|
LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}개")
|
||||||
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)
|
||||||
|
|
@ -75,9 +54,9 @@ object NicknameDataStore {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 닉네임 설정 (실제 이름과 함께) */
|
/** 닉네임 설정 */
|
||||||
fun setNickname(uuid: UUID, originalName: String, nickname: String) {
|
fun setNickname(uuid: UUID, nickname: String) {
|
||||||
nicknames[uuid.toString()] = NicknameEntry(originalName, nickname)
|
nicknames[uuid.toString()] = nickname
|
||||||
save()
|
save()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -90,18 +69,6 @@ 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()]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -110,7 +77,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.nickname.equals(target, ignoreCase = true) &&
|
it.value.equals(target, ignoreCase = true) &&
|
||||||
(excludeUUID == null || it.key != excludeUUID.toString())
|
(excludeUUID == null || it.key != excludeUUID.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -124,7 +91,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.nickname.equals(target, ignoreCase = true) }
|
val entry = nicknames.entries.find { it.value.equals(target, ignoreCase = true) }
|
||||||
return entry?.key?.let { UUID.fromString(it) }
|
return entry?.key?.let { UUID.fromString(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
IconExporter/.gitignore
vendored
19
IconExporter/.gitignore
vendored
|
|
@ -1,19 +0,0 @@
|
||||||
# Gradle
|
|
||||||
.gradle/
|
|
||||||
build/
|
|
||||||
bin/
|
|
||||||
|
|
||||||
# IDE
|
|
||||||
.idea/
|
|
||||||
*.iml
|
|
||||||
*.ipr
|
|
||||||
*.iws
|
|
||||||
.eclipse/
|
|
||||||
.settings/
|
|
||||||
.project
|
|
||||||
.classpath
|
|
||||||
.kotlin/
|
|
||||||
|
|
||||||
# 런타임 생성 파일
|
|
||||||
run/
|
|
||||||
logs/
|
|
||||||
|
|
@ -1,110 +0,0 @@
|
||||||
# 🖼️ IconExporter
|
|
||||||
|
|
||||||
마인크래프트 아이템/블록 아이콘을 PNG 파일로 추출하는 클라이언트 전용 NeoForge 모드입니다.
|
|
||||||
|
|
||||||

|
|
||||||

|
|
||||||

|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✨ 주요 기능
|
|
||||||
|
|
||||||
- 🎨 **고품질 아이콘 추출** - 게임 내 실제 렌더링과 동일한 품질
|
|
||||||
- 📦 **모드 아이템 지원** - 바닐라 + 설치된 모든 모드 아이템 추출
|
|
||||||
- ⚡ **대량 추출** - 틱 기반 큐 시스템으로 게임 프리즈 없이 대량 처리
|
|
||||||
- 🖼️ **투명 배경** - 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`
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,24 +0,0 @@
|
||||||
# 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
|
|
||||||
BIN
IconExporter/gradle/wrapper/gradle-wrapper.jar
vendored
BIN
IconExporter/gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
|
|
@ -1,7 +0,0 @@
|
||||||
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
249
IconExporter/gradlew
vendored
|
|
@ -1,249 +0,0 @@
|
||||||
#!/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
92
IconExporter/gradlew.bat
vendored
|
|
@ -1,92 +0,0 @@
|
||||||
@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
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
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'
|
|
||||||
|
|
@ -1,57 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,274 +0,0 @@
|
||||||
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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,259 +0,0 @@
|
||||||
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}초"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,139 +0,0 @@
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,25 +0,0 @@
|
||||||
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"
|
|
||||||
|
|
@ -15,7 +15,6 @@ 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 추출 (클라이언트) |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -48,7 +47,6 @@ minecraft-mod/
|
||||||
├── Essentials/ # 서버 필수 기능 모드
|
├── Essentials/ # 서버 필수 기능 모드
|
||||||
├── ServerStatus/ # 서버 상태 API 모드
|
├── ServerStatus/ # 서버 상태 API 모드
|
||||||
├── DiscordBot/ # Discord 연동 모드
|
├── DiscordBot/ # Discord 연동 모드
|
||||||
├── IconExporter/ # 아이콘 추출 모드 (클라이언트)
|
|
||||||
└── .gitignore
|
└── .gitignore
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@ 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
|
||||||
|
|
@ -18,8 +15,6 @@ 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로 제공 */
|
||||||
|
|
@ -42,10 +37,6 @@ 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 {
|
||||||
|
|
@ -71,29 +62,6 @@ 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] 서버 설정 중...")
|
||||||
|
|
@ -107,9 +75,6 @@ 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})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,33 +1,35 @@
|
||||||
package co.caadiq.serverstatus.config
|
package co.caadiq.serverstatus.config
|
||||||
|
|
||||||
import co.caadiq.serverstatus.ServerStatusMod
|
import co.caadiq.serverstatus.ServerStatusMod
|
||||||
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.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import net.neoforged.fml.loading.FMLPaths
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
|
||||||
/** 모드 설정 클래스 config/serverstatus/ 폴더에 저장 */
|
/**
|
||||||
|
* 모드 설정 클래스
|
||||||
|
* 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 {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// config/serverstatus/ 폴더에 저장
|
// config/serverstatus/ 폴더에 저장
|
||||||
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()) {
|
||||||
|
|
@ -43,8 +45,10 @@ data class ModConfig(
|
||||||
ModConfig()
|
ModConfig()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 설정 저장 */
|
/**
|
||||||
|
* 설정 저장
|
||||||
|
*/
|
||||||
fun save(config: ModConfig) {
|
fun save(config: ModConfig) {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(configDir)
|
Files.createDirectories(configDir)
|
||||||
|
|
|
||||||
|
|
@ -2,29 +2,36 @@ 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 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.Serializable
|
||||||
import kotlinx.serialization.Transient
|
import kotlinx.serialization.Transient
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import net.neoforged.fml.loading.FMLPaths
|
import net.neoforged.fml.loading.FMLPaths
|
||||||
|
import java.nio.file.Files
|
||||||
|
import kotlin.io.path.exists
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.io.path.writeText
|
||||||
|
|
||||||
/** 플레이어 데이터 저장소 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리 config/serverstatus/ 폴더에 저장 */
|
/**
|
||||||
|
* 플레이어 데이터 저장소
|
||||||
|
* 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리
|
||||||
|
* 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 var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
@Transient
|
||||||
|
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
|
||||||
) {
|
) {
|
||||||
/** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */
|
/**
|
||||||
|
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
|
||||||
|
*/
|
||||||
fun getCurrentSessionMs(): Long {
|
fun getCurrentSessionMs(): Long {
|
||||||
return if (isOnline) {
|
return if (isOnline) {
|
||||||
System.currentTimeMillis() - lastJoin
|
System.currentTimeMillis() - lastJoin
|
||||||
|
|
@ -32,44 +39,39 @@ data class PlayerData(
|
||||||
0L
|
0L
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 */
|
/**
|
||||||
|
* 실시간 총 플레이타임 (ms) - 누적 + 현재 세션
|
||||||
|
*/
|
||||||
fun getRealTimeTotalMs(): Long {
|
fun getRealTimeTotalMs(): Long {
|
||||||
return totalPlayTimeMs + getCurrentSessionMs()
|
return totalPlayTimeMs + getCurrentSessionMs()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutableMapOf()) {
|
data class PlayerDataStore(
|
||||||
|
val players: MutableMap<String, PlayerData> = mutableMapOf()
|
||||||
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
prettyPrint = true
|
prettyPrint = true
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// config/serverstatus/ 폴더에 저장
|
// config/serverstatus/ 폴더에 저장
|
||||||
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID)
|
||||||
private val dataPath = configDir.resolve("players.json")
|
private val dataPath = configDir.resolve("players.json")
|
||||||
|
|
||||||
// Essentials 닉네임 파일 경로
|
// Essentials 닉네임 파일 경로
|
||||||
private val essentialsNicknamePath =
|
private val essentialsNicknamePath = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
|
||||||
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()) {
|
||||||
val content = essentialsNicknamePath.readText()
|
json.decodeFromString<Map<String, String>>(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()
|
||||||
}
|
}
|
||||||
|
|
@ -77,29 +79,10 @@ data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutable
|
||||||
emptyMap()
|
emptyMap()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 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()) {
|
||||||
|
|
@ -113,20 +96,18 @@ data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutable
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 특정 플레이어의 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 우선) */
|
/**
|
||||||
fun getActualName(uuid: String): String {
|
* 모든 플레이어 닉네임 Essentials와 동기화
|
||||||
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
|
||||||
|
|
@ -140,13 +121,13 @@ data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutable
|
||||||
}
|
}
|
||||||
if (synced > 0) {
|
if (synced > 0) {
|
||||||
save()
|
save()
|
||||||
ServerStatusMod.LOGGER.info(
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}명")
|
||||||
"[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}명"
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 데이터 저장 */
|
/**
|
||||||
|
* 데이터 저장
|
||||||
|
*/
|
||||||
fun save() {
|
fun save() {
|
||||||
try {
|
try {
|
||||||
Files.createDirectories(configDir)
|
Files.createDirectories(configDir)
|
||||||
|
|
@ -155,19 +136,24 @@ data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutable
|
||||||
ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}")
|
ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 플레이어 입장 처리 */
|
/**
|
||||||
|
* 플레이어 입장 처리
|
||||||
|
*/
|
||||||
fun onPlayerJoin(uuid: String, name: String) {
|
fun onPlayerJoin(uuid: String, name: String) {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
val player =
|
val player = players.getOrPut(uuid) {
|
||||||
players.getOrPut(uuid) { PlayerData(uuid = uuid, name = name, firstJoin = now) }
|
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 ->
|
||||||
|
|
@ -178,23 +164,29 @@ data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutable
|
||||||
// 통계 저장
|
// 통계 저장
|
||||||
if (stats != null) {
|
if (stats != null) {
|
||||||
player.savedStats = stats
|
player.savedStats = stats
|
||||||
ServerStatusMod.LOGGER.info(
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}")
|
||||||
"[${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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
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)
|
|
||||||
|
|
@ -4,42 +4,46 @@ 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
|
||||||
val uuid = player.stringUUID
|
val uuid = player.stringUUID
|
||||||
val name = player.name.string
|
val name = player.name.string
|
||||||
|
|
||||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)")
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)")
|
||||||
|
|
||||||
// 플레이어 데이터 저장
|
// 플레이어 데이터 저장
|
||||||
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
|
||||||
val uuid = player.stringUUID
|
val uuid = player.stringUUID
|
||||||
val name = player.name.string
|
val name = player.name.string
|
||||||
|
|
||||||
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
|
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)")
|
||||||
|
|
||||||
// 통계 수집 후 저장
|
// 통계 수집 후 저장
|
||||||
val stats =
|
val stats = try {
|
||||||
try {
|
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||||
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
} catch (e: Exception) {
|
||||||
} catch (e: Exception) {
|
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}")
|
||||||
ServerStatusMod.LOGGER.error(
|
null
|
||||||
"[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}"
|
}
|
||||||
)
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
// 플레이어 데이터 및 통계 저장
|
// 플레이어 데이터 및 통계 저장
|
||||||
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)
|
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,6 @@ 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,
|
||||||
|
|
@ -33,40 +29,12 @@ 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 {
|
||||||
|
|
|
||||||
|
|
@ -1,119 +0,0 @@
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
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) {
|
|
||||||
// 로그 처리 중 오류 무시
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
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
|
||||||
|
|
@ -36,12 +34,6 @@ 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(
|
||||||
|
|
@ -61,8 +53,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, POST, OPTIONS")
|
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type, Authorization")
|
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
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())
|
||||||
|
|
@ -73,43 +65,13 @@ 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 = actualName,
|
name = displayName,
|
||||||
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
|
||||||
)
|
)
|
||||||
|
|
@ -244,327 +206,6 @@ 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>)
|
|
||||||
|
|
|
||||||
|
|
@ -2,53 +2,87 @@ 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 tps: Double, // TPS (Ticks Per Second)
|
val players: PlayersInfo,
|
||||||
val mspt: Double, // MSPT (Milliseconds Per Tick)
|
val gameRules: Map<String, Boolean>,
|
||||||
val memoryUsedMb: Long, // 사용 중인 메모리 (MB)
|
val mods: List<ModInfo>
|
||||||
val memoryMaxMb: Long, // 최대 메모리 (MB)
|
|
||||||
val players: PlayersInfo,
|
|
||||||
val gameRules: Map<String, Boolean>,
|
|
||||||
val mods: List<ModInfo>
|
|
||||||
)
|
)
|
||||||
|
|
||||||
/** 플레이어 정보 */
|
/**
|
||||||
|
* 플레이어 정보
|
||||||
|
*/
|
||||||
@Serializable
|
@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
|
@Serializable
|
||||||
data class PlayerDetail(
|
data class PlayerDetail(
|
||||||
val uuid: String,
|
val uuid: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val displayName: String, // Essentials 닉네임 (없으면 name과 동일)
|
val firstJoin: Long,
|
||||||
val firstJoin: Long,
|
val lastJoin: Long,
|
||||||
val lastJoin: Long,
|
val lastLeave: Long,
|
||||||
val lastLeave: Long,
|
val isOnline: Boolean,
|
||||||
val isOnline: Boolean,
|
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
|
||||||
val isOp: Boolean, // OP 여부
|
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
|
||||||
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
|
||||||
|
)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue