diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameCommand.kt b/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameCommand.kt index e80d207..7966a2a 100644 --- a/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameCommand.kt +++ b/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameCommand.kt @@ -1,4 +1,3 @@ - package com.beemer.essentials.nickname import com.mojang.brigadier.arguments.StringArgumentType @@ -9,119 +8,153 @@ import net.minecraft.server.level.ServerPlayer import net.neoforged.bus.api.SubscribeEvent import net.neoforged.neoforge.event.RegisterCommandsEvent -/** - * 닉네임 명령어 - * /닉네임 변경 <닉네임> - * /닉네임 초기화 - */ +/** 닉네임 명령어 /닉네임 변경 <닉네임> /닉네임 초기화 */ object NicknameCommand { - @SubscribeEvent - fun onRegisterCommands(event: RegisterCommandsEvent) { - // 한글 명령어 - event.dispatcher.register( - Commands.literal("닉네임") - .then( - Commands.literal("변경") - .then( - Commands.argument("닉네임", StringArgumentType.greedyString()) - .executes { context -> - val player = context.source.entity as? ServerPlayer - ?: return@executes 0 - - val nickname = StringArgumentType.getString(context, "닉네임").trim() - executeSet(player, nickname) + @SubscribeEvent + fun onRegisterCommands(event: RegisterCommandsEvent) { + // 한글 명령어 + event.dispatcher.register( + Commands.literal("닉네임") + .then( + Commands.literal("변경") + .then( + Commands.argument( + "닉네임", + StringArgumentType + .greedyString() + ) + .executes { context -> + val player = + context.source + .entity as? + ServerPlayer + ?: return@executes 0 + + val nickname = + StringArgumentType + .getString( + context, + "닉네임" + ) + .trim() + executeSet(player, nickname) + } + ) + ) + .then( + Commands.literal("초기화").executes { context -> + val player = + context.source.entity as? ServerPlayer + ?: return@executes 0 + + executeReset(player) + } + ) + ) + + // 영어 명령어 + event.dispatcher.register( + Commands.literal("nickname") + .then( + Commands.literal("set") + .then( + Commands.argument( + "name", + StringArgumentType + .greedyString() + ) + .executes { context -> + val player = + context.source + .entity as? + ServerPlayer + ?: return@executes 0 + + val nickname = + StringArgumentType + .getString( + context, + "name" + ) + .trim() + executeSet(player, nickname) + } + ) + ) + .then( + Commands.literal("reset").executes { context -> + val player = + context.source.entity as? ServerPlayer + ?: return@executes 0 + + executeReset(player) + } + ) + ) + } + + private fun executeSet(player: ServerPlayer, nickname: String): Int { + // 유효성 검사: 길이 + if (nickname.length < 2 || nickname.length > 16) { + player.sendSystemMessage( + Component.literal("닉네임은 2~16자 사이여야 합니다.").withStyle { + it.withColor(ChatFormatting.RED) } ) - ) - .then( - Commands.literal("초기화") - .executes { context -> - val player = context.source.entity as? ServerPlayer - ?: return@executes 0 - - 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) + 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( - Commands.literal("reset") - .executes { context -> - val player = context.source.entity as? ServerPlayer - ?: return@executes 0 - - executeReset(player) + + return 1 + } + + private fun executeReset(player: ServerPlayer): Int { + if (!NicknameDataStore.hasNickname(player.uuid)) { + player.sendSystemMessage( + Component.literal("설정된 닉네임이 없습니다.").withStyle { + it.withColor(ChatFormatting.RED) + } + ) + return 0 + } + + NicknameDataStore.removeNickname(player.uuid) + NicknameManager.removeNickname(player) + + player.sendSystemMessage( + Component.literal("닉네임이 초기화되었습니다.").withStyle { + it.withColor(ChatFormatting.GOLD) } ) - ) - } - - private fun executeSet(player: ServerPlayer, nickname: String): Int { - // 유효성 검사: 길이 - if (nickname.length < 2 || nickname.length > 16) { - player.sendSystemMessage( - Component.literal("닉네임은 2~16자 사이여야 합니다.") - .withStyle { it.withColor(ChatFormatting.RED) } - ) - return 0 + + return 1 } - - // 유효성 검사: 중복 - if (NicknameDataStore.isNicknameTaken(nickname, player.uuid)) { - player.sendSystemMessage( - Component.literal("이미 사용 중인 닉네임입니다.") - .withStyle { it.withColor(ChatFormatting.RED) } - ) - return 0 - } - - // 닉네임 저장 및 적용 - NicknameDataStore.setNickname(player.uuid, nickname) - NicknameManager.applyNickname(player, nickname) - - player.sendSystemMessage( - Component.literal("닉네임이 ") - .withStyle { it.withColor(ChatFormatting.GOLD) } - .append(Component.literal(nickname).withStyle { it.withColor(ChatFormatting.AQUA) }) - .append(Component.literal("(으)로 변경되었습니다.").withStyle { it.withColor(ChatFormatting.GOLD) }) - ) - - return 1 - } - - private fun executeReset(player: ServerPlayer): Int { - if (!NicknameDataStore.hasNickname(player.uuid)) { - player.sendSystemMessage( - Component.literal("설정된 닉네임이 없습니다.") - .withStyle { it.withColor(ChatFormatting.RED) } - ) - return 0 - } - - NicknameDataStore.removeNickname(player.uuid) - NicknameManager.removeNickname(player) - - player.sendSystemMessage( - Component.literal("닉네임이 초기화되었습니다.") - .withStyle { it.withColor(ChatFormatting.GOLD) } - ) - - return 1 - } } diff --git a/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameDataStore.kt b/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameDataStore.kt index 755deb2..7372233 100644 --- a/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameDataStore.kt +++ b/Essentials/src/main/kotlin/com/beemer/essentials/nickname/NicknameDataStore.kt @@ -11,6 +11,12 @@ import net.neoforged.fml.loading.FMLPaths import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.Logger +/** 닉네임 데이터 엔트리 - 실제 이름과 닉네임을 함께 저장 */ +data class NicknameEntry( + val originalName: String, // 실제 마인크래프트 이름 + val nickname: String // 설정된 닉네임 +) + /** 닉네임 데이터 저장소 JSON 파일로 닉네임 저장/로드 */ object NicknameDataStore { private const val MOD_ID = "essentials" @@ -22,8 +28,8 @@ object NicknameDataStore { private val gson: Gson = GsonBuilder().setPrettyPrinting().create() - // UUID -> 닉네임 매핑 - private val nicknames: MutableMap = ConcurrentHashMap() + // UUID -> NicknameEntry 매핑 + private val nicknames: MutableMap = ConcurrentHashMap() /** 닉네임 데이터 로드 */ fun load() { @@ -32,11 +38,26 @@ object NicknameDataStore { if (Files.exists(FILE_PATH)) { val json = Files.readString(FILE_PATH) - val type = object : TypeToken>() {}.type - val loaded: Map = gson.fromJson(json, type) ?: emptyMap() - nicknames.clear() - nicknames.putAll(loaded) - LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}개") + + // 먼저 새 형식으로 로드 시도 + try { + val type = object : TypeToken>() {}.type + val loaded: Map = gson.fromJson(json, type) ?: emptyMap() + nicknames.clear() + nicknames.putAll(loaded) + LOGGER.info("[Essentials] 닉네임 데이터 로드 완료: ${nicknames.size}개") + } catch (e: Exception) { + // 기존 형식(UUID -> String)으로 마이그레이션 + val oldType = object : TypeToken>() {}.type + val oldData: Map = gson.fromJson(json, oldType) ?: emptyMap() + nicknames.clear() + oldData.forEach { (uuid, nickname) -> + // 기존 데이터는 실제 이름을 알 수 없으므로 "Unknown"으로 설정 + nicknames[uuid] = NicknameEntry("Unknown", nickname) + } + save() // 새 형식으로 저장 + LOGGER.info("[Essentials] 닉네임 데이터 마이그레이션 완료: ${nicknames.size}개") + } } } catch (e: Exception) { LOGGER.error("[Essentials] 닉네임 데이터 로드 실패", e) @@ -54,9 +75,9 @@ object NicknameDataStore { } } - /** 닉네임 설정 */ - fun setNickname(uuid: UUID, nickname: String) { - nicknames[uuid.toString()] = nickname + /** 닉네임 설정 (실제 이름과 함께) */ + fun setNickname(uuid: UUID, originalName: String, nickname: String) { + nicknames[uuid.toString()] = NicknameEntry(originalName, nickname) save() } @@ -69,6 +90,18 @@ object NicknameDataStore { /** 닉네임 조회 */ @JvmStatic fun getNickname(uuid: UUID): String? { + return nicknames[uuid.toString()]?.nickname + } + + /** 실제 이름 조회 */ + @JvmStatic + fun getOriginalName(uuid: UUID): String? { + return nicknames[uuid.toString()]?.originalName + } + + /** 전체 엔트리 조회 */ + @JvmStatic + fun getEntry(uuid: UUID): NicknameEntry? { return nicknames[uuid.toString()] } @@ -77,7 +110,7 @@ object NicknameDataStore { val target = nickname.trim() if (target.isEmpty()) return false return nicknames.entries.any { - it.value.equals(target, ignoreCase = true) && + it.value.nickname.equals(target, ignoreCase = true) && (excludeUUID == null || it.key != excludeUUID.toString()) } } @@ -91,7 +124,7 @@ object NicknameDataStore { fun getUuidByNickname(nickname: String): UUID? { val target = nickname.trim() if (target.isEmpty()) return null - val entry = nicknames.entries.find { it.value.equals(target, ignoreCase = true) } + val entry = nicknames.entries.find { it.value.nickname.equals(target, ignoreCase = true) } return entry?.key?.let { UUID.fromString(it) } } } diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt index d50c107..d66d911 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/ServerStatusMod.kt @@ -7,6 +7,8 @@ import co.caadiq.serverstatus.data.PlayerStatsCollector import co.caadiq.serverstatus.data.PlayerTracker import co.caadiq.serverstatus.data.ServerDataCollector import co.caadiq.serverstatus.data.WorldDataCollector +import co.caadiq.serverstatus.log.LogCaptureAppender +import co.caadiq.serverstatus.log.LogUploadService import co.caadiq.serverstatus.network.HttpApiServer import net.minecraft.server.MinecraftServer import net.neoforged.bus.api.IEventBus @@ -74,6 +76,14 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) { 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}") + } } /** 서버 종료 이벤트 */ @@ -81,6 +91,7 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) { fun onServerStopped(event: ServerStoppedEvent) { minecraftServer = null LOGGER.info("[$MOD_ID] 마인크래프트 서버 인스턴스 해제됨") + LogCaptureAppender.uninstall() } /** 전용 서버 설정 이벤트 */ @@ -96,6 +107,9 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) { httpApiServer = HttpApiServer(config.httpPort) httpApiServer.start() + // 로그 캡쳐 Appender 설치 + LogCaptureAppender.install() + LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})") } } diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/ModConfig.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/ModConfig.kt index b221118..44ef689 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/ModConfig.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/ModConfig.kt @@ -1,35 +1,33 @@ package co.caadiq.serverstatus.config import co.caadiq.serverstatus.ServerStatusMod -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.Json -import net.neoforged.fml.loading.FMLPaths import java.nio.file.Files +import java.util.UUID import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import net.neoforged.fml.loading.FMLPaths -/** - * 모드 설정 클래스 - * config/serverstatus/ 폴더에 저장 - */ +/** 모드 설정 클래스 config/serverstatus/ 폴더에 저장 */ @Serializable data class ModConfig( - val httpPort: Int = 8080 + val httpPort: Int = 8080, + val serverId: String = UUID.randomUUID().toString().take(8), // 서버 고유 ID (기본값: 랜덤 8자) + val backendUrl: String = "http://minecraft-status:80" // 백엔드 API URL ) { companion object { - private val json = Json { - prettyPrint = true + private val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - + // config/serverstatus/ 폴더에 저장 private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID) private val configPath = configDir.resolve("config.json") - - /** - * 설정 파일 로드 (없으면 기본값으로 생성) - */ + + /** 설정 파일 로드 (없으면 기본값으로 생성) */ fun load(): ModConfig { return try { if (configPath.exists()) { @@ -45,10 +43,8 @@ data class ModConfig( ModConfig() } } - - /** - * 설정 저장 - */ + + /** 설정 저장 */ fun save(config: ModConfig) { try { Files.createDirectories(configDir) diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt index f2732a6..48383e5 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/config/PlayerDataStore.kt @@ -2,36 +2,29 @@ package co.caadiq.serverstatus.config import co.caadiq.serverstatus.ServerStatusMod import co.caadiq.serverstatus.data.PlayerStats -import kotlinx.serialization.Serializable -import kotlinx.serialization.Transient -import kotlinx.serialization.json.Json -import net.neoforged.fml.loading.FMLPaths import java.nio.file.Files import kotlin.io.path.exists import kotlin.io.path.readText import kotlin.io.path.writeText +import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient +import kotlinx.serialization.json.Json +import net.neoforged.fml.loading.FMLPaths -/** - * 플레이어 데이터 저장소 - * 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리 - * config/serverstatus/ 폴더에 저장 - */ +/** 플레이어 데이터 저장소 첫 접속, 마지막 접속, 플레이타임, 저장된 통계 등을 자체 관리 config/serverstatus/ 폴더에 저장 */ @Serializable data class PlayerData( - val uuid: String, - var name: String, - var firstJoin: Long = System.currentTimeMillis(), - var lastJoin: Long = System.currentTimeMillis(), - var lastLeave: Long = 0L, - var totalPlayTimeMs: Long = 0L, - // 마지막 로그아웃 시 저장된 통계 - var savedStats: PlayerStats? = null, - @Transient - var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함) + val uuid: String, + var name: String, + var firstJoin: Long = System.currentTimeMillis(), + var lastJoin: Long = System.currentTimeMillis(), + var lastLeave: Long = 0L, + var totalPlayTimeMs: Long = 0L, + // 마지막 로그아웃 시 저장된 통계 + var savedStats: PlayerStats? = null, + @Transient var isOnline: Boolean = false // 현재 접속 상태 (저장 안 함) ) { - /** - * 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 - */ + /** 현재 세션 플레이타임 (ms) - 접속 중일 때만 계산 */ fun getCurrentSessionMs(): Long { return if (isOnline) { System.currentTimeMillis() - lastJoin @@ -39,39 +32,44 @@ data class PlayerData( 0L } } - - /** - * 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 - */ + + /** 실시간 총 플레이타임 (ms) - 누적 + 현재 세션 */ fun getRealTimeTotalMs(): Long { return totalPlayTimeMs + getCurrentSessionMs() } } @Serializable -data class PlayerDataStore( - val players: MutableMap = mutableMapOf() -) { +data class PlayerDataStore(val players: MutableMap = mutableMapOf()) { companion object { - private val json = Json { - prettyPrint = true + private val json = Json { + prettyPrint = true ignoreUnknownKeys = true } - + // config/serverstatus/ 폴더에 저장 private val configDir = FMLPaths.CONFIGDIR.get().resolve(ServerStatusMod.MOD_ID) private val dataPath = configDir.resolve("players.json") - + // Essentials 닉네임 파일 경로 - private val essentialsNicknamePath = FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json") - - /** - * Essentials 닉네임 조회 - */ + private val essentialsNicknamePath = + FMLPaths.CONFIGDIR.get().resolve("essentials/nicknames.json") + + /** Essentials 닉네임 조회 - UUID -> nickname 매핑 반환 */ private fun loadEssentialsNicknames(): Map { return try { if (essentialsNicknamePath.exists()) { - json.decodeFromString>(essentialsNicknamePath.readText()) + val content = essentialsNicknamePath.readText() + // 먼저 새 형식(NicknameEntry) 시도 + try { + @kotlinx.serialization.Serializable + data class NicknameEntry(val originalName: String, val nickname: String) + val entries = json.decodeFromString>(content) + entries.mapValues { it.value.nickname } + } catch (e: Exception) { + // 기존 형식 (UUID -> String) 시도 + json.decodeFromString>(content) + } } else { emptyMap() } @@ -79,10 +77,29 @@ data class PlayerDataStore( emptyMap() } } - - /** - * 플레이어 데이터 로드 - */ + + /** Essentials 실제 이름 조회 - UUID -> originalName 매핑 반환 */ + private fun loadEssentialsOriginalNames(): Map { + 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>(content) + entries.mapValues { it.value.originalName } + } catch (e: Exception) { + emptyMap() + } + } else { + emptyMap() + } + } catch (e: Exception) { + emptyMap() + } + } + + /** 플레이어 데이터 로드 */ fun load(): PlayerDataStore { return try { if (dataPath.exists()) { @@ -96,18 +113,20 @@ data class PlayerDataStore( } } } - - /** - * 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) - */ + + /** 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름) */ fun getDisplayName(uuid: String): String { val essentialsNicks = loadEssentialsNicknames() return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown" } - - /** - * 모든 플레이어 닉네임 Essentials와 동기화 - */ + + /** 특정 플레이어의 실제 마인크래프트 이름 가져오기 (Essentials에서 저장된 originalName 우선) */ + fun getActualName(uuid: String): String { + val essentialsOriginalNames = loadEssentialsOriginalNames() + return essentialsOriginalNames[uuid] ?: players[uuid]?.name ?: "Unknown" + } + + /** 모든 플레이어 닉네임 Essentials와 동기화 */ fun syncNicknamesFromEssentials() { val essentialsNicks = loadEssentialsNicknames() var synced = 0 @@ -121,13 +140,13 @@ data class PlayerDataStore( } if (synced > 0) { save() - ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}명") + ServerStatusMod.LOGGER.info( + "[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}명" + ) } } - - /** - * 데이터 저장 - */ + + /** 데이터 저장 */ fun save() { try { Files.createDirectories(configDir) @@ -136,24 +155,19 @@ data class PlayerDataStore( ServerStatusMod.LOGGER.error("플레이어 데이터 저장 실패: ${e.message}") } } - - /** - * 플레이어 입장 처리 - */ + + /** 플레이어 입장 처리 */ fun onPlayerJoin(uuid: String, name: String) { val now = System.currentTimeMillis() - val player = players.getOrPut(uuid) { - PlayerData(uuid = uuid, name = name, firstJoin = now) - } + val player = + players.getOrPut(uuid) { PlayerData(uuid = uuid, name = name, firstJoin = now) } player.name = name player.lastJoin = now player.isOnline = true save() } - - /** - * 플레이어 퇴장 처리 (통계 저장 포함) - */ + + /** 플레이어 퇴장 처리 (통계 저장 포함) */ fun onPlayerLeave(uuid: String, stats: PlayerStats?) { val now = System.currentTimeMillis() players[uuid]?.let { player -> @@ -164,29 +178,23 @@ data class PlayerDataStore( // 통계 저장 if (stats != null) { player.savedStats = stats - ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}") + ServerStatusMod.LOGGER.info( + "[${ServerStatusMod.MOD_ID}] 플레이어 통계 저장됨: ${player.name}" + ) } save() } } - - /** - * 플레이어 정보 조회 - */ + + /** 플레이어 정보 조회 */ fun getPlayer(uuid: String): PlayerData? = players[uuid] - - /** - * 전체 플레이어 목록 조회 - */ + + /** 전체 플레이어 목록 조회 */ fun getAllPlayers(): List = players.values.toList().sortedBy { it.name } - - /** - * 플레이어 온라인 상태 확인 - */ + + /** 플레이어 온라인 상태 확인 */ fun isPlayerOnline(uuid: String): Boolean = players[uuid]?.isOnline ?: false - - /** - * 저장된 통계 조회 - */ + + /** 저장된 통계 조회 */ fun getSavedStats(uuid: String): PlayerStats? = players[uuid]?.savedStats } diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/data/LogCollector.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/data/LogCollector.kt new file mode 100644 index 0000000..1625c30 --- /dev/null +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/data/LogCollector.kt @@ -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() + + /** 로그 추가 */ + 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 = logs.toList() + + /** 최근 N개 로그 조회 */ + fun getRecentLogs(count: Int): List { + 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) diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/data/PlayerTracker.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/data/PlayerTracker.kt index f3aabfa..dd0f7e0 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/data/PlayerTracker.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/data/PlayerTracker.kt @@ -4,46 +4,42 @@ import co.caadiq.serverstatus.ServerStatusMod import net.neoforged.bus.api.SubscribeEvent import net.neoforged.neoforge.event.entity.player.PlayerEvent -/** - * 플레이어 이벤트 추적기 - * 입장/퇴장 이벤트를 감지하여 데이터 저장 - */ +/** 플레이어 이벤트 추적기 입장/퇴장 이벤트를 감지하여 데이터 저장 */ object PlayerTracker { - - /** - * 플레이어 입장 이벤트 - */ + + /** 플레이어 입장 이벤트 */ @SubscribeEvent fun onPlayerJoin(event: PlayerEvent.PlayerLoggedInEvent) { val player = event.entity val uuid = player.stringUUID val name = player.name.string - + ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 입장: $name ($uuid)") - + // 플레이어 데이터 저장 ServerStatusMod.playerDataStore.onPlayerJoin(uuid, name) } - - /** - * 플레이어 퇴장 이벤트 - */ + + /** 플레이어 퇴장 이벤트 */ @SubscribeEvent fun onPlayerLeave(event: PlayerEvent.PlayerLoggedOutEvent) { val player = event.entity val uuid = player.stringUUID val name = player.name.string - + ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] 플레이어 퇴장: $name ($uuid)") - + // 통계 수집 후 저장 - val stats = try { - ServerStatusMod.playerStatsCollector.collectStats(uuid) - } catch (e: Exception) { - ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}") - null - } - + val stats = + try { + ServerStatusMod.playerStatsCollector.collectStats(uuid) + } catch (e: Exception) { + ServerStatusMod.LOGGER.error( + "[${ServerStatusMod.MOD_ID}] 통계 수집 실패: ${e.message}" + ) + null + } + // 플레이어 데이터 및 통계 저장 ServerStatusMod.playerDataStore.onPlayerLeave(uuid, stats) } diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/log/LogUploadService.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/log/LogUploadService.kt new file mode 100644 index 0000000..9a9f126 --- /dev/null +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/log/LogUploadService.kt @@ -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() + } +} diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/log/ServerStatusAppender.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/log/ServerStatusAppender.kt new file mode 100644 index 0000000..835c544 --- /dev/null +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/log/ServerStatusAppender.kt @@ -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) { + // 로그 처리 중 오류 무시 + } + } +} diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt index c0f5fea..c28f4cb 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/HttpApiServer.kt @@ -1,6 +1,8 @@ package co.caadiq.serverstatus.network import co.caadiq.serverstatus.ServerStatusMod +import co.caadiq.serverstatus.data.LogCollector +import co.caadiq.serverstatus.data.LogEntry import co.caadiq.serverstatus.data.WorldInfo import com.sun.net.httpserver.HttpExchange import com.sun.net.httpserver.HttpHandler @@ -35,6 +37,10 @@ class HttpApiServer(private val port: Int) { server?.createContext("/player", PlayerHandler()) server?.createContext("/worlds", WorldsHandler()) server?.createContext("/command", CommandHandler()) + server?.createContext("/logs", LogsHandler()) + server?.createContext("/logfiles", LogFilesHandler()) + server?.createContext("/logfile", LogFileDownloadHandler()) + server?.createContext("/banlist", BanlistHandler()) server?.start() ServerStatusMod.LOGGER.info( @@ -66,13 +72,43 @@ class HttpApiServer(private val port: Int) { private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail { // Essentials에서 닉네임 조회 (없으면 저장된 이름 사용) val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid) + val actualName = ServerStatusMod.playerDataStore.getActualName(player.uuid) + + // OP 여부 확인 + val isOp: Boolean = + try { + val server = + net.neoforged.neoforge.server.ServerLifecycleHooks.getCurrentServer() + if (server != null) { + val playerList = server.playerList + if (player.isOnline) { + // 온라인 플레이어: 서버에서 직접 확인 + val serverPlayer = + playerList.players.find { it.stringUUID == player.uuid } + if (serverPlayer != null) playerList.isOp(serverPlayer.gameProfile) + else false + } else { + // 오프라인 플레이어: GameProfile로 확인 + val uuid = java.util.UUID.fromString(player.uuid) + val profile = com.mojang.authlib.GameProfile(uuid, actualName) + playerList.isOp(profile) + } + } else { + false + } + } catch (e: Exception) { + false + } + return PlayerDetail( uuid = player.uuid, - name = displayName, + name = actualName, + displayName = displayName, firstJoin = player.firstJoin, lastJoin = player.lastJoin, lastLeave = player.lastLeave, isOnline = player.isOnline, + isOp = isOp, currentSessionMs = player.getCurrentSessionMs(), totalPlayTimeMs = player.totalPlayTimeMs ) @@ -253,9 +289,8 @@ class HttpApiServer(private val port: Int) { try { val commandSource = server.createCommandSourceStack() server.commands.performPrefixedCommand(commandSource, cleanCommand) - ServerStatusMod.LOGGER.info("[Admin] 명령어 실행: $cleanCommand") } 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>(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) @Serializable data class CommandRequest(val command: String) + +@Serializable data class LogsResponse(val logs: List) + +@Serializable data class LogFileInfo(val name: String, val size: String, val date: String) + +@Serializable data class LogFilesResponse(val files: List) + +@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) diff --git a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/MessageTypes.kt b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/MessageTypes.kt index fe45df4..b0aabd3 100644 --- a/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/MessageTypes.kt +++ b/ServerStatus/src/main/kotlin/co/caadiq/serverstatus/network/MessageTypes.kt @@ -2,87 +2,49 @@ package co.caadiq.serverstatus.network import kotlinx.serialization.Serializable -/** - * 서버 상태 - */ +/** 서버 상태 */ @Serializable data class ServerStatus( - val online: Boolean, - val version: String, - val modLoader: String, - val difficulty: String, - val uptimeMinutes: Long, - val players: PlayersInfo, - val gameRules: Map, - val mods: List + val online: Boolean, + val version: String, + val modLoader: String, + val difficulty: String, + val uptimeMinutes: Long, + val players: PlayersInfo, + val gameRules: Map, + val mods: List ) -/** - * 플레이어 정보 - */ +/** 플레이어 정보 */ @Serializable -data class PlayersInfo( - val current: Int, - val max: Int, - val online: List -) +data class PlayersInfo(val current: Int, val max: Int, val online: List) -/** - * 접속 중인 플레이어 - */ -@Serializable -data class OnlinePlayer( - val name: String, - val uuid: String, - val isOp: Boolean -) +/** 접속 중인 플레이어 */ +@Serializable data class OnlinePlayer(val name: String, val uuid: String, val isOp: Boolean) -/** - * 모드 정보 - */ -@Serializable -data class ModInfo( - val id: String, - val version: String -) +/** 모드 정보 */ +@Serializable data class ModInfo(val id: String, val version: String) -/** - * 플레이어 상세 정보 (전체 플레이어용) - */ +/** 플레이어 상세 정보 (전체 플레이어용) */ @Serializable data class PlayerDetail( - val uuid: String, - val name: String, - val firstJoin: Long, - val lastJoin: Long, - val lastLeave: Long, - val isOnline: Boolean, - val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만) - val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값) + val uuid: String, + val name: String, + val displayName: String, // Essentials 닉네임 (없으면 name과 동일) + val firstJoin: Long, + val lastJoin: Long, + val lastLeave: Long, + val isOnline: Boolean, + val isOp: Boolean, // OP 여부 + val currentSessionMs: Long, // 현재 세션 플레이타임 (접속 중일 때만) + val totalPlayTimeMs: Long // 누적 플레이타임 (저장된 값) ) -/** - * 전체 플레이어 목록 응답 - */ -@Serializable -data class AllPlayersResponse( - val players: List -) +/** 전체 플레이어 목록 응답 */ +@Serializable data class AllPlayersResponse(val players: List) -/** - * 플레이어 입장 이벤트 - */ -@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)