feat: 서버 상태 모드 기능 개선
- Essentials 닉네임 동기화 기능 추가 - AuthCommand 명령어 추가 - 플레이어 데이터 저장소 개선
This commit is contained in:
parent
f2fb0ad324
commit
67f9cac26f
5 changed files with 337 additions and 96 deletions
|
|
@ -5,13 +5,19 @@ import com.beemer.essentials.nickname.NicknameDataStore
|
||||||
import java.time.ZoneId
|
import java.time.ZoneId
|
||||||
import java.time.ZonedDateTime
|
import java.time.ZonedDateTime
|
||||||
import java.time.format.DateTimeFormatter
|
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.Component
|
||||||
|
import net.minecraft.network.chat.MutableComponent
|
||||||
|
import net.minecraft.network.chat.Style
|
||||||
import net.minecraft.server.level.ServerPlayer
|
import net.minecraft.server.level.ServerPlayer
|
||||||
|
|
||||||
object ChatUtils {
|
object ChatUtils {
|
||||||
private const val SECTION = '\u00a7'
|
private const val SECTION = '\u00a7'
|
||||||
private val legacyPattern = Regex("&([0-9A-FK-ORa-fk-or])")
|
private val legacyPattern = Regex("&([0-9A-FK-ORa-fk-or])")
|
||||||
private val hexPattern = Regex("&#([A-Fa-f0-9]{6})")
|
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 val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")
|
||||||
|
|
||||||
private fun translateChatColors(text: String?): String {
|
private fun translateChatColors(text: String?): String {
|
||||||
|
|
@ -56,6 +62,42 @@ object ChatUtils {
|
||||||
return translateChatColors(withPlaceholders)
|
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) {
|
fun broadcastJoin(player: ServerPlayer, firstTime: Boolean) {
|
||||||
val key = if (firstTime) "first-time-join" else "join"
|
val key = if (firstTime) "first-time-join" else "join"
|
||||||
val template = ChatConfig.getFormat(key) ?: defaultJoinTemplate(firstTime)
|
val template = ChatConfig.getFormat(key) ?: defaultJoinTemplate(firstTime)
|
||||||
|
|
@ -72,7 +114,10 @@ object ChatUtils {
|
||||||
fun broadcastChat(player: ServerPlayer, rawMessage: String) {
|
fun broadcastChat(player: ServerPlayer, rawMessage: String) {
|
||||||
val template = ChatConfig.getFormat("chat") ?: "%name% &7&l: &f%message%"
|
val template = ChatConfig.getFormat("chat") ?: "%name% &7&l: &f%message%"
|
||||||
val formatted = chatFormatting(template, player, rawMessage)
|
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 =
|
private fun defaultJoinTemplate(firstTime: Boolean): String =
|
||||||
|
|
|
||||||
|
|
@ -1,30 +1,30 @@
|
||||||
package co.caadiq.serverstatus
|
package co.caadiq.serverstatus
|
||||||
|
|
||||||
|
import co.caadiq.serverstatus.command.AuthCommand
|
||||||
import co.caadiq.serverstatus.config.ModConfig
|
import co.caadiq.serverstatus.config.ModConfig
|
||||||
import co.caadiq.serverstatus.config.PlayerDataStore
|
import co.caadiq.serverstatus.config.PlayerDataStore
|
||||||
|
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.data.PlayerStatsCollector
|
|
||||||
import co.caadiq.serverstatus.network.HttpApiServer
|
import co.caadiq.serverstatus.network.HttpApiServer
|
||||||
import net.neoforged.bus.api.IEventBus
|
import net.neoforged.bus.api.IEventBus
|
||||||
|
import net.neoforged.bus.api.SubscribeEvent
|
||||||
import net.neoforged.fml.ModContainer
|
import net.neoforged.fml.ModContainer
|
||||||
import net.neoforged.fml.common.Mod
|
import net.neoforged.fml.common.Mod
|
||||||
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
|
import net.neoforged.fml.event.lifecycle.FMLDedicatedServerSetupEvent
|
||||||
import net.neoforged.neoforge.common.NeoForge
|
import net.neoforged.neoforge.common.NeoForge
|
||||||
|
import net.neoforged.neoforge.event.RegisterCommandsEvent
|
||||||
import org.apache.logging.log4j.LogManager
|
import org.apache.logging.log4j.LogManager
|
||||||
|
|
||||||
/**
|
/** 메인 모드 클래스 서버 상태 정보를 HTTP API로 제공 */
|
||||||
* 메인 모드 클래스
|
|
||||||
* 서버 상태 정보를 HTTP API로 제공
|
|
||||||
*/
|
|
||||||
@Mod(ServerStatusMod.MOD_ID)
|
@Mod(ServerStatusMod.MOD_ID)
|
||||||
class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
|
class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MOD_ID = "serverstatus"
|
const val MOD_ID = "serverstatus"
|
||||||
val LOGGER = LogManager.getLogger(MOD_ID)
|
val LOGGER = LogManager.getLogger(MOD_ID)
|
||||||
|
|
||||||
lateinit var config: ModConfig
|
lateinit var config: ModConfig
|
||||||
private set
|
private set
|
||||||
lateinit var playerDataStore: PlayerDataStore
|
lateinit var playerDataStore: PlayerDataStore
|
||||||
|
|
@ -38,38 +38,43 @@ class ServerStatusMod(modBus: IEventBus, container: ModContainer) {
|
||||||
lateinit var httpApiServer: HttpApiServer
|
lateinit var httpApiServer: HttpApiServer
|
||||||
private set
|
private set
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
init {
|
||||||
LOGGER.info("[$MOD_ID] 모드 초기화 중...")
|
LOGGER.info("[$MOD_ID] 모드 초기화 중...")
|
||||||
|
|
||||||
// 설정 초기화
|
// 설정 초기화
|
||||||
config = ModConfig.load()
|
config = ModConfig.load()
|
||||||
playerDataStore = PlayerDataStore.load()
|
playerDataStore = PlayerDataStore.load()
|
||||||
|
|
||||||
// 서버 전용 이벤트 등록
|
// 서버 전용 이벤트 등록
|
||||||
modBus.addListener(::onServerSetup)
|
modBus.addListener(::onServerSetup)
|
||||||
|
|
||||||
// 플레이어 이벤트 리스너 등록
|
// 플레이어 이벤트 및 명령어 리스너 등록
|
||||||
NeoForge.EVENT_BUS.register(PlayerTracker)
|
NeoForge.EVENT_BUS.register(PlayerTracker)
|
||||||
|
NeoForge.EVENT_BUS.register(this)
|
||||||
|
|
||||||
LOGGER.info("[$MOD_ID] 모드 초기화 완료")
|
LOGGER.info("[$MOD_ID] 모드 초기화 완료")
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 명령어 등록 이벤트 */
|
||||||
* 전용 서버 설정 이벤트
|
@SubscribeEvent
|
||||||
*/
|
fun onRegisterCommands(event: RegisterCommandsEvent) {
|
||||||
|
AuthCommand.register(event.dispatcher)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전용 서버 설정 이벤트 */
|
||||||
private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
|
private fun onServerSetup(event: FMLDedicatedServerSetupEvent) {
|
||||||
LOGGER.info("[$MOD_ID] 서버 설정 중...")
|
LOGGER.info("[$MOD_ID] 서버 설정 중...")
|
||||||
|
|
||||||
// 데이터 수집기 초기화
|
// 데이터 수집기 초기화
|
||||||
serverDataCollector = ServerDataCollector()
|
serverDataCollector = ServerDataCollector()
|
||||||
worldDataCollector = WorldDataCollector()
|
worldDataCollector = WorldDataCollector()
|
||||||
playerStatsCollector = PlayerStatsCollector()
|
playerStatsCollector = PlayerStatsCollector()
|
||||||
|
|
||||||
// HTTP API 서버 시작
|
// HTTP API 서버 시작
|
||||||
httpApiServer = HttpApiServer(config.httpPort)
|
httpApiServer = HttpApiServer(config.httpPort)
|
||||||
httpApiServer.start()
|
httpApiServer.start()
|
||||||
|
|
||||||
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
|
LOGGER.info("[$MOD_ID] HTTP API 서버 시작됨 (포트: ${config.httpPort})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -62,6 +62,24 @@ data class PlayerDataStore(
|
||||||
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 닉네임 파일 경로
|
||||||
|
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}명")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 데이터 저장
|
* 데이터 저장
|
||||||
*/
|
*/
|
||||||
|
|
|
||||||
|
|
@ -1,101 +1,90 @@
|
||||||
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.AdvancementsInfo
|
|
||||||
import co.caadiq.serverstatus.data.PlayerStats
|
|
||||||
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
|
||||||
import com.sun.net.httpserver.HttpServer
|
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.net.InetSocketAddress
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.util.concurrent.Executors
|
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) {
|
class HttpApiServer(private val port: Int) {
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
prettyPrint = false
|
prettyPrint = false
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
encodeDefaults = true
|
encodeDefaults = true
|
||||||
}
|
}
|
||||||
|
|
||||||
private var server: HttpServer? = null
|
private var server: HttpServer? = null
|
||||||
|
|
||||||
/**
|
/** 서버 시작 */
|
||||||
* 서버 시작
|
|
||||||
*/
|
|
||||||
fun start() {
|
fun start() {
|
||||||
try {
|
try {
|
||||||
server = HttpServer.create(InetSocketAddress(port), 0)
|
server = HttpServer.create(InetSocketAddress(port), 0)
|
||||||
server?.executor = Executors.newFixedThreadPool(4)
|
server?.executor = Executors.newFixedThreadPool(4)
|
||||||
|
|
||||||
// 엔드포인트 등록
|
// 엔드포인트 등록
|
||||||
server?.createContext("/status", StatusHandler())
|
server?.createContext("/status", StatusHandler())
|
||||||
server?.createContext("/players", PlayersHandler())
|
server?.createContext("/players", PlayersHandler())
|
||||||
server?.createContext("/player", PlayerHandler())
|
server?.createContext("/player", PlayerHandler())
|
||||||
server?.createContext("/worlds", WorldsHandler())
|
server?.createContext("/worlds", WorldsHandler())
|
||||||
|
|
||||||
server?.start()
|
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) {
|
} catch (e: Exception) {
|
||||||
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] HTTP 서버 시작 실패: ${e.message}")
|
ServerStatusMod.LOGGER.error("[${ServerStatusMod.MOD_ID}] HTTP 서버 시작 실패: ${e.message}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** 서버 종료 */
|
||||||
* 서버 종료
|
|
||||||
*/
|
|
||||||
fun stop() {
|
fun stop() {
|
||||||
server?.stop(1)
|
server?.stop(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** CORS 헤더 추가 및 응답 전송 */
|
||||||
* CORS 헤더 추가 및 응답 전송
|
|
||||||
*/
|
|
||||||
private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) {
|
private fun sendJsonResponse(exchange: HttpExchange, response: String, statusCode: Int = 200) {
|
||||||
exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8")
|
exchange.responseHeaders.add("Content-Type", "application/json; charset=utf-8")
|
||||||
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
|
exchange.responseHeaders.add("Access-Control-Allow-Origin", "*")
|
||||||
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS")
|
exchange.responseHeaders.add("Access-Control-Allow-Methods", "GET, OPTIONS")
|
||||||
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type")
|
exchange.responseHeaders.add("Access-Control-Allow-Headers", "Content-Type")
|
||||||
|
|
||||||
val bytes = response.toByteArray(StandardCharsets.UTF_8)
|
val bytes = response.toByteArray(StandardCharsets.UTF_8)
|
||||||
exchange.sendResponseHeaders(statusCode, bytes.size.toLong())
|
exchange.sendResponseHeaders(statusCode, bytes.size.toLong())
|
||||||
exchange.responseBody.use { it.write(bytes) }
|
exchange.responseBody.use { it.write(bytes) }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** PlayerData를 PlayerDetail로 변환 (Essentials 닉네임 우선 적용) */
|
||||||
* PlayerData를 PlayerDetail로 변환
|
|
||||||
*/
|
|
||||||
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
|
private fun toPlayerDetail(player: co.caadiq.serverstatus.config.PlayerData): PlayerDetail {
|
||||||
|
// Essentials에서 닉네임 조회 (없으면 저장된 이름 사용)
|
||||||
|
val displayName = ServerStatusMod.playerDataStore.getDisplayName(player.uuid)
|
||||||
return PlayerDetail(
|
return PlayerDetail(
|
||||||
uuid = player.uuid,
|
uuid = player.uuid,
|
||||||
name = player.name,
|
name = 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,
|
||||||
currentSessionMs = player.getCurrentSessionMs(),
|
currentSessionMs = player.getCurrentSessionMs(),
|
||||||
totalPlayTimeMs = player.totalPlayTimeMs
|
totalPlayTimeMs = player.totalPlayTimeMs
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** GET /status - 서버 상태 */
|
||||||
* GET /status - 서버 상태
|
|
||||||
*/
|
|
||||||
private inner class StatusHandler : HttpHandler {
|
private inner class StatusHandler : HttpHandler {
|
||||||
override fun handle(exchange: HttpExchange) {
|
override fun handle(exchange: HttpExchange) {
|
||||||
if (exchange.requestMethod == "OPTIONS") {
|
if (exchange.requestMethod == "OPTIONS") {
|
||||||
sendJsonResponse(exchange, "", 204)
|
sendJsonResponse(exchange, "", 204)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val status = ServerStatusMod.serverDataCollector.collectStatus()
|
val status = ServerStatusMod.serverDataCollector.collectStatus()
|
||||||
val response = json.encodeToString(status)
|
val response = json.encodeToString(status)
|
||||||
|
|
@ -106,19 +95,18 @@ class HttpApiServer(private val port: Int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** GET /players - 전체 플레이어 목록 */
|
||||||
* GET /players - 전체 플레이어 목록
|
|
||||||
*/
|
|
||||||
private inner class PlayersHandler : HttpHandler {
|
private inner class PlayersHandler : HttpHandler {
|
||||||
override fun handle(exchange: HttpExchange) {
|
override fun handle(exchange: HttpExchange) {
|
||||||
if (exchange.requestMethod == "OPTIONS") {
|
if (exchange.requestMethod == "OPTIONS") {
|
||||||
sendJsonResponse(exchange, "", 204)
|
sendJsonResponse(exchange, "", 204)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val players = ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) }
|
val players =
|
||||||
|
ServerStatusMod.playerDataStore.getAllPlayers().map { toPlayerDetail(it) }
|
||||||
val response = json.encodeToString(AllPlayersResponse(players))
|
val response = json.encodeToString(AllPlayersResponse(players))
|
||||||
sendJsonResponse(exchange, response)
|
sendJsonResponse(exchange, response)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
|
@ -127,11 +115,10 @@ class HttpApiServer(private val port: Int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /player/{uuid} - 특정 플레이어 정보
|
* GET /player/{uuid} - 특정 플레이어 정보 GET /player/{uuid}/stats - 플레이어 통계 GET
|
||||||
* GET /player/{uuid}/stats - 플레이어 통계
|
* /player/{uuid}/advancements - 플레이어 도전과제
|
||||||
* GET /player/{uuid}/advancements - 플레이어 도전과제
|
|
||||||
*/
|
*/
|
||||||
private inner class PlayerHandler : HttpHandler {
|
private inner class PlayerHandler : HttpHandler {
|
||||||
override fun handle(exchange: HttpExchange) {
|
override fun handle(exchange: HttpExchange) {
|
||||||
|
|
@ -139,39 +126,49 @@ class HttpApiServer(private val port: Int) {
|
||||||
sendJsonResponse(exchange, "", 204)
|
sendJsonResponse(exchange, "", 204)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val path = exchange.requestURI.path
|
val path = exchange.requestURI.path
|
||||||
val parts = path.removePrefix("/player/").split("/")
|
val parts = path.removePrefix("/player/").split("/")
|
||||||
val uuid = parts.getOrNull(0)?.takeIf { it.isNotBlank() }
|
val uuid = parts.getOrNull(0)?.takeIf { it.isNotBlank() }
|
||||||
val subPath = parts.getOrNull(1)
|
val subPath = parts.getOrNull(1)
|
||||||
|
|
||||||
if (uuid == null) {
|
if (uuid == null) {
|
||||||
sendJsonResponse(exchange, """{"error": "UUID required"}""", 400)
|
sendJsonResponse(exchange, """{"error": "UUID required"}""", 400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
when (subPath) {
|
when (subPath) {
|
||||||
"stats" -> {
|
"stats" -> {
|
||||||
// 온라인이면 실시간 통계, 오프라인이면 저장된 통계
|
// 온라인이면 실시간 통계, 오프라인이면 저장된 통계
|
||||||
val isOnline = ServerStatusMod.playerDataStore.isPlayerOnline(uuid)
|
val isOnline = ServerStatusMod.playerDataStore.isPlayerOnline(uuid)
|
||||||
val stats = if (isOnline) {
|
val stats =
|
||||||
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
if (isOnline) {
|
||||||
} else {
|
ServerStatusMod.playerStatsCollector.collectStats(uuid)
|
||||||
ServerStatusMod.playerDataStore.getSavedStats(uuid)
|
} else {
|
||||||
}
|
ServerStatusMod.playerDataStore.getSavedStats(uuid)
|
||||||
|
}
|
||||||
if (stats != null) {
|
if (stats != null) {
|
||||||
sendJsonResponse(exchange, json.encodeToString(stats))
|
sendJsonResponse(exchange, json.encodeToString(stats))
|
||||||
} else {
|
} else {
|
||||||
sendJsonResponse(exchange, """{"error": "Player stats not available"}""", 404)
|
sendJsonResponse(
|
||||||
|
exchange,
|
||||||
|
"""{"error": "Player stats not available"}""",
|
||||||
|
404
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"advancements" -> {
|
"advancements" -> {
|
||||||
val advancements = ServerStatusMod.playerStatsCollector.collectAdvancements(uuid)
|
val advancements =
|
||||||
|
ServerStatusMod.playerStatsCollector.collectAdvancements(uuid)
|
||||||
if (advancements != null) {
|
if (advancements != null) {
|
||||||
sendJsonResponse(exchange, json.encodeToString(advancements))
|
sendJsonResponse(exchange, json.encodeToString(advancements))
|
||||||
} else {
|
} else {
|
||||||
sendJsonResponse(exchange, """{"error": "Player not online or not found"}""", 404)
|
sendJsonResponse(
|
||||||
|
exchange,
|
||||||
|
"""{"error": "Player not online or not found"}""",
|
||||||
|
404
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else -> {
|
else -> {
|
||||||
|
|
@ -190,17 +187,15 @@ class HttpApiServer(private val port: Int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** GET /worlds - 월드 정보 */
|
||||||
* GET /worlds - 월드 정보
|
|
||||||
*/
|
|
||||||
private inner class WorldsHandler : HttpHandler {
|
private inner class WorldsHandler : HttpHandler {
|
||||||
override fun handle(exchange: HttpExchange) {
|
override fun handle(exchange: HttpExchange) {
|
||||||
if (exchange.requestMethod == "OPTIONS") {
|
if (exchange.requestMethod == "OPTIONS") {
|
||||||
sendJsonResponse(exchange, "", 204)
|
sendJsonResponse(exchange, "", 204)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val worlds = ServerStatusMod.worldDataCollector.collectWorlds()
|
val worlds = ServerStatusMod.worldDataCollector.collectWorlds()
|
||||||
val response = json.encodeToString(WorldsResponse(worlds))
|
val response = json.encodeToString(WorldsResponse(worlds))
|
||||||
|
|
@ -213,7 +208,4 @@ class HttpApiServer(private val port: Int) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Serializable
|
@Serializable data class WorldsResponse(val worlds: List<WorldInfo>)
|
||||||
data class WorldsResponse(
|
|
||||||
val worlds: List<WorldInfo>
|
|
||||||
)
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue