diff --git a/src/main/java/com/example/playertime/PlayerTimeMod.java b/src/main/java/com/example/playertime/PlayerTimeMod.java new file mode 100644 index 0000000..e5d5eb7 --- /dev/null +++ b/src/main/java/com/example/playertime/PlayerTimeMod.java @@ -0,0 +1,64 @@ +package com.example.playertime; + +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class PlayerTimeMod implements ModInitializer { + public static final Logger LOGGER = LoggerFactory.getLogger("PlayerTimeTracker"); + private static PlayerTimeTracker timeTracker; + private static WebServer webServer; + + @Override + public void onInitialize() { + try { + LOGGER.info("[在线时间] 初始化玩家在线时长视奸MOD"); + + ServerLifecycleEvents.SERVER_STARTING.register(server -> { + LOGGER.info("[在线时间] 服务器启动 - 初始化跟踪器"); + timeTracker = new PlayerTimeTracker(server); + + try { + webServer = new WebServer(timeTracker, 60048); + webServer.start(); + LOGGER.info("[在线时间] 在线时长Web服务器启动在端口60048"); + } catch (Exception e) { + LOGGER.error("[在线时间] 无法启动 Web 服务器", e); + throw new RuntimeException("[在线时间] Web 服务器启动失败", e); + } + }); + + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> { + if (timeTracker != null) { + timeTracker.onPlayerJoin(handler.player); + } + }); + + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> { + if (timeTracker != null) { + timeTracker.onPlayerLeave(handler.player); + } + }); + + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + LOGGER.info("[在线时间] 服务器停止 - 保存数据"); + if (webServer != null) { + webServer.stop(); + } + if (timeTracker != null) { + timeTracker.saveAll(); + } + }); + + } catch (Exception e) { + LOGGER.error("[在线时间] Mod 初始化失败!", e); + throw new RuntimeException("[在线时间] Mod 初始化失败", e); + } + } + + public static PlayerTimeTracker getTimeTracker() { + return timeTracker; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/playertime/PlayerTimeTracker.java b/src/main/java/com/example/playertime/PlayerTimeTracker.java new file mode 100644 index 0000000..c766fd3 --- /dev/null +++ b/src/main/java/com/example/playertime/PlayerTimeTracker.java @@ -0,0 +1,236 @@ +package com.example.playertime; + +import com.google.gson.*; +import com.mojang.authlib.GameProfile; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; + +public class PlayerTimeTracker { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private final MinecraftServer server; + private final Path dataFile; + private final ExecutorService executor = Executors.newSingleThreadExecutor(); + private final Map playerData = new ConcurrentHashMap<>(); + + public PlayerTimeTracker(MinecraftServer server) { + this.server = server; + this.dataFile = server.getRunDirectory().resolve("player_time_data.json"); + loadData(); + } + + private boolean isWhitelisted(String playerName) { + MinecraftServer server = this.server; + if (server == null) return false; + + return server.getPlayerManager().getWhitelist() + .isAllowed(server.getUserCache().findByName(playerName).orElse(null)); + } + + + public void onPlayerJoin(ServerPlayerEntity player) { + PlayerTimeData data = playerData.computeIfAbsent(player.getUuid(), uuid -> new PlayerTimeData()); + data.lastLogin = Instant.now().getEpochSecond(); + saveAsync(player.getUuid()); + } + + public void onPlayerLeave(ServerPlayerEntity player) { + PlayerTimeData data = playerData.get(player.getUuid()); + if (data != null) { + long now = Instant.now().getEpochSecond(); + long sessionTime = now - data.lastLogin; + data.totalTime += sessionTime; + + // 维护30天滚动窗口 + data.rolling30Days.addPlayTime(now, sessionTime); + data.rolling7Days.addPlayTime(now, sessionTime); + + data.lastLogin = 0; + saveAsync(player.getUuid()); + } + } + + public PlayerTimeStats getPlayerStats(UUID uuid) { + PlayerTimeData data = playerData.get(uuid); + if (data == null) { + return null; + } + + long now = Instant.now().getEpochSecond(); + PlayerTimeStats stats = new PlayerTimeStats(); + stats.totalTime = data.totalTime; + + // 如果玩家在线,添加当前会话时间 + if (data.lastLogin > 0) { + stats.totalTime += (now - data.lastLogin); + } + + stats.last30Days = data.rolling30Days.getTotalTime(now); + stats.last7Days = data.rolling7Days.getTotalTime(now); + + return stats; + } + + public Map getWhitelistedPlayerStats() { + Map stats = new LinkedHashMap<>(); // 保持插入顺序 + long now = Instant.now().getEpochSecond(); + + // 获取白名单玩家名称列表 + List whitelistedNames = List.of(server.getPlayerManager() + .getWhitelist() + .getNames()); + + playerData.forEach((uuid, data) -> { + // 获取玩家名称 + String playerName = getPlayerName(uuid); + + // 只处理白名单玩家 + if (whitelistedNames.contains(playerName)) { + long totalTime = data.totalTime; + if (data.lastLogin > 0) { + totalTime += (now - data.lastLogin); + } + + stats.put(playerName, "总时长: " + formatTime(totalTime) + + " | 30天: " + formatTime(data.rolling30Days.getTotalTime(now)) + + " | 7天: " + formatTime(data.rolling7Days.getTotalTime(now))); + } + }); + + return stats; + } + + private String getPlayerName(UUID uuid) { + // 尝试获取在线玩家 + ServerPlayerEntity player = server.getPlayerManager().getPlayer(uuid); + if (player != null) { + return player.getName().getString(); + } + + // 尝试从用户缓存获取 - 现在正确处理Optional + Optional profile = server.getUserCache().getByUuid(uuid); + if (profile.isPresent()) { + return profile.get().getName(); + } + + return "Unknown"; + } + + private void loadData() { + if (!Files.exists(dataFile)) { + return; + } + + try (Reader reader = Files.newBufferedReader(dataFile)) { + JsonObject root = JsonParser.parseReader(reader).getAsJsonObject(); + for (Map.Entry entry : root.entrySet()) { + UUID uuid = UUID.fromString(entry.getKey()); + playerData.put(uuid, GSON.fromJson(entry.getValue(), PlayerTimeData.class)); + } + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("无法加载玩家在线时间数据", e); + } + } + + public void saveAll() { + JsonObject root = new JsonObject(); + playerData.forEach((uuid, data) -> { + root.add(uuid.toString(), GSON.toJsonTree(data)); + }); + + try (Writer writer = Files.newBufferedWriter(dataFile)) { + GSON.toJson(root, writer); + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("无法保存玩家在线时间数据", e); + } + } + + private void saveAsync(UUID uuid) { + executor.execute(() -> { + JsonObject root; + try { + if (Files.exists(dataFile)) { + try (Reader reader = Files.newBufferedReader(dataFile)) { + root = JsonParser.parseReader(reader).getAsJsonObject(); + } + } else { + root = new JsonObject(); + } + } catch (Exception e) { + root = new JsonObject(); + } + + PlayerTimeData data = playerData.get(uuid); + if (data != null) { + root.add(uuid.toString(), GSON.toJsonTree(data)); + } + + try (Writer writer = Files.newBufferedWriter(dataFile)) { + GSON.toJson(root, writer); + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("无法保存" + uuid + "的在线时间数据", e); + } + }); + } + + public static String formatTime(long seconds) { + long hours = seconds / 3600; + long minutes = (seconds % 3600) / 60; + return String.format("%dh %02dm", hours, minutes); + } + + private static class PlayerTimeData { + long totalTime = 0; + long lastLogin = 0; + RollingTimeWindow rolling30Days = new RollingTimeWindow(30); + RollingTimeWindow rolling7Days = new RollingTimeWindow(7); + } + + public static class PlayerTimeStats { + public long totalTime; + public long last30Days; + public long last7Days; + } + + private static class RollingTimeWindow { + private final int days; + private final List entries = new ArrayList<>(); + + public RollingTimeWindow(int days) { + this.days = days; + } + + public void addPlayTime(long timestamp, long seconds) { + entries.add(new TimeEntry(timestamp, seconds)); + cleanUp(timestamp); + } + + public long getTotalTime(long currentTime) { + cleanUp(currentTime); + return entries.stream().mapToLong(e -> e.seconds).sum(); + } + + private void cleanUp(long currentTime) { + long cutoff = currentTime - (days * 24 * 3600); + entries.removeIf(entry -> entry.timestamp < cutoff); + } + + private static class TimeEntry { + final long timestamp; + final long seconds; + + TimeEntry(long timestamp, long seconds) { + this.timestamp = timestamp; + this.seconds = seconds; + } + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/playertime/WebServer.java b/src/main/java/com/example/playertime/WebServer.java new file mode 100644 index 0000000..309c954 --- /dev/null +++ b/src/main/java/com/example/playertime/WebServer.java @@ -0,0 +1,111 @@ +package com.example.playertime; + +import com.google.gson.Gson; +import com.sun.net.httpserver.HttpServer; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpExchange; + +import java.io.*; +import java.net.InetSocketAddress; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.util.*; +import java.util.concurrent.*; + +public class WebServer { + private final HttpServer server; + private final PlayerTimeTracker timeTracker; + private final ExecutorService executor = Executors.newFixedThreadPool(4); + private static final Map MIME_TYPES = Map.of( + "html", "text/html", + "css", "text/css", + "js", "application/javascript", + "json", "application/json" + ); + + public WebServer(PlayerTimeTracker timeTracker, int port) throws IOException { + this.timeTracker = timeTracker; + this.server = HttpServer.create(new InetSocketAddress(port), 0); + setupContexts(); + } + + private void setupContexts() { + // API 端点 + // 在 WebServer.java 中修改 /api/stats 的处理 + server.createContext("/api/stats", exchange -> { + if (!"GET".equals(exchange.getRequestMethod())) { + sendResponse(exchange, 405, "Method Not Allowed"); + return; + } + + try { + // 改为使用新的白名单统计方法 + Map stats = timeTracker.getWhitelistedPlayerStats(); + String response = new Gson().toJson(stats); + sendResponse(exchange, 200, response.getBytes(), "application/json"); + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("Failed to get stats", e); + sendResponse(exchange, 500, "Internal Server Error"); + } + }); + + // 静态文件服务 + server.createContext("/", exchange -> { + try { + String path = exchange.getRequestURI().getPath(); + if (path.equals("/")) path = "/index.html"; + + // 从资源目录加载文件 + String resourcePath = "assets/playertime/web" + path; + InputStream is = getClass().getClassLoader().getResourceAsStream(resourcePath); + + if (is == null) { + sendResponse(exchange, 404, "Not Found"); + return; + } + + // 确定内容类型 + String extension = path.substring(path.lastIndexOf('.') + 1); + String contentType = MIME_TYPES.getOrDefault(extension, "text/plain"); + + // 读取文件内容 + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + byte[] data = new byte[1024]; + int nRead; + while ((nRead = is.read(data, 0, data.length)) != -1) { + buffer.write(data, 0, nRead); + } + buffer.flush(); + + sendResponse(exchange, 200, buffer.toByteArray(), contentType); + } catch (Exception e) { + PlayerTimeMod.LOGGER.error("Failed to serve resource", e); + sendResponse(exchange, 500, "Internal Server Error"); + } + }); + + server.setExecutor(executor); + } + + private void sendResponse(HttpExchange exchange, int code, String response) throws IOException { + sendResponse(exchange, code, response.getBytes(StandardCharsets.UTF_8), "text/plain"); + } + + private void sendResponse(HttpExchange exchange, int code, byte[] response, String contentType) throws IOException { + exchange.getResponseHeaders().set("Content-Type", contentType); + exchange.sendResponseHeaders(code, response.length); + try (OutputStream os = exchange.getResponseBody()) { + os.write(response); + } + } + + public void start() { + server.start(); + } + + public void stop() { + server.stop(0); + executor.shutdown(); + } +} \ No newline at end of file diff --git a/src/main/resources/assets/playertime/web/css/style.css b/src/main/resources/assets/playertime/web/css/style.css new file mode 100644 index 0000000..c2d0856 --- /dev/null +++ b/src/main/resources/assets/playertime/web/css/style.css @@ -0,0 +1,77 @@ +body { + font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; + margin: 0; + padding: 0; + background-color: #f5f5f5; + color: #333; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +h1 { + color: #2c3e50; + text-align: center; + margin-bottom: 30px; +} + +.controls { + margin-bottom: 20px; + text-align: center; +} + +button { + background-color: #3498db; + color: white; + border: none; + padding: 10px 20px; + border-radius: 4px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.3s; +} + +button:hover { + background-color: #2980b9; +} + +.stats-container { + background-color: white; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1); + overflow: hidden; +} + +table { + width: 100%; + border-collapse: collapse; +} + +th, td { + padding: 12px 15px; + text-align: left; + border-bottom: 1px solid #ddd; +} + +th { + background-color: #3498db; + color: white; +} + +tr:hover { + background-color: #f1f1f1; +} + +tr:nth-child(even) { + background-color: #f9f9f9; +} + +@media (max-width: 768px) { + table { + display: block; + overflow-x: auto; + } +} \ No newline at end of file diff --git a/src/main/resources/assets/playertime/web/index.html b/src/main/resources/assets/playertime/web/index.html new file mode 100644 index 0000000..6e59c39 --- /dev/null +++ b/src/main/resources/assets/playertime/web/index.html @@ -0,0 +1,32 @@ + + + + + + [在线时间] 玩家在线时间 + + + +
+

KSE玩家在线时间统计

+
+ +

仅跟踪和显示列入白名单的玩家

+
+
+ + + + + + + + + + +
玩家总计时间最近 30 天最近 7 天
+
+
+ + + \ No newline at end of file diff --git a/src/main/resources/assets/playertime/web/js/app.js b/src/main/resources/assets/playertime/web/js/app.js new file mode 100644 index 0000000..47ee957 --- /dev/null +++ b/src/main/resources/assets/playertime/web/js/app.js @@ -0,0 +1,65 @@ +document.addEventListener('DOMContentLoaded', function() { + const refreshBtn = document.getElementById('refresh-btn'); + const statsTable = document.getElementById('stats-table').getElementsByTagName('tbody')[0]; + + // 初始加载数据 + loadStats(); + + // 刷新按钮点击事件 + refreshBtn.addEventListener('click', loadStats); + + // 加载统计数据 + function loadStats() { + fetch('/api/stats') + .then(response => response.json()) + .then(data => { + updateTable(data); + }) + .catch(error => { + console.error('Error fetching stats:', error); + alert('Failed to load player stats. Check console for details.'); + }); + } + + // 更新表格数据 + function updateTable(statsData) { + const statsTable = document.getElementById('stats-table').getElementsByTagName('tbody')[0]; + statsTable.innerHTML = ''; + + // 新数据格式是 { "玩家名": "统计信息", ... } + Object.entries(statsData).forEach(([playerName, statString]) => { + const row = statsTable.insertRow(); + + // 玩家名列 + const nameCell = row.insertCell(0); + nameCell.textContent = playerName; + + // 解析统计信息 + const stats = {}; + statString.split(" | ").forEach(part => { + const [label, value] = part.split(": "); + stats[label.trim()] = value; + }); + + // 总时长列 + const totalCell = row.insertCell(1); + totalCell.textContent = stats["总时长"]; + + // 30天列 + const thirtyCell = row.insertCell(2); + thirtyCell.textContent = stats["30天"]; + + // 7天列 + const sevenCell = row.insertCell(3); + sevenCell.textContent = stats["7天"]; + }); + } + + // 辅助函数:将"Xh Ym"格式的时间转换为分钟数 + function parseTime(timeStr) { + const [hPart, mPart] = timeStr.split(' '); + const hours = parseInt(hPart.replace('h', '')); + const minutes = parseInt(mPart.replace('m', '')); + return hours * 60 + minutes; + } +}); \ No newline at end of file diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..e498df0 --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,24 @@ +{ + "schemaVersion": 1, + "id": "playertime", + "version": "${version}", + "name": "玩家在线时长统计Mod", + "description": "", + "authors": [], + "contact": {}, + "license": "MIT", + "environment": "server", + "entrypoints": { + "main": [ + "com.example.playertime.PlayerTimeMod" + ] + }, + "mixins": [ + "playertime.mixins.json" + ], + "depends": { + "fabricloader": ">=${loader_version}", + "fabric": "*", + "minecraft": "${minecraft_version}" + } +} diff --git a/src/main/resources/playertime.mixins.json b/src/main/resources/playertime.mixins.json new file mode 100644 index 0000000..5f09377 --- /dev/null +++ b/src/main/resources/playertime.mixins.json @@ -0,0 +1,8 @@ +{ + "required": true, + "package": "com.example.playertime.mixins", + "compatibilityLevel": "JAVA_21", + "injectors": { + "defaultRequire": 1 + } +} \ No newline at end of file