feat: 닉네임 동기화 개선 및 로그 수집기 추가

- PlayerDataStore: displayName/actualName 분리 조회 함수 추가
- Essentials 닉네임 동기화 로직 개선
- LogCollector 추가 (실시간 로그 수집)
- HttpApiServer: displayName 반환 추가
This commit is contained in:
Caadiq 2025-12-23 10:07:50 +09:00
parent 19143d969c
commit 054d7b896a
11 changed files with 843 additions and 318 deletions

View file

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

View file

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

View file

@ -7,6 +7,8 @@ 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.minecraft.server.MinecraftServer
import net.neoforged.bus.api.IEventBus import net.neoforged.bus.api.IEventBus
@ -74,6 +76,14 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
fun onServerStarted(event: ServerStartedEvent) { fun onServerStarted(event: ServerStartedEvent) {
minecraftServer = event.server minecraftServer = event.server
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 저장됨") 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}")
}
} }
/** 서버 종료 이벤트 */ /** 서버 종료 이벤트 */
@ -81,6 +91,7 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
fun onServerStopped(event: ServerStoppedEvent) { fun onServerStopped(event: ServerStoppedEvent) {
minecraftServer = null minecraftServer = null
LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨") LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨")
LogCaptureAppender.uninstall()
} }
/** 전용 서버 설정 이벤트 */ /** 전용 서버 설정 이벤트 */
@ -96,6 +107,9 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
httpApiServer = HttpApiServer(config.httpPort) httpApiServer = HttpApiServer(config.httpPort)
httpApiServer.start() httpApiServer.start()
// 로그 캡쳐 Appender 설치
LogCaptureAppender.install()
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})") LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
} }
} }

View file

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

View file

@ -2,36 +2,29 @@ package co.caadiq.serverstatus.config
import co.caadiq.serverstatus.ServerStatusMod import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.PlayerStats import co.caadiq.serverstatus.data.PlayerStats
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
import java.nio.file.Files import java.nio.file.Files
import kotlin.io.path.exists import kotlin.io.path.exists
import kotlin.io.path.readText import kotlin.io.path.readText
import kotlin.io.path.writeText import kotlin.io.path.writeText
import kotlinx.serialization.Serializable
import kotlinx.serialization.Transient
import kotlinx.serialization.json.Json
import net.neoforged.fml.loading.FMLPaths
/** /** 플레이어 데이터 저장소 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리 config/serverstatus/ 폴더에 저장 */
* 플레이어 데이터 저장소
* 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리
* config/serverstatus/ 폴더에 저장
*/
@Serializable @Serializable
data class PlayerData( data class PlayerData(
val uuid: String, val uuid: String,
var name: String, var name: String,
var firstJoin: Long = System.currentTimeMillis(), var firstJoin: Long = System.currentTimeMillis(),
var lastJoin: Long = System.currentTimeMillis(), var lastJoin: Long = System.currentTimeMillis(),
var lastLeave: Long = 0L, var lastLeave: Long = 0L,
var totalPlayTimeMs: Long = 0L, var totalPlayTimeMs: Long = 0L,
// 마지막 로그아웃 시 저장된 통계 // 마지막 로그아웃 시 저장된 통계
var savedStats: PlayerStats? = null, var savedStats: PlayerStats? = null,
@Transient @Transient var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함)
) { ) {
/** /** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */
* 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산
*/
fun getCurrentSessionMs(): Long { fun getCurrentSessionMs(): Long {
return if (isOnline) { return if (isOnline) {
System.currentTimeMillis() - lastJoin System.currentTimeMillis() - lastJoin
@ -39,39 +32,44 @@ data class PlayerData(
0L 0L
} }
} }
/** /** 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 */
* 실시간 플레이타임 (ms) - 누적 + 현재 세션
*/
fun getRealTimeTotalMs(): Long { fun getRealTimeTotalMs(): Long {
return totalPlayTimeMs + getCurrentSessionMs() return totalPlayTimeMs + getCurrentSessionMs()
} }
} }
@Serializable @Serializable
data class PlayerDataStore( data class PlayerDataStore(val players: MutableMap<String, PlayerData> = mutableMapOf()) {
val players: MutableMap<String, PlayerData> = mutableMapOf()
) {
companion object { companion object {
private val json = Json { private val json = Json {
prettyPrint = true prettyPrint = true
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 = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json") private val essentialsNicknamePath =
FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json")
/**
* Essentials 닉네임 조회 /** Essentials 닉네임 조회 - UUID -> nickname 매핑 반환 */
*/
private fun loadEssentialsNicknames(): Map<String, String> { private fun loadEssentialsNicknames(): Map<String, String> {
return try { return try {
if (essentialsNicknamePath.exists()) { if (essentialsNicknamePath.exists()) {
json.decodeFromString<Map<String, String>>(essentialsNicknamePath.readText()) val content = essentialsNicknamePath.readText()
// 먼저 새 형식(NicknameEntry) 시도
try {
@kotlinx.serialization.Serializable
data class NicknameEntry(val originalName: String, val nickname: String)
val entries = json.decodeFromString<Map<String, NicknameEntry>>(content)
entries.mapValues { it.value.nickname }
} catch (e: Exception) {
// 기존 형식 (UUID -> String) 시도
json.decodeFromString<Map<String, String>>(content)
}
} else { } else {
emptyMap() emptyMap()
} }
@ -79,10 +77,29 @@ data class PlayerDataStore(
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()) {
@ -96,18 +113,20 @@ data class PlayerDataStore(
} }
} }
} }
/** /** 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) */
* 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름)
*/
fun getDisplayName(uuid: String): String { fun getDisplayName(uuid: String): String {
val essentialsNicks = loadEssentialsNicknames() val essentialsNicks = loadEssentialsNicknames()
return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown" return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown"
} }
/** /** 특정 플레이어의 실제 마인크래프트 이름 가져오기 (Essentials에서 저장된 originalName 우선) */
* 모든 플레이어 닉네임 Essentials와 동기화 fun getActualName(uuid: String): String {
*/ val essentialsOriginalNames = loadEssentialsOriginalNames()
return essentialsOriginalNames[uuid] ?: players[uuid]?.name ?: "Unknown"
}
/** 모든 플레이어 닉네임 Essentials와 동기화 */
fun syncNicknamesFromEssentials() { fun syncNicknamesFromEssentials() {
val essentialsNicks = loadEssentialsNicknames() val essentialsNicks = loadEssentialsNicknames()
var synced = 0 var synced = 0
@ -121,13 +140,13 @@ data class PlayerDataStore(
} }
if (synced > 0) { if (synced > 0) {
save() save()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}") ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}"
)
} }
} }
/** /** 데이터 저장 */
* 데이터 저장
*/
fun save() { fun save() {
try { try {
Files.createDirectories(configDir) Files.createDirectories(configDir)
@ -136,24 +155,19 @@ data class PlayerDataStore(
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 = players.getOrPut(uuid) { val player =
PlayerData(uuid = uuid, name = name, firstJoin = now) players.getOrPut(uuid) { PlayerData(uuid = uuid, name = name, firstJoin = now) }
}
player.name = name player.name = name
player.lastJoin = now player.lastJoin = now
player.isOnline = true player.isOnline = true
save() save()
} }
/** /** 플레이어 퇴장 처리 (통계 저장 포함) */
* 플레이어 퇴장 처리 (통계 저장 포함)
*/
fun onPlayerLeave(uuid: String, stats: PlayerStats?) { fun onPlayerLeave(uuid: String, stats: PlayerStats?) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
players[uuid]?.let { player -> players[uuid]?.let { player ->
@ -164,29 +178,23 @@ data class PlayerDataStore(
// 통계 저장 // 통계 저장
if (stats != null) { if (stats != null) {
player.savedStats = stats player.savedStats = stats
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}") ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}"
)
} }
save() save()
} }
} }
/** /** 플레이어 정보 조회 */
* 플레이어 정보 조회
*/
fun getPlayer(uuid: String): PlayerData? = players[uuid] fun getPlayer(uuid: String): PlayerData? = players[uuid]
/** /** 전체 플레이어 목록 조회 */
* 전체 플레이어 목록 조회
*/
fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name } fun getAllPlayers(): List<PlayerData> = players.values.toList().sortedBy { it.name }
/** /** 플레이어 온라인 상태 확인 */
* 플레이어 온라인 상태 확인
*/
fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false
/** /** 저장된 통계 조회 */
* 저장된 통계 조회
*/
fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats
} }

