feat: 서버 상태 모드 기능 개선

- Essentials 닉네임 동기화 기능 추가

- AuthCommand 명령어 추가

- 플레이어 데이터 저장소 개선
This commit is contained in:
Caadiq 2025-12-22 14:57:35 +09:00
parent f2fb0ad324
commit 67f9cac26f
5 changed files with 337 additions and 96 deletions

View file

@ -5,13 +5,19 @@ import com.beemer.essentials.nickname.NicknameDataStore
import java.time.ZoneId
import java.time.ZonedDateTime
import java.time.format.DateTimeFormatter
import net.minecraft.ChatFormatting
import net.minecraft.network.chat.ClickEvent
import net.minecraft.network.chat.Component
import net.minecraft.network.chat.MutableComponent
import net.minecraft.network.chat.Style
import net.minecraft.server.level.ServerPlayer
object ChatUtils {
private const val SECTION = '\u00a7'
private val legacyPattern = Regex("&([0-9A-FK-ORa-fk-or])")
private val hexPattern = Regex("&#([A-Fa-f0-9]{6})")
private val urlPattern =
Regex("(https?://[\\w\\-._~:/?#\\[\\]@!$&'()*+,;=%]+)", RegexOption.IGNORE_CASE)
private val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
private fun translateChatColors(text: String?): String {
@ -56,6 +62,42 @@ object ChatUtils {
return translateChatColors(withPlaceholders)
}
/** 문자열에서 URL을 찾아 클릭 가능한 Component로 변환 */
private fun parseWithUrls(text: String): MutableComponent {
val result: MutableComponent = Component.empty()
var lastEnd = 0
urlPattern.findAll(text).forEach { match ->
// URL 앞의 일반 텍스트 추가
if (match.range.first > lastEnd) {
result.append(Component.literal(text.substring(lastEnd, match.range.first)))
}
// URL 부분 - 클릭 가능하게 + 밑줄 + 파란색
val url = match.value
val urlComponent =
Component.literal(url)
.withStyle(
Style.EMPTY
.withClickEvent(
ClickEvent(ClickEvent.Action.OPEN_URL, url)
)
.withColor(ChatFormatting.AQUA)
.withUnderlined(true)
)
result.append(urlComponent)
lastEnd = match.range.last + 1
}
// 마지막 URL 이후의 텍스트 추가
if (lastEnd < text.length) {
result.append(Component.literal(text.substring(lastEnd)))
}
return result
}
fun broadcastJoin(player: ServerPlayer, firstTime: Boolean) {
val key = if (firstTime) "first-time-join" else "join"
val template = ChatConfig.getFormat(key) ?: defaultJoinTemplate(firstTime)
@ -72,7 +114,10 @@ object ChatUtils {
fun broadcastChat(player: ServerPlayer, rawMessage: String) {
val template = ChatConfig.getFormat("chat") ?: "%name% &7&l: &f%message%"
val formatted = chatFormatting(template, player, rawMessage)
player.server.playerList.broadcastSystemMessage(Component.literal(formatted), false)
// URL을 클릭 가능한 링크로 변환
val component = parseWithUrls(formatted)
player.server.playerList.broadcastSystemMessage(component, false)
}
private fun defaultJoinTemplate(firstTime: Boolean): String =

View file

@ -1,23 +1,23 @@
package co.caadiq.serverstatus
import co.caadiq.serverstatus.command.AuthCommand
import co.caadiq.serverstatus.config.ModConfig
import co.caadiq.serverstatus.config.PlayerDataStore
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.data.PlayerStatsCollector
import co.caadiq.serverstatus.network.HttpApiServer
import net.neoforged.bus.api.IEventBus
import net.neoforged.bus.api.SubscribeEvent
import net.neoforged.fml.ModContainer
import net.neoforged.fml.common.Mod
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
import net.neoforged.neoforge.common.NeoForge
import net.neoforged.neoforge.event.RegisterCommandsEvent
import org.apache.logging.log4j.LogManager
/**
* 메인 모드 클래스
* 서버 상태 정보를 HTTP API로 제공
*/
/** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */
@Mod(ServerStatusMod.MOD_ID)
class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
@ -49,15 +49,20 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
// 서버 전용 이벤트 등록
modBus.addListener(::onServerSetup)
// 플레이어 이벤트 리스너 등록
// 플레이어 이벤트 및 명령어 리스너 등록
NeoForge.EVENT_BUS.register(PlayerTracker)
NeoForge.EVENT_BUS.register(this)
LOGGER.info("[$MOD_ID] 모드 초기화 완료")
}
/**
* 전용 서버 설정 이벤트
*/
/** 명령어 등록 이벤트 */
@SubscribeEvent
fun onRegisterCommands(event: RegisterCommandsEvent) {
AuthCommand.register(event.dispatcher)
}
/** 전용 서버 설정 이벤트 */
private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
LOGGER.info("[$MOD_ID] 서버 설정 중...")

View file

@ -0,0 +1,153 @@
package co.caadiq.serverstatus.command
import co.caadiq.serverstatus.ServerStatusMod
import com.mojang.brigadier.CommandDispatcher
import com.mojang.brigadier.arguments.StringArgumentType
import com.mojang.brigadier.context.CommandContext
import java.net.HttpURLConnection
import java.net.URI
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import net.minecraft.commands.CommandSourceStack
import net.minecraft.commands.Commands
import net.minecraft.network.chat.Component
import net.minecraft.server.level.ServerPlayer
/** /인증 명령어 처리 웹사이트 계정 연동을 위한 명령어 */
object AuthCommand {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
// 웹 서버 연동 API URL (환경 또는 설정에서 가져올 수 있음)
private const val WEB_API_URL = "http://minecraft-status/link/verify"
/** 명령어 등록 */
fun register(dispatcher: CommandDispatcher<CommandSourceStack>) {
// /인증 <토큰>
dispatcher.register(
Commands.literal("인증")
.then(
Commands.argument("token", StringArgumentType.word()).executes {
context ->
executeAuth(context)
}
)
)
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] /인증 명령어 등록됨")
}
/** 인증 명령어 실행 */
private fun executeAuth(context: CommandContext<CommandSourceStack>): Int {
val source = context.source
val player = source.player
// 플레이어만 사용 가능
if (player == null) {
source.sendFailure(Component.literal("§c플레이어만 이 명령어를 사용할 수 있습니다."))
return 0
}
val token = StringArgumentType.getString(context, "token").uppercase()
// 토큰 검증 (6자리 영숫자)
if (!token.matches(Regex("^[A-Z0-9]{6}$"))) {
source.sendFailure(Component.literal("§c유효하지 않은 인증 코드입니다."))
return 0
}
// 비동기로 웹 API 호출
Thread {
try {
val result = verifyWithWebServer(token, player)
// UI 스레드에서 메시지 전송
player.server.execute {
if (result.success) {
player.sendSystemMessage(
Component.literal("§a§l✓ 계정 연동 완료!\n§7웹사이트에서 프로필이 업데이트됩니다.")
)
} else {
player.sendSystemMessage(
Component.literal(
"§c✗ 연동 실패: ${result.error ?: "알 수 없는 오류"}"
)
)
}
}
} catch (e: Exception) {
ServerStatusMod.LOGGER.error(
"[${ServerStatusMod.MOD_ID}] 인증 오류: ${e.message}"
)
player.server.execute {
player.sendSystemMessage(Component.literal("§c✗ 연동 처리 중 오류가 발생했습니다."))
}
}
}
.start()
// 즉시 응답
source.sendSuccess({ Component.literal("§e인증 처리 중...") }, false)
return 1
}
/** 웹 서버에 연동 요청 */
private fun verifyWithWebServer(token: String, player: ServerPlayer): VerifyResponse {
val requestBody =
json.encodeToString(
VerifyRequest(
token = token,
uuid = player.stringUUID,
name = player.name.string
)
)
val url = URI(WEB_API_URL).toURL()
val conn = url.openConnection() as HttpURLConnection
try {
conn.requestMethod = "POST"
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json; charset=utf-8")
conn.connectTimeout = 5000
conn.readTimeout = 5000
// 요청 전송
conn.outputStream.use { os -> os.write(requestBody.toByteArray(Charsets.UTF_8)) }
// 응답 처리
val responseCode = conn.responseCode
val responseBody =
if (responseCode in 200..299) {
conn.inputStream.bufferedReader().use { it.readText() }
} else {
conn.errorStream?.bufferedReader()?.use { it.readText() } ?: ""
}
ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] 인증 응답: $responseCode - $responseBody"
)
return try {
json.decodeFromString<VerifyResponse>(responseBody)
} catch (e: Exception) {
VerifyResponse(success = responseCode in 200..299, error = responseBody)
}
} finally {
conn.disconnect()
}
}
@Serializable data class VerifyRequest(val token: String, val uuid: String, val name: String)
@Serializable
data class VerifyResponse(
val success: Boolean = false,
val message: String? = null,
val error: String? = null
)
}

