初版,1.21.4

This commit is contained in:
BRanulf 2025-04-13 21:40:00 +08:00
parent 6e6a2c9333
commit 8fdc1200b2
8 changed files with 617 additions and 0 deletions

View File

@ -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;
}
}

View File

@ -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<UUID, PlayerTimeData> 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<String, String> getWhitelistedPlayerStats() {
Map<String, String> stats = new LinkedHashMap<>(); // 保持插入顺序
long now = Instant.now().getEpochSecond();
// 获取白名单玩家名称列表
List<String> 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<GameProfile> 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<String, JsonElement> 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<TimeEntry> 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;
}
}
}
}

View File

@ -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<String, String> 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<String, String> 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();
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>[在线时间] 玩家在线时间</title>
<link rel="stylesheet" href="css/style.css">
</head>
<body>
<div class="container">
<h1>KSE玩家在线时间统计</h1>
<div class="controls">
<button id="refresh-btn">刷新数据</button>
<p class="info-note">仅跟踪和显示列入白名单的玩家</p>
</div>
<div class="stats-container">
<table id="stats-table">
<thead>
<tr>
<th>玩家</th>
<th>总计时间</th>
<th>最近 30 天</th>
<th>最近 7 天</th>
</tr>
</thead>
<tbody></tbody>
</table>
</div>
</div>
<script src="js/app.js"></script>
</body>
</html>

View File

@ -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;
}
});

View File

@ -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}"
}
}

View File

@ -0,0 +1,8 @@
{
"required": true,
"package": "com.example.playertime.mixins",
"compatibilityLevel": "JAVA_21",
"injectors": {
"defaultRequire": 1
}
}