View file

@ -0,0 +1,46 @@
package co.caadiq.serverstatus.data
import java.util.concurrent.ConcurrentLinkedDeque
import kotlinx.serialization.Serializable
/** 서버 로그 수집기 최근 로그를 메모리에 저장하고 API로 제공 */
object LogCollector {
// 최대 저장할 로그 수
private const val MAX_LOGS = 500
// 로그 저장소 (thread-safe)
private val logs = ConcurrentLinkedDeque<LogEntry>()
/** 로그 추가 */
fun addLog(level: String, message: String) {
val entry =
LogEntry(
time = java.time.LocalTime.now().toString().substring(0, 8),
level = level,
message = message
)
logs.addLast(entry)
// 최대 개수 초과 시 오래된 로그 제거
while (logs.size > MAX_LOGS) {
logs.pollFirst()
}
}
/** 모든 로그 조회 */
fun getLogs(): List<LogEntry> = logs.toList()
/** 최근 N개 로그 조회 */
fun getRecentLogs(count: Int): List<LogEntry> {
val allLogs = logs.toList()
return if (allLogs.size <= count) allLogs else allLogs.takeLast(count)
}
/** 로그 초기화 */
fun clear() {
logs.clear()
}
}
@Serializable data class LogEntry(val time: String, val level: String, val message: String)

View file

@ -4,46 +4,42 @@ 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 = try { val stats =
ServerStatusMod.playerStatsCollector.collectStats(uuid) try {
} catch (e: Exception) { ServerStatusMod.playerStatsCollector.collectStats(uuid)
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}") } catch (e: Exception) {
null ServerStatusMod.LOGGER.error(
} "[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}"
)
null
}
// 플레이어 데이터 및 통계 저장 // 플레이어 데이터 및 통계 저장
ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats) ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats)
} }

View file

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

View file

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

View file