View file

@ -62,6 +62,24 @@ data class PlayerDataStore(
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 fun loadEssentialsNicknames(): Map<String, String> {
return try {
if (essentialsNicknamePath.exists()) {
json.decodeFromString<Map<String, String>>(essentialsNicknamePath.readText())
} else {
emptyMap()
}
} catch (e: Exception) {
emptyMap()
}
}
/**
* 플레이어 데이터 로드
*/
@ -79,6 +97,34 @@ data class PlayerDataStore(
}
}
/**
* 특정 플레이어의 Essentials 닉네임 가져오기 (없으면 저장된 이름)
*/
fun getDisplayName(uuid: String): String {
val essentialsNicks = loadEssentialsNicknames()
return essentialsNicks[uuid] ?: players[uuid]?.name ?: "Unknown"
}
/**
* 모든 플레이어 닉네임 Essentials와 동기화
*/
fun syncNicknamesFromEssentials() {
val essentialsNicks = loadEssentialsNicknames()
var synced = 0
players.forEach { (uuid, player) ->
essentialsNicks[uuid]?.let { nick ->
if (player.name != nick) {
player.name = nick
synced++
}
}
}
if (synced > 0) {
save()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] Essentials 닉네임 동기화: ${synced}")
}
}
/**
* 데이터 저장
*/