@ -1,6 +1,8 @@
package co.caadiq.serverstatus.network package co.caadiq.serverstatus.network
import co.caadiq.serverstatus.ServerStatusMod import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.LogCollector
import co.caadiq.serverstatus.data.LogEntry
import co.caadiq.serverstatus.data.WorldInfo import co.caadiq.serverstatus.data.WorldInfo
import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler import com.sun.net.httpserver.HttpHandler
@ -35,6 +37,10 @@ class HttpApiServer(private val port: Int) {
server?.createContext("/player", PlayerHandler()) server?.createContext("/player", PlayerHandler())
server?.createContext("/worlds", WorldsHandler()) server?.createContext("/worlds", WorldsHandler())
server?.createContext("/command", CommandHandler()) server?.createContext("/command", CommandHandler())
server?.createContext("/logs", LogsHandler())
server?.createContext("/logfiles", LogFilesHandler())
server?.createContext("/logfile", LogFileDownloadHandler())
server?.createContext("/banlist", BanlistHandler())
server?.start() server?.start()
ServerStatusMod.LOGGER.info( ServerStatusMod.LOGGER.info(
@ -66,13 +72,43 @@ class HttpApiServer(private val port: Int) {
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail { private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용) // Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid) val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid)
val actualName = ServerStatusMod.playerDataStore.getActualName(player.uuid)
// OP 여부 확인
val isOp: Boolean =
try {
val server =
net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer()
if (server != null) {
val playerList = server.playerList
if (player.isOnline) {
// 온라인 플레이어: 서버에서 직접 확인
val serverPlayer =
playerList.players.find { it.stringUUID == player.uuid }
if (serverPlayer != null) playerList.isOp(serverPlayer.gameProfile)
else false
} else {
// 오프라인 플레이어: GameProfile로 확인
val uuid = java.util.UUID.fromString(player.uuid)
val profile = com.mojang.authlib.GameProfile(uuid, actualName)
playerList.isOp(profile)
}
} else {
false
}
} catch (e: Exception) {
false
}
return PlayerDetail( return PlayerDetail(
uuid = player.uuid, uuid = player.uuid,
name = displayName, name = actualName,
displayName = displayName,
firstJoin = player.firstJoin, firstJoin = player.firstJoin,
lastJoin = player.lastJoin, lastJoin = player.lastJoin,
lastLeave = player.lastLeave, lastLeave = player.lastLeave,
isOnline = player.isOnline, isOnline = player.isOnline,
isOp = isOp,
currentSessionMs = player.getCurrentSessionMs(), currentSessionMs = player.getCurrentSessionMs(),
totalPlayTimeMs = player.totalPlayTimeMs totalPlayTimeMs = player.totalPlayTimeMs
) )
@ -253,9 +289,8 @@ class HttpApiServer(private val port: Int) {
try { try {
val commandSource = server.createCommandSourceStack() val commandSource = server.createCommandSourceStack()
server.commands.performPrefixedCommand(commandSource, cleanCommand) server.commands.performPrefixedCommand(commandSource, cleanCommand)
ServerStatusMod.LOGGER.info("[Admin] 명령어 실행: $cleanCommand")
} catch (e: Exception) { } catch (e: Exception) {
ServerStatusMod.LOGGER.error("[Admin] 명령어 실행 실패: ${e.message}") // 명령어 실행 실패는 무시 (콘솔에 이미 표시됨)
} }
} }
@ -273,8 +308,200 @@ class HttpApiServer(private val port: Int) {
} }
} }
} }
/** 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)
}
}
}
} }
@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 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>)

View file

@ -2,87 +2,49 @@ package co.caadiq.serverstatus.network
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/** /** 서버 상태 */
* 서버 상태
*/
@Serializable @Serializable
data class ServerStatus( data class ServerStatus(
val online: Boolean, val online: Boolean,
val version: String, val version: String,
val modLoader: String, val modLoader: String,
val difficulty: String, val difficulty: String,
val uptimeMinutes: Long, val uptimeMinutes: Long,
val players: PlayersInfo, val players: PlayersInfo,
val gameRules: Map<String, Boolean>, val gameRules: Map<String, Boolean>,
val mods: List<ModInfo> val mods: List<ModInfo>
) )
/** /** 플레이어 정보 */
* 플레이어 정보
*/
@Serializable @Serializable
data class PlayersInfo( data class PlayersInfo(val current: Int, val max: Int, val online: List<OnlinePlayer>)
val current: Int,
val max: Int,
val online: List<OnlinePlayer>
)
/** /** 접속 중인 플레이어 */
* 접속 중인 플레이어 @Serializable data class OnlinePlayer(val name: String, val uuid: String, val isOp: Boolean)
*/
@Serializable
data class OnlinePlayer(
val name: String,
val uuid: String,
val isOp: Boolean
)
/** /** 모드 정보 */
* 모드 정보 @Serializable data class ModInfo(val id: String, val version: String)
*/
@Serializable
data class ModInfo(
val id: String,
val version: String
)
/** /** 플레이어 상세 정보 (전체 플레이어용) */
* 플레이어 상세 정보 (전체 플레이어용)
*/
@Serializable @Serializable
data class PlayerDetail( data class PlayerDetail(
val uuid: String, val uuid: String,
val name: String, val name: String,
val firstJoin: Long, val displayName: String, // Essentials 닉네임 (없으면 name과 동일)
val lastJoin: Long, val firstJoin: Long,
val lastLeave: Long, val lastJoin: Long,
val isOnline: Boolean, val lastLeave: Long,
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만) val isOnline: Boolean,
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값) val isOp: Boolean, // OP 여부
val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만)
val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값)
) )
/** /** 전체 플레이어 목록 응답 */
* 전체 플레이어 목록 응답 @Serializable data class AllPlayersResponse(val players: List<PlayerDetail>)
*/
@Serializable
data class AllPlayersResponse(
val players: List<PlayerDetail>
)
/** /** 플레이어 입장 이벤트 */
* 플레이어 입장 이벤트 @Serializable data class PlayerJoinEvent(val uuid: String, val name: String)
*/
@Serializable
data class PlayerJoinEvent(
val uuid: String,
val name: String
)
/** /** 플레이어 퇴장 이벤트 */
* 플레이어 퇴장 이벤트 @Serializable data class PlayerLeaveEvent(val uuid: String, val name: String)
*/
@Serializable
data class PlayerLeaveEvent(
val uuid: String,
val name: String
)