View file

@ -1,23 +1,18 @@
package co.caadiq.serverstatus.network
import co.caadiq.serverstatus.ServerStatusMod
import co.caadiq.serverstatus.data.AdvancementsInfo
import co.caadiq.serverstatus.data.PlayerStats
import co.caadiq.serverstatus.data.WorldInfo
import com.sun.net.httpserver.HttpExchange
import com.sun.net.httpserver.HttpHandler
import com.sun.net.httpserver.HttpServer
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.net.InetSocketAddress
import java.nio.charset.StandardCharsets
import java.util.concurrent.Executors
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* HTTP API 서버
* Java 내장 HttpServer 사용 - 외부 의존성 없음
*/
/** HTTP API 서버 Java 내장 HttpServer 사용 - 외부 의존성 없음 */
class HttpApiServer(private val port: Int) {
private val json = Json {
@ -28,9 +23,7 @@ class HttpApiServer(private val port: Int) {
private var server: HttpServer? = null
/**
* 서버 시작
*/
/** 서버 시작 */
fun start() {
try {
server = HttpServer.create(InetSocketAddress(port), 0)
@ -43,22 +36,20 @@ class HttpApiServer(private val port: Int) {
server?.createContext("/worlds", WorldsHandler())
server?.start()
ServerStatusMod.LOGGER.info("[${ServerStatusMod.MOD_ID}] HTTP API 서버 시작: http://0.0.0.0:$port")
ServerStatusMod.LOGGER.info(
"[${ServerStatusMod.MOD_ID}] HTTP API 서버 시작: http://0.0.0.0:$port"
)
} catch (e: Exception) {
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] HTTP 서버 시작 실패: ${e.message}")
}
}
/**
* 서버 종료
*/
/** 서버 종료 */
fun stop() {
server?.stop(1)
}
/**
* CORS 헤더 추가 응답 전송
*/
/** CORS 헤더 추가 및 응답 전송 */
private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) {
exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8")
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
@ -70,25 +61,23 @@ class HttpApiServer(private val port: Int) {
exchange.responseBody.use { it.write(bytes) }
}
/**
* PlayerData를 PlayerDetail로 변환
*/
/** PlayerData를 PlayerDetail로 변환 (Essentials 닉네임 우선 적용) */
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid)
return PlayerDetail(
uuid = player.uuid,
name = player.name,
firstJoin = player.firstJoin,
lastJoin = player.lastJoin,
lastLeave = player.lastLeave,
isOnline = player.isOnline,
currentSessionMs = player.getCurrentSessionMs(),
totalPlayTimeMs = player.totalPlayTimeMs
uuid = player.uuid,
name = displayName,
firstJoin = player.firstJoin,
lastJoin = player.lastJoin,
lastLeave = player.lastLeave,
isOnline = player.isOnline,
currentSessionMs = player.getCurrentSessionMs(),
totalPlayTimeMs = player.totalPlayTimeMs
)
}
/**
* GET /status - 서버 상태
*/
/** GET /status - 서버 상태 */
private inner class StatusHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
@ -107,9 +96,7 @@ class HttpApiServer(private val port: Int) {
}
}
/**
* GET /players - 전체 플레이어 목록
*/
/** GET /players - 전체 플레이어 목록 */
private inner class PlayersHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
@ -118,7 +105,8 @@ class HttpApiServer(private val port: Int) {
}
try {
val players = ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) }
val players =
ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) }
val response = json.encodeToString(AllPlayersResponse(players))
sendJsonResponse(exchange, response)
} catch (e: Exception) {
@ -129,9 +117,8 @@ class HttpApiServer(private val port: Int) {
}
/**
* GET /player/{uuid} - 특정 플레이어 정보
* GET /player/{uuid}/stats - 플레이어 통계
* GET /player/{uuid}/advancements - 플레이어 도전과제
* GET /player/{uuid} - 특정 플레이어 정보 GET /player/{uuid}/stats - 플레이어 통계 GET
* /player/{uuid}/advancements - 플레이어 도전과제
*/
private inner class PlayerHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
@ -155,23 +142,33 @@ class HttpApiServer(private val port: Int) {
"stats" -> {
// 온라인이면 실시간 통계, 오프라인이면 저장된 통계
val isOnline = ServerStatusMod.playerDataStore.isPlayerOnline(uuid)
val stats = if (isOnline) {
ServerStatusMod.playerStatsCollector.collectStats(uuid)
} else {
ServerStatusMod.playerDataStore.getSavedStats(uuid)
}
val stats =
if (isOnline) {
ServerStatusMod.playerStatsCollector.collectStats(uuid)
} else {
ServerStatusMod.playerDataStore.getSavedStats(uuid)
}
if (stats != null) {
sendJsonResponse(exchange, json.encodeToString(stats))
} else {
sendJsonResponse(exchange, """{"error": "Player stats not available"}""", 404)
sendJsonResponse(
exchange,
"""{"error": "Player stats not available"}""",
404
)
}
}
"advancements" -> {
val advancements = ServerStatusMod.playerStatsCollector.collectAdvancements(uuid)
val advancements =
ServerStatusMod.playerStatsCollector.collectAdvancements(uuid)
if (advancements != null) {
sendJsonResponse(exchange, json.encodeToString(advancements))
} else {
sendJsonResponse(exchange, """{"error": "Player not online or not found"}""", 404)
sendJsonResponse(
exchange,
"""{"error": "Player not online or not found"}""",
404
)
}
}
else -> {
@ -191,9 +188,7 @@ class HttpApiServer(private val port: Int) {
}
}
/**
* GET /worlds - 월드 정보
*/
/** GET /worlds - 월드 정보 */
private inner class WorldsHandler : HttpHandler {
override fun handle(exchange: HttpExchange) {
if (exchange.requestMethod == "OPTIONS") {
@ -213,7 +208,4 @@ class HttpApiServer(private val port: Int) {
}
}
@Serializable
data class WorldsResponse(
val worlds: List<WorldInfo>
)
@Serializable data class WorldsResponse(val worlds: List<WorldInfo>)