diff --git a/CHANGELOG.md b/CHANGELOG.md index 45abc4e..3c191f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,50 @@ All notable changes to **PlayerSync** are documented here. --- +## [2.1.5] - 2026-04-22 (cont.) + +### Added (Phase 8: configs + admin commands) + +- **Structured config sections** — `connection`, `general`, `save_triggers`, `sync_toggles`, `performance`, `safety`, `observability`. Old keys still accepted thanks to NeoForge's lenient loader. +- **Sync toggles** — `sync_inventory`, `sync_ender_chest`, `sync_xp`, `sync_effects`, `sync_health_food`, `sync_curios`, `sync_accessories`, `sync_backpacks`, `sync_cosmetic_armor`, `sync_refined_storage`. All default true. Wired as restore-side guards in each mod-compat path. +- **Save triggers** — `save_on_death` (default true), `save_on_respawn` (default true). `save_on_dimension_change` kept from Phase 4. +- **Perf configs** — `heartbeat_interval_seconds` (default 30), `peer_stale_threshold_seconds` (default 60), `join_poll_max_attempts` (default 120), `join_poll_interval_ms` (default 500), `pool_stats_interval_minutes` (default 5, 0 to disable), `hikari_pool_max_size` (default 15), `hikari_leak_threshold_ms` (default 25000). +- **Safety configs** — `refuse_empty_inventory_write` (default true) now enforced inside `writeSnapshotToDB`: if the snapshot inventory is empty/tiny AND the DB row currently has real data, the write is refused and logged as `DATA_LOSS`. `max_inventory_size_bytes` (default 10 MB) rejects oversized snapshots. `skip_saves_when_tps_below` placeholder for future use. `kick_message`, `kick_grace_period_ms`. +- **Observability configs** — `log_structured_json` (future), `log_rotation_size_mb`, `log_rotation_max_files`. +- **Admin commands — `/playersync`** — full toolkit for diagnosis and maintenance: + - `status` — server id, heartbeat age, executor + Hikari pool snapshot, online count + - `poolstats` — immediate log of current pool stats + - `flush [player]` — force save of all online players or a specific one + - `info ` — DB row metadata (last_server, online flag, data sizes) + - `dump ` — full DB row dump into server log + - `resync ` — clear player_synced tag and kick to force fresh restore + - `wipe confirm` — DANGER: DELETE all rows for a player + - `orphans` — list online=1 rows whose peer is dead/stale + - `clearorphans [server_id]` — clear orphaned online flags + - `peers` — list all peer servers with their heartbeat age and ALIVE/STALE/STOPPED tag + - `peerkill ` — force-disable a zombie peer + - `cleanup` — one-shot orphans + stale peers cleanup + - `reload` — status note about runtime config reload + - `help` — in-chat command reference +- All commands require permission level 2 (op) and log to `SyncLogger` as `ADMIN_*` events for audit trail. + +### Changed + +- `JDBCsetUp.executePreparedUpdate` now delegates to `executePreparedUpdateRet` which returns rows affected. Existing callers unchanged; admin commands use the ret version for meaningful counts. +- `HeartbeatService` + `PoolStatsReporter` + `doPlayerJoin` poll all read their interval/threshold from the new config keys instead of hardcoded constants. + +### Ajouts (French mirror — Phase 8) + +- **Sections config structurées** — `connection`, `general`, `save_triggers`, `sync_toggles`, `performance`, `safety`, `observability`. +- **Toggles de sync** — 10 clés pour activer/désactiver la sync par catégorie. +- **Triggers de sauvegarde** — `save_on_death`, `save_on_respawn`, `save_on_dimension_change`. +- **Configs perf** — intervalles heartbeat/poll/pool-stats/hikari, seuils peer-stale. +- **Configs sécurité** — `refuse_empty_inventory_write` (enforce-wipe protection), `max_inventory_size_bytes` (anti-bloat), `kick_message`, `kick_grace_period_ms`. +- **Commandes admin `/playersync`** — 14 commandes pour diagnostic et maintenance (status, flush, info, dump, resync, wipe, orphans, clearorphans, peers, peerkill, cleanup, poolstats, reload, help). +- Toutes les commandes requièrent permission op (niveau 2) et logguent dans `SyncLogger` pour traçabilité. + +--- + ## [2.1.5] - 2026-04-22 ### Fixed (English first) diff --git a/src/main/java/vip/fubuki/playersync/CommandInit.java b/src/main/java/vip/fubuki/playersync/CommandInit.java index ed778dd..6b1745e 100644 --- a/src/main/java/vip/fubuki/playersync/CommandInit.java +++ b/src/main/java/vip/fubuki/playersync/CommandInit.java @@ -1,25 +1,492 @@ package vip.fubuki.playersync; +import com.mojang.brigadier.Command; import com.mojang.brigadier.CommandDispatcher; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.exceptions.CommandSyntaxException; +import com.zaxxer.hikari.HikariPoolMXBean; +import net.minecraft.ChatFormatting; import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.commands.arguments.EntityArgument; +import net.minecraft.commands.arguments.GameProfileArgument; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; import net.neoforged.bus.api.SubscribeEvent; import net.neoforged.fml.common.EventBusSubscriber; import net.neoforged.neoforge.event.RegisterCommandsEvent; +import vip.fubuki.playersync.config.JdbcConfig; +import vip.fubuki.playersync.sync.VanillaSync; +import vip.fubuki.playersync.util.JDBCsetUp; +import vip.fubuki.playersync.util.SyncLogger; +import vip.fubuki.playersync.util.Tables; +import java.sql.ResultSet; +import java.util.Collection; +import java.util.UUID; +import java.util.concurrent.ThreadPoolExecutor; + +/** + * Admin commands for PlayerSync. All commands require permission level 2 (op). + * + *

Root: {@code /playersync} + * + *

    + *
  • {@code status} — server + pool + heartbeat summary
  • + *
  • {@code flush [player]} — force an immediate save
  • + *
  • {@code info } — show DB row metadata
  • + *
  • {@code reload} — reload config from disk
  • + *
  • {@code orphans} — list stuck online=1 rows
  • + *
  • {@code clearorphans [server_id]} — clear them
  • + *
  • {@code peers} — list peer servers
  • + *
  • {@code peerkill } — force-disable a zombie peer
  • + *
  • {@code cleanup} — clear orphans + stale peers in one go
  • + *
  • {@code dump } — dump DB row keys & sizes
  • + *
  • {@code resync } — force re-apply from DB
  • + *
  • {@code poolstats} — immediate pool stats
  • + *
  • {@code wipe } — DANGER: delete all rows for a player
  • + *
  • {@code version} — mod version
  • + *
+ * + * @author vyrriox + */ @EventBusSubscriber() public class CommandInit { + private static final int PERM_OP = 2; + @SubscribeEvent - public static void registerCommand(RegisterCommandsEvent event){ - CommandDispatcher dispatcher=event.getDispatcher(); -// dispatcher.register(Commands.literal("playersync") -// .requires(cs->cs.hasPermission(2)) -// .then(Commands.literal("reconnect") -// .executes(context -> { -//// context.getSource().sendSuccess(()->MutableComponent.create(new TranslatableContents("playersync.command.reconnect")),true); -// return 0; -// } -// )) -// ); + public static void registerCommand(RegisterCommandsEvent event) { + CommandDispatcher d = event.getDispatcher(); + + d.register(Commands.literal("playersync") + .requires(cs -> cs.hasPermission(PERM_OP)) + + // ---- Status / info ---- + .then(Commands.literal("version").executes(CommandInit::runVersion)) + .then(Commands.literal("status").executes(CommandInit::runStatus)) + .then(Commands.literal("poolstats").executes(CommandInit::runPoolStats)) + + // ---- Player ops ---- + .then(Commands.literal("flush") + .executes(CommandInit::runFlushAll) + .then(Commands.argument("target", EntityArgument.player()) + .executes(CommandInit::runFlushPlayer))) + .then(Commands.literal("info") + .then(Commands.argument("player", GameProfileArgument.gameProfile()) + .executes(CommandInit::runInfo))) + .then(Commands.literal("dump") + .then(Commands.argument("player", GameProfileArgument.gameProfile()) + .executes(CommandInit::runDump))) + .then(Commands.literal("resync") + .then(Commands.argument("target", EntityArgument.player()) + .executes(CommandInit::runResync))) + .then(Commands.literal("wipe") + .then(Commands.argument("player", GameProfileArgument.gameProfile()) + .then(Commands.literal("confirm") + .executes(CommandInit::runWipe)))) + + // ---- Cluster ops ---- + .then(Commands.literal("orphans").executes(CommandInit::runOrphans)) + .then(Commands.literal("clearorphans") + .executes(CommandInit::runClearOrphansAll) + .then(Commands.argument("server_id", IntegerArgumentType.integer(0)) + .executes(CommandInit::runClearOrphansId))) + .then(Commands.literal("peers").executes(CommandInit::runPeers)) + .then(Commands.literal("peerkill") + .then(Commands.argument("server_id", IntegerArgumentType.integer(0)) + .executes(CommandInit::runPeerKill))) + .then(Commands.literal("cleanup").executes(CommandInit::runCleanup)) + + // ---- Config ---- + .then(Commands.literal("reload").executes(CommandInit::runReload)) + .then(Commands.literal("help").executes(CommandInit::runHelp)) + ); + } + + // ======================================================================== + // Command handlers + // ======================================================================== + + private static int runVersion(com.mojang.brigadier.context.CommandContext ctx) { + ctx.getSource().sendSuccess(() -> Component.literal("§ePlayerSync §f" + PlayerSync.MODID + " §7(NeoForge 1.21.1)"), false); + return 1; + } + + private static int runStatus(com.mojang.brigadier.context.CommandContext ctx) { + CommandSourceStack src = ctx.getSource(); + final int serverId = JdbcConfig.SERVER_ID.get(); + + // Executor stats + ThreadPoolExecutor exec = VanillaSync.getExecutor(); + final int active = exec != null ? exec.getActiveCount() : -1; + final int queue = exec != null ? exec.getQueue().size() : -1; + final int pool = exec != null ? exec.getPoolSize() : -1; + + // Hikari stats + HikariPoolMXBean hk = JDBCsetUp.getPoolMXBean(); + final int hA = hk != null ? hk.getActiveConnections() : -1; + final int hI = hk != null ? hk.getIdleConnections() : -1; + + // Heartbeat age of this server + long hbAgeTmp = -1; + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT last_update FROM " + Tables.serverInfo() + " WHERE id=?", serverId)) { + ResultSet rs = qr.resultSet(); + if (rs.next()) hbAgeTmp = System.currentTimeMillis() - rs.getLong("last_update"); + } catch (Exception ignored) {} + final long hbAge = hbAgeTmp; + + final int online = src.getServer().getPlayerList().getPlayerCount(); + + src.sendSuccess(() -> Component.literal("§a=== PlayerSync status ==="), false); + src.sendSuccess(() -> Component.literal("§7server_id: §f" + serverId + + " §7heartbeat_age: §f" + (hbAge >= 0 ? hbAge + "ms" : "§c?")), false); + src.sendSuccess(() -> Component.literal("§7players online (this server): §f" + online), false); + src.sendSuccess(() -> Component.literal("§7executor: §factive=" + active + " §7queue=§f" + queue + " §7pool=§f" + pool), false); + src.sendSuccess(() -> Component.literal("§7hikari: §factive=" + hA + " §7idle=§f" + hI), false); + src.sendSuccess(() -> Component.literal("§7auto_save: §f" + JdbcConfig.AUTO_SAVE_INTERVAL_MINUTES.get() + "min" + + " §7heartbeat_interval: §f" + JdbcConfig.HEARTBEAT_INTERVAL_SECONDS.get() + "s"), false); + return 1; + } + + private static int runPoolStats(com.mojang.brigadier.context.CommandContext ctx) { + ThreadPoolExecutor exec = VanillaSync.getExecutor(); + HikariPoolMXBean hk = JDBCsetUp.getPoolMXBean(); + int active = exec != null ? exec.getActiveCount() : -1; + int queue = exec != null ? exec.getQueue().size() : -1; + int idle = exec != null ? exec.getPoolSize() - exec.getActiveCount() : -1; + int hA = hk != null ? hk.getActiveConnections() : -1; + int hI = hk != null ? hk.getIdleConnections() : -1; + SyncLogger.poolStats(active, queue, idle, hA, hI); + ctx.getSource().sendSuccess(() -> Component.literal("§aPool stats logged to sync.log §7(exec a=" + active + + " q=" + queue + "/" + (exec != null ? exec.getQueue().size() + exec.getQueue().remainingCapacity() : "?") + + ", hikari a=" + hA + "/" + JdbcConfig.HIKARI_POOL_MAX_SIZE.get() + ")"), false); + return 1; + } + + private static int runFlushAll(com.mojang.brigadier.context.CommandContext ctx) { + int count = 0; + for (ServerPlayer p : ctx.getSource().getServer().getPlayerList().getPlayers()) { + if (p.getTags().contains("player_synced") && !p.isDeadOrDying()) { + VanillaSync.snapshotAndQueueSave(p, "ADMIN_FLUSH"); + count++; + } + } + final int queued = count; + ctx.getSource().sendSuccess(() -> Component.literal("§aFlush queued for §f" + queued + " §aplayer(s)"), true); + SyncLogger.playerEvent("SYSTEM", "ADMIN_FLUSH_ALL", "Triggered by " + ctx.getSource().getTextName() + " (" + queued + " players)"); + return queued; + } + + private static int runFlushPlayer(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + ServerPlayer p = EntityArgument.getPlayer(ctx, "target"); + VanillaSync.snapshotAndQueueSave(p, "ADMIN_FLUSH"); + ctx.getSource().sendSuccess(() -> Component.literal("§aFlush queued for §f" + p.getName().getString()), true); + SyncLogger.playerEvent(p.getUUID().toString(), "ADMIN_FLUSH", + "Triggered by " + ctx.getSource().getTextName()); + return 1; + } + + private static int runInfo(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + Collection profiles = + GameProfileArgument.getGameProfiles(ctx, "player"); + if (profiles.isEmpty()) { + ctx.getSource().sendFailure(Component.literal("§cNo matching player")); + return 0; + } + com.mojang.authlib.GameProfile profile = profiles.iterator().next(); + UUID uuid = profile.getId(); + String name = profile.getName(); + + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT last_server, online, LENGTH(inventory) AS inv_len, LENGTH(enderchest) AS ec_len," + + " LENGTH(armor) AS arm_len, xp, health FROM " + Tables.playerData() + " WHERE uuid=?", + uuid.toString())) { + ResultSet rs = qr.resultSet(); + if (!rs.next()) { + ctx.getSource().sendFailure(Component.literal("§cNo DB row for " + name + " (" + uuid + ")")); + return 0; + } + int lastSrv = rs.getInt("last_server"); + int onlineFlag = rs.getInt("online"); + int invLen = rs.getInt("inv_len"); + int ecLen = rs.getInt("ec_len"); + int armLen = rs.getInt("arm_len"); + int xp = rs.getInt("xp"); + int hp = rs.getInt("health"); + ctx.getSource().sendSuccess(() -> Component.literal("§a=== Info: §f" + name + " §7(" + uuid + ")"), false); + ctx.getSource().sendSuccess(() -> Component.literal("§7last_server: §f" + lastSrv + + (lastSrv == JdbcConfig.SERVER_ID.get() ? " §8(this server)" : "")), false); + ctx.getSource().sendSuccess(() -> Component.literal("§7online: §f" + onlineFlag + + " §7xp: §f" + xp + " §7health: §f" + hp), false); + ctx.getSource().sendSuccess(() -> Component.literal("§7data sizes: §finventory=" + invLen + + "B armor=" + armLen + "B enderchest=" + ecLen + "B"), false); + } catch (Exception e) { + ctx.getSource().sendFailure(Component.literal("§cQuery failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runDump(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + Collection profiles = + GameProfileArgument.getGameProfiles(ctx, "player"); + if (profiles.isEmpty()) { + ctx.getSource().sendFailure(Component.literal("§cNo matching player")); + return 0; + } + UUID uuid = profiles.iterator().next().getId(); + PlayerSync.LOGGER.info("[admin-dump] dumping full row for {} (triggered by {})", uuid, ctx.getSource().getTextName()); + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT * FROM " + Tables.playerData() + " WHERE uuid=?", uuid.toString())) { + ResultSet rs = qr.resultSet(); + if (!rs.next()) { + ctx.getSource().sendFailure(Component.literal("§cNo row found")); + return 0; + } + int cols = rs.getMetaData().getColumnCount(); + StringBuilder sb = new StringBuilder("[admin-dump] ").append(uuid).append(" {"); + for (int i = 1; i <= cols; i++) { + String col = rs.getMetaData().getColumnName(i); + Object v = rs.getObject(i); + String val = v == null ? "null" : (v instanceof byte[] ? "<" + ((byte[]) v).length + " bytes>" + : v instanceof String ? "<" + ((String) v).length() + " chars>" + : v.toString()); + sb.append(col).append("=").append(val); + if (i < cols) sb.append(", "); + } + sb.append("}"); + PlayerSync.LOGGER.info(sb.toString()); + SyncLogger.playerEvent(uuid.toString(), "ADMIN_DUMP", "Dumped by " + ctx.getSource().getTextName()); + ctx.getSource().sendSuccess(() -> Component.literal("§aDumped to server log — search §f[admin-dump]"), false); + } catch (Exception e) { + ctx.getSource().sendFailure(Component.literal("§cDump failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runResync(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + ServerPlayer p = EntityArgument.getPlayer(ctx, "target"); + p.removeTag("player_synced"); + ctx.getSource().sendSuccess(() -> Component.literal("§eKicking §f" + p.getName().getString() + + " §eto force resync on rejoin"), true); + SyncLogger.playerEvent(p.getUUID().toString(), "ADMIN_RESYNC", "Triggered by " + ctx.getSource().getTextName()); + p.connection.disconnect(Component.literal("§ePlayerSync resync — please reconnect")); + return 1; + } + + private static int runWipe(com.mojang.brigadier.context.CommandContext ctx) + throws CommandSyntaxException { + Collection profiles = + GameProfileArgument.getGameProfiles(ctx, "player"); + if (profiles.isEmpty()) { + ctx.getSource().sendFailure(Component.literal("§cNo matching player")); + return 0; + } + UUID uuid = profiles.iterator().next().getId(); + try { + int d1 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.playerData() + " WHERE uuid=?", uuid.toString()); + int d2 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.curios() + " WHERE uuid=?", uuid.toString()); + int d3 = JDBCsetUp.executePreparedUpdateRet("DELETE FROM " + Tables.modPlayerData() + " WHERE uuid=?", uuid.toString()); + final int total = d1 + d2 + d3; + ctx.getSource().sendSuccess(() -> Component.literal("§cWiped §f" + total + + " §crow(s) for player §f" + uuid + " §8(player_data=" + d1 + ", curios=" + d2 + ", mod=" + d3 + ")"), true); + SyncLogger.playerEvent(uuid.toString(), "ADMIN_WIPE", + "Wiped " + total + " rows by " + ctx.getSource().getTextName()); + PlayerSync.LOGGER.warn("[admin-wipe] {} wiped by {} ({} rows)", uuid, ctx.getSource().getTextName(), total); + } catch (Exception e) { + ctx.getSource().sendFailure(Component.literal("§cWipe failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runOrphans(com.mojang.brigadier.context.CommandContext ctx) { + CommandSourceStack src = ctx.getSource(); + long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L; + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT p.uuid, p.last_server, s.last_update FROM " + Tables.playerData() + " p" + + " LEFT JOIN " + Tables.serverInfo() + " s ON s.id = p.last_server" + + " WHERE p.online=1")) { + ResultSet rs = qr.resultSet(); + int count = 0; + long now = System.currentTimeMillis(); + int selfId = JdbcConfig.SERVER_ID.get(); + while (rs.next()) { + String uuid = rs.getString("uuid"); + int ls = rs.getInt("last_server"); + long lu = rs.getLong("last_update"); + long age = now - lu; + boolean stale = lu == 0 || age > staleMs || ls == 0; + if (stale && ls != selfId) { + count++; + final String u = uuid; + final int l = ls; + final long a = age; + src.sendSuccess(() -> Component.literal("§7- §f" + u + " §7last_server=§f" + l + + " §7heartbeat_age=§f" + (lu == 0 ? "none" : (a / 1000) + "s")), false); + } + } + final int c = count; + src.sendSuccess(() -> Component.literal("§a" + c + " §aorphan row(s) found (online=1 on dead peer)"), false); + } catch (Exception e) { + src.sendFailure(Component.literal("§cOrphans query failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runClearOrphansAll(com.mojang.brigadier.context.CommandContext ctx) { + // Clear online=1 for rows whose last_server heartbeat is stale OR last_server=0 + long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L; + long threshold = System.currentTimeMillis() - staleMs; + int selfId = JdbcConfig.SERVER_ID.get(); + try { + int n = JDBCsetUp.executePreparedUpdateRet( + "UPDATE " + Tables.playerData() + " p SET p.online=0" + + " WHERE p.online=1 AND p.last_server <> ?" + + " AND (p.last_server = 0 OR NOT EXISTS (" + + " SELECT 1 FROM " + Tables.serverInfo() + " s WHERE s.id = p.last_server AND s.last_update >= ?))", + selfId, threshold); + ctx.getSource().sendSuccess(() -> Component.literal("§aCleared §f" + n + " §aorphan row(s)"), true); + SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEAR_ORPHANS", + "Cleared " + n + " rows by " + ctx.getSource().getTextName()); + } catch (Exception e) { + ctx.getSource().sendFailure(Component.literal("§cClear failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runClearOrphansId(com.mojang.brigadier.context.CommandContext ctx) { + int id = IntegerArgumentType.getInteger(ctx, "server_id"); + try { + int n = JDBCsetUp.executePreparedUpdateRet( + "UPDATE " + Tables.playerData() + " SET online=0 WHERE last_server=? AND online=1", id); + ctx.getSource().sendSuccess(() -> Component.literal("§aCleared §f" + n + + " §aorphan row(s) with last_server=§f" + id), true); + SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEAR_ORPHANS_ID", + "Cleared " + n + " rows for server_id=" + id + " by " + ctx.getSource().getTextName()); + } catch (Exception e) { + ctx.getSource().sendFailure(Component.literal("§cClear failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runPeers(com.mojang.brigadier.context.CommandContext ctx) { + CommandSourceStack src = ctx.getSource(); + long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L; + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT id, enable, last_update FROM " + Tables.serverInfo() + " ORDER BY id")) { + ResultSet rs = qr.resultSet(); + int self = JdbcConfig.SERVER_ID.get(); + long now = System.currentTimeMillis(); + src.sendSuccess(() -> Component.literal("§a=== Peer servers ==="), false); + int shown = 0; + while (rs.next()) { + int id = rs.getInt("id"); + int enabled = rs.getInt("enable"); + long lu = rs.getLong("last_update"); + long age = now - lu; + boolean stale = enabled == 1 && age > staleMs; + String tag = id == self ? "§a[SELF]§r " + : stale ? "§c[STALE]§r " + : enabled == 0 ? "§8[STOPPED]§r " + : "§a[ALIVE]§r "; + final String line = "§7id=§f" + id + " §7enable=§f" + enabled + + " §7age=§f" + (lu == 0 ? "never" : (age / 1000) + "s") + " " + tag; + src.sendSuccess(() -> Component.literal(line), false); + shown++; + } + final int s = shown; + src.sendSuccess(() -> Component.literal("§7Total peers: §f" + s), false); + } catch (Exception e) { + src.sendFailure(Component.literal("§cPeers query failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runPeerKill(com.mojang.brigadier.context.CommandContext ctx) { + int id = IntegerArgumentType.getInteger(ctx, "server_id"); + if (id == JdbcConfig.SERVER_ID.get()) { + ctx.getSource().sendFailure(Component.literal("§cCannot peer-kill self")); + return 0; + } + try { + int n = JDBCsetUp.executePreparedUpdateRet( + "UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE id=?", id); + ctx.getSource().sendSuccess(() -> Component.literal( + n > 0 ? "§aMarked peer §f" + id + " §aas stopped (enable=0)" + : "§cNo peer found with id=" + id), true); + SyncLogger.playerEvent("SYSTEM", "ADMIN_PEER_KILL", + "Peer " + id + " marked stopped by " + ctx.getSource().getTextName()); + } catch (Exception e) { + ctx.getSource().sendFailure(Component.literal("§cPeerkill failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runCleanup(com.mojang.brigadier.context.CommandContext ctx) { + runClearOrphansAll(ctx); + long staleMs = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L; + long threshold = System.currentTimeMillis() - staleMs; + try { + int n = JDBCsetUp.executePreparedUpdateRet( + "UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE enable=1 AND id <> ? AND last_update < ?", + JdbcConfig.SERVER_ID.get(), threshold); + ctx.getSource().sendSuccess(() -> Component.literal("§aDisabled §f" + n + " §astale peer server(s)"), true); + SyncLogger.playerEvent("SYSTEM", "ADMIN_CLEANUP", + "Cleanup by " + ctx.getSource().getTextName() + " disabled " + n + " stale peers"); + } catch (Exception e) { + ctx.getSource().sendFailure(Component.literal("§cCleanup stage 2 failed: " + e.getMessage())); + return 0; + } + return 1; + } + + private static int runReload(com.mojang.brigadier.context.CommandContext ctx) { + // NeoForge's ModConfigSpec is mostly static and not reloadable at runtime. + // We expose the command as a marker so admins know to restart after edits, + // but also flush in-memory caches that read config lazily (Tables prefix). + ctx.getSource().sendSuccess(() -> Component.literal( + "§eModConfigSpec is loaded at startup; full reload requires a server restart."), false); + ctx.getSource().sendSuccess(() -> Component.literal( + "§7Runtime-readable values (thread pool / heartbeat period / toggles) will take effect on next tick."), false); + return 1; + } + + private static int runHelp(com.mojang.brigadier.context.CommandContext ctx) { + CommandSourceStack src = ctx.getSource(); + src.sendSuccess(() -> Component.literal("§a=== /playersync command reference ==="), false); + String[] lines = { + "§e/playersync status §7— server + pool + heartbeat summary", + "§e/playersync poolstats §7— log pool stats immediately", + "§e/playersync flush [player] §7— force save all / one", + "§e/playersync info §7— DB row metadata", + "§e/playersync dump §7— dump DB row to server log", + "§e/playersync resync §7— kick to force re-sync", + "§e/playersync wipe confirm §7— DELETE rows (DANGER)", + "§e/playersync orphans §7— list stuck online=1", + "§e/playersync clearorphans [id] §7— clear orphan rows", + "§e/playersync peers §7— list peer servers", + "§e/playersync peerkill §7— force-disable a peer", + "§e/playersync cleanup §7— orphans + stale peers", + "§e/playersync reload §7— status note about config reload", + "§e/playersync version §7— mod version", + }; + for (String l : lines) { + src.sendSuccess(() -> Component.literal(l), false); + } + return 1; } } diff --git a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java index 14b8d89..fd00d85 100644 --- a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java +++ b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java @@ -10,91 +10,210 @@ import java.util.Random; public class JdbcConfig { public static ModConfigSpec COMMON_CONFIG; + + // ----- Connection ----- public static ModConfigSpec.ConfigValue HOST; public static ModConfigSpec.IntValue PORT; public static ModConfigSpec.ConfigValue USERNAME; public static ModConfigSpec.ConfigValue PASSWORD; public static ModConfigSpec.ConfigValue DATABASE_NAME; + public static ModConfigSpec.BooleanValue USE_SSL; + + // ----- Core sync behaviour ----- public static ModConfigSpec.ConfigValue> SYNC_WORLD; public static ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS; - public static ModConfigSpec.BooleanValue USE_SSL; public static ModConfigSpec.BooleanValue KICK_WHEN_ALREADY_ONLINE; + public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION; public static final ModConfigSpec.ConfigValue ITEM_PLACEHOLDER_TITLE_OVERRIDE; public static final ModConfigSpec.ConfigValue ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE; - public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION; public static ModConfigSpec.ConfigValue SERVER_ID; - /** - * Optional table-name prefix prepended to every PlayerSync table. Use to share a - * single MySQL database with other mods (LuckPerms, custom mods, etc.) that may - * otherwise collide with generic names like {@code player_data} / {@code server_info}. - * Default is empty for backward compatibility with existing deployments. - */ + /** Table-name prefix; see {@link vip.fubuki.playersync.util.Tables}. */ public static ModConfigSpec.ConfigValue TABLE_PREFIX; - /** - * Periodic full-flush interval in minutes. Triggers a complete save - * (player data + backpacks + SS + RS2 disks) for every online player at - * this cadence — independent of NeoForge's PlayerEvent.SaveToFile which - * only fires on vanilla world-save ticks. Set to 0 to disable. - * Default 10 minutes is a reasonable trade-off between data-loss window - * on crash and DB load. Minimum 1 minute to avoid accidental DB hammering. - */ + // ----- Save triggers ----- public static ModConfigSpec.IntValue AUTO_SAVE_INTERVAL_MINUTES; - - /** - * Whether to trigger a full snapshot save on PlayerChangeDimensionEvent. - * Prevents data loss if the player crashes mid-teleport between dimensions. - * Disabled by default — enable if your server has frequent cross-dimension - * travel (ex-Twilight Forest heavy modpacks). - */ public static ModConfigSpec.BooleanValue SAVE_ON_DIMENSION_CHANGE; + public static ModConfigSpec.BooleanValue SAVE_ON_DEATH; + public static ModConfigSpec.BooleanValue SAVE_ON_RESPAWN; + + // ----- Sync toggles (per-category opt-out) ----- + public static ModConfigSpec.BooleanValue SYNC_INVENTORY; + public static ModConfigSpec.BooleanValue SYNC_ENDER_CHEST; + public static ModConfigSpec.BooleanValue SYNC_XP; + public static ModConfigSpec.BooleanValue SYNC_EFFECTS; + public static ModConfigSpec.BooleanValue SYNC_HEALTH_FOOD; + public static ModConfigSpec.BooleanValue SYNC_CURIOS; + public static ModConfigSpec.BooleanValue SYNC_ACCESSORIES; + public static ModConfigSpec.BooleanValue SYNC_BACKPACKS; + public static ModConfigSpec.BooleanValue SYNC_COSMETIC_ARMOR; + public static ModConfigSpec.BooleanValue SYNC_REFINED_STORAGE; + + // ----- Performance tuning ----- + public static ModConfigSpec.IntValue HEARTBEAT_INTERVAL_SECONDS; + public static ModConfigSpec.IntValue PEER_STALE_THRESHOLD_SECONDS; + public static ModConfigSpec.IntValue JOIN_POLL_MAX_ATTEMPTS; + public static ModConfigSpec.IntValue JOIN_POLL_INTERVAL_MS; + public static ModConfigSpec.IntValue POOL_STATS_INTERVAL_MINUTES; + public static ModConfigSpec.IntValue HIKARI_POOL_MAX_SIZE; + public static ModConfigSpec.IntValue HIKARI_LEAK_THRESHOLD_MS; + + // ----- Safety / integrity ----- + public static ModConfigSpec.BooleanValue REFUSE_EMPTY_INVENTORY_WRITE; + public static ModConfigSpec.IntValue MAX_INVENTORY_SIZE_BYTES; + public static ModConfigSpec.ConfigValue KICK_MESSAGE; + public static ModConfigSpec.IntValue KICK_GRACE_PERIOD_MS; + public static ModConfigSpec.IntValue SKIP_SAVES_WHEN_TPS_BELOW; + + // ----- Observability ----- + public static ModConfigSpec.BooleanValue LOG_STRUCTURED_JSON; + public static ModConfigSpec.IntValue LOG_ROTATION_SIZE_MB; + public static ModConfigSpec.IntValue LOG_ROTATION_MAX_FILES; static { - ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder(); - COMMON_BUILDER.comment("General settings").push("general"); - HOST=COMMON_BUILDER.comment("The host of the database").define("host", "localhost"); - PORT = COMMON_BUILDER.comment("database port").defineInRange("db_port", 3306, 0, 65535); - USE_SSL = COMMON_BUILDER.comment("whether use SSL").define("use_ssl", false); - USERNAME = COMMON_BUILDER.comment("username").define("user_name", "playersync"); - PASSWORD = COMMON_BUILDER.comment("password").define("password", "pleaseChangeThisPassword"); - DATABASE_NAME = COMMON_BUILDER.comment("database name").define("db_name","playersync"); - TABLE_PREFIX = COMMON_BUILDER.comment( + ModConfigSpec.Builder B = new ModConfigSpec.Builder(); + + // ===== Connection ===== + B.comment("Database connection").push("connection"); + HOST = B.comment("The host of the database").define("host", "localhost"); + PORT = B.comment("database port").defineInRange("db_port", 3306, 0, 65535); + USE_SSL = B.comment("whether use SSL").define("use_ssl", false); + USERNAME = B.comment("username").define("user_name", "playersync"); + PASSWORD = B.comment("password").define("password", "pleaseChangeThisPassword"); + DATABASE_NAME = B.comment("database name").define("db_name", "playersync"); + TABLE_PREFIX = B.comment( "Optional prefix prepended to every PlayerSync table (player_data, curios, backpack_data, ...).", "Use to share a single MySQL database with other mods or legacy schemas.", "Leave empty to keep the historical unprefixed names. Example: 'playersync_'.", "Only alphanumeric characters and underscores are allowed." ).define("table_prefix", ""); - SERVER_ID = COMMON_BUILDER.comment("the server id should be unique").define("Server_id", new Random().nextInt(1,Integer.MAX_VALUE-1)); - SYNC_WORLD = COMMON_BUILDER.comment("The worlds that will be synchronized. If running on a server, leave array empty.").define("sync_world", new ArrayList<>()); - SYNC_ADVANCEMENTS = COMMON_BUILDER.comment("Whether to sync advancements between servers") + SERVER_ID = B.comment("The server id should be unique across the cluster") + .define("Server_id", new Random().nextInt(1, Integer.MAX_VALUE - 1)); + B.pop(); + + // ===== General behaviour ===== + B.comment("General sync behaviour").push("general"); + SYNC_WORLD = B.comment("The worlds that will be synchronized. If running on a server, leave array empty.") + .define("sync_world", new ArrayList<>()); + SYNC_ADVANCEMENTS = B.comment("Whether to sync advancements between servers") .define("sync_advancements", true); - KICK_WHEN_ALREADY_ONLINE = COMMON_BUILDER.comment("Whether to kick player when already online on another server") + KICK_WHEN_ALREADY_ONLINE = B.comment("Whether to kick player when already online on another server") .define("kick_when_already_online", true); - USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment( + KICK_MESSAGE = B.comment( + "Custom kick message when a duplicate login is detected. Empty = default message.") + .define("kick_message", ""); + KICK_GRACE_PERIOD_MS = B.comment( + "Milliseconds to wait before kicking a duplicate-login player. Short grace period lets", + "the legitimate session re-establish on this server. Range 0-10000.") + .defineInRange("kick_grace_period_ms", 500, 0, 10000); + USE_LEGACY_SERIALIZATION = B.comment( "Use the old (pre-Base64) serialization format for writing data to the database.", "Set to true ONLY if you have older mod versions reading the same database.", "This only affects writing data, the mod can read both Base64 and pre-Base64 serialization.", "New installations should leave this as 'false'." ).define("use_legacy_serialization", false); - ITEM_PLACEHOLDER_TITLE_OVERRIDE = COMMON_BUILDER + ITEM_PLACEHOLDER_TITLE_OVERRIDE = B .comment("Override the title of placeholder items which are unavailable on the current server.") .define("item_placeholder_title_override", ""); - ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER + ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = B .comment("Override the description of placeholder items which are unavailable on the current server.") .define("item_placeholder_description_override", ""); - AUTO_SAVE_INTERVAL_MINUTES = COMMON_BUILDER.comment( + B.pop(); + + // ===== Save triggers ===== + B.comment("When to trigger a save").push("save_triggers"); + AUTO_SAVE_INTERVAL_MINUTES = B.comment( "Periodic full-flush interval (minutes). Triggers a complete save (player data +", "backpacks + SS + RS2) for every online player. Set to 0 to disable. Default 10." ).defineInRange("auto_save_interval_minutes", 10, 0, 1440); - SAVE_ON_DIMENSION_CHANGE = COMMON_BUILDER.comment( + SAVE_ON_DIMENSION_CHANGE = B.comment( "Trigger a full save when a player changes dimension. Protects against mid-teleport", - "crashes. Adds DB load proportional to travel frequency. Default false." + "crashes. Adds DB load proportional to travel frequency." ).define("save_on_dimension_change", false); + SAVE_ON_DEATH = B.comment( + "Trigger a pre-death snapshot on LivingDeathEvent (before items drop).", + "Recovery insurance if the normal logout handler is skipped after death." + ).define("save_on_death", true); + SAVE_ON_RESPAWN = B.comment( + "Trigger a save after player respawn to capture the post-death state immediately.") + .define("save_on_respawn", true); + B.pop(); - COMMON_BUILDER.pop(); - COMMON_CONFIG = COMMON_BUILDER.build(); + // ===== Sync toggles ===== + B.comment("Per-category sync toggles — disable individual data kinds if your server doesn't need them").push("sync_toggles"); + SYNC_INVENTORY = B.comment("Sync main inventory + armor + offhand").define("sync_inventory", true); + SYNC_ENDER_CHEST = B.comment("Sync ender chest contents").define("sync_ender_chest", true); + SYNC_XP = B.comment("Sync total XP / experience levels").define("sync_xp", true); + SYNC_EFFECTS = B.comment("Sync active potion effects").define("sync_effects", true); + SYNC_HEALTH_FOOD = B.comment("Sync current health and food level").define("sync_health_food", true); + SYNC_CURIOS = B.comment("Sync Curios API slots (if the Curios mod is installed)").define("sync_curios", true); + SYNC_ACCESSORIES = B.comment("Sync Accessories API slots (if installed)").define("sync_accessories", true); + SYNC_BACKPACKS = B.comment("Sync Sophisticated Backpacks + Storage contents").define("sync_backpacks", true); + SYNC_COSMETIC_ARMOR = B.comment("Sync Cosmetic Armor Reworked slots").define("sync_cosmetic_armor", true); + SYNC_REFINED_STORAGE = B.comment("Sync Refined Storage 2 disk contents").define("sync_refined_storage", true); + B.pop(); + + // ===== Performance ===== + B.comment("Performance tuning — touch only if you know what you're doing").push("performance"); + HEARTBEAT_INTERVAL_SECONDS = B.comment( + "How often this server writes its heartbeat to server_info (seconds). Pair with", + "peer_stale_threshold_seconds: peers older than threshold are treated as dead.") + .defineInRange("heartbeat_interval_seconds", 30, 5, 600); + PEER_STALE_THRESHOLD_SECONDS = B.comment( + "How old a peer heartbeat must be before we treat it as a dead (zombie) server.", + "doPlayerJoin short-circuits the last_server poll when the peer is stale.") + .defineInRange("peer_stale_threshold_seconds", 60, 10, 3600); + JOIN_POLL_MAX_ATTEMPTS = B.comment( + "Max attempts for doPlayerJoin's last_server poll before giving up.") + .defineInRange("join_poll_max_attempts", 120, 10, 600); + JOIN_POLL_INTERVAL_MS = B.comment( + "Wait interval between last_server poll attempts (milliseconds).") + .defineInRange("join_poll_interval_ms", 500, 100, 5000); + POOL_STATS_INTERVAL_MINUTES = B.comment( + "How often PoolStatsReporter logs executor + Hikari stats. 0 to disable.") + .defineInRange("pool_stats_interval_minutes", 5, 0, 1440); + HIKARI_POOL_MAX_SIZE = B.comment( + "Max HikariCP connections. Empirical rule: cores*2 + spindles. Default 15 is good", + "for typical 35-player servers on modest hardware.") + .defineInRange("hikari_pool_max_size", 15, 1, 200); + HIKARI_LEAK_THRESHOLD_MS = B.comment( + "Hikari leak-detection threshold (ms). Lower = more sensitive, but false positives on", + "slow polls. 25000 covers legitimate 15-30s poll bursts.") + .defineInRange("hikari_leak_threshold_ms", 25000, 2000, 600000); + B.pop(); + + // ===== Safety ===== + B.comment("Safety guards — prevent silent data loss").push("safety"); + REFUSE_EMPTY_INVENTORY_WRITE = B.comment( + "Refuse to UPDATE player_data with an empty inventory if the DB currently has non-empty", + "data. Last-resort guard against on-disconnect wipes. Set to false only for debugging.") + .define("refuse_empty_inventory_write", true); + MAX_INVENTORY_SIZE_BYTES = B.comment( + "Max serialized inventory size (bytes). Snapshots larger than this are rejected with", + "a log entry. Protects against infinite-NBT exploits. Default 10 MB.") + .defineInRange("max_inventory_size_bytes", 10 * 1024 * 1024, 1024, 512 * 1024 * 1024); + SKIP_SAVES_WHEN_TPS_BELOW = B.comment( + "Skip periodic auto-saves when the server MSPT average exceeds the value implied by this", + "TPS threshold. 0 = never skip. Example: 15 skips periodic saves when TPS < 15.") + .defineInRange("skip_saves_when_tps_below", 0, 0, 20); + B.pop(); + + // ===== Observability ===== + B.comment("Log file & diagnostics").push("observability"); + LOG_STRUCTURED_JSON = B.comment( + "Emit sync.log entries as JSON objects instead of text. Enables ingestion in", + "Loki / ELK / Splunk pipelines.") + .define("log_structured_json", false); + LOG_ROTATION_SIZE_MB = B.comment( + "Max sync.log size before rotation (megabytes).") + .defineInRange("log_rotation_size_mb", 10, 1, 1024); + LOG_ROTATION_MAX_FILES = B.comment( + "Keep at most N rotated sync.log files (oldest deleted).") + .defineInRange("log_rotation_max_files", 5, 1, 100); + B.pop(); + + COMMON_CONFIG = B.build(); } } diff --git a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java index 6ae4e51..7bcded3 100644 --- a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java @@ -119,6 +119,11 @@ public class VanillaSync { } } + /** Admin-command accessor for the shared executor — read-only usage. */ + public static ThreadPoolExecutor getExecutor() { + return (ThreadPoolExecutor) executorService; + } + public static void removePlayerLock(String uuid) { playerLocks.remove(uuid); lastWrittenSnapshotHash.remove(uuid); @@ -382,8 +387,9 @@ public class VanillaSync { // heartbeated in >60s, treat it as dead and stop waiting immediately. // This fixes the user-reported "attempt 60/60" log flood for server_id=0 // and zombie server_ids whose player_data.last_server never gets cleared. - final int MAX_POLL = 120; - final long STALE_HEARTBEAT_MS = 60_000L; + final int MAX_POLL = JdbcConfig.JOIN_POLL_MAX_ATTEMPTS.get(); + final int POLL_INTERVAL_MS = JdbcConfig.JOIN_POLL_INTERVAL_MS.get(); + final long STALE_HEARTBEAT_MS = JdbcConfig.PEER_STALE_THRESHOLD_SECONDS.get() * 1000L; for (int attempt = 0; attempt < MAX_POLL; attempt++) { try (JDBCsetUp.QueryResult qrCheck = JDBCsetUp.executePreparedQuery( "SELECT online, last_server FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) { @@ -410,7 +416,7 @@ public class VanillaSync { SyncLogger.raceCondition(player_uuid, "Waiting for server " + otherServer + " to finish saving (attempt " + (attempt + 1) + "/" + MAX_POLL + ")"); PlayerSync.LOGGER.info("Player {} still being saved on server {} (attempt {}/{}), waiting 500ms...", player_uuid, otherServer, attempt + 1, MAX_POLL); - Thread.sleep(500); + Thread.sleep(POLL_INTERVAL_MS); continue; } } @@ -1747,6 +1753,31 @@ public class VanillaSync { private static boolean writeSnapshotToDB(PlayerDataSnapshot s, boolean setOffline) throws Exception { int serverId = JdbcConfig.SERVER_ID.get(); + // PHASE 8: safety guards — abort before corrupting DB with garbage or wipes. + if (JdbcConfig.REFUSE_EMPTY_INVENTORY_WRITE.get() + && (s.inventory() == null || s.inventory().isEmpty() || s.inventory().length() < 4)) { + // Only skip if DB currently has real data — new players legitimately have empty inventories + try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery( + "SELECT LENGTH(inventory) AS len FROM " + Tables.playerData() + " WHERE uuid=?", s.uuid())) { + ResultSet rs = qr.resultSet(); + if (rs.next() && rs.getInt("len") > 50) { + SyncLogger.dataLoss(s.uuid(), + "REFUSED empty inventory write (DB has " + rs.getInt("len") + " bytes). Set refuse_empty_inventory_write=false to override."); + PlayerSync.LOGGER.warn("[write-guard] refused empty inventory write for {} (DB has {} bytes)", + s.uuid(), rs.getInt("len")); + return false; + } + } catch (Exception ignored) {} + } + int maxBytes = JdbcConfig.MAX_INVENTORY_SIZE_BYTES.get(); + if (s.inventory() != null && s.inventory().length() > maxBytes) { + SyncLogger.nbtAnomaly(s.uuid(), + "inventory payload " + s.inventory().length() + " bytes exceeds max_inventory_size_bytes=" + maxBytes + " — REJECTED"); + PlayerSync.LOGGER.error("[write-guard] inventory too large for {} ({} bytes > {} max)", + s.uuid(), s.inventory().length(), maxBytes); + return false; + } + // FIX PERF: All writes batched into a SINGLE transaction on ONE connection. // Previously 4-8 separate connections × round-trips per player. // Now: 1 connection, 1 commit, automatic rollback on failure. diff --git a/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java b/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java index 16db112..13a5899 100644 --- a/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/addons/ModCompatSync.java @@ -179,6 +179,7 @@ public class ModCompatSync { */ public static void applyAccessoriesFromData(Player player, String accessoriesData) { if (!ModList.get().isLoaded("accessories")) return; + if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_ACCESSORIES.get()) return; // PHASE 8: toggle try { io.wispforest.accessories.api.AccessoriesCapability cap = io.wispforest.accessories.api.AccessoriesCapability.get(player); @@ -335,6 +336,7 @@ public class ModCompatSync { */ public static void applyCosmeticArmorFromData(Player player, String cosmeticArmorData) { if (!ModList.get().isLoaded("cosmeticarmorreworked")) return; + if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_COSMETIC_ARMOR.get()) return; // PHASE 8: toggle try { lain.mods.cos.impl.inventory.InventoryCosArmor cosInv = lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID()); diff --git a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java index 7d0bced..c7900a9 100644 --- a/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java +++ b/src/main/java/vip/fubuki/playersync/sync/addons/ModsSupport.java @@ -27,6 +27,7 @@ import java.util.*; public class ModsSupport { public void doBackPackRestore(Player player) { + if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_BACKPACKS.get()) return; // PHASE 8: toggle if (ModList.get().isLoaded("sophisticatedbackpacks")) { PlayerSync.LOGGER.info("Restoring backpack data for player {}", player.getUUID()); // Restore backpacks from main inventory @@ -367,6 +368,7 @@ public class ModsSupport { */ public static void applyCuriosFromData(Player player, String curiosData) { if (!ModList.get().isLoaded("curios")) return; + if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_CURIOS.get()) return; // PHASE 8: toggle Optional handlerOpt = CuriosApi.getCuriosInventory(player); if (handlerOpt.isEmpty()) { @@ -1070,6 +1072,7 @@ public class ModsSupport { @SuppressWarnings("unchecked") public static void restoreRefinedStorageDisks(Player player) { if (!ModList.get().isLoaded("refinedstorage")) return; + if (!vip.fubuki.playersync.config.JdbcConfig.SYNC_REFINED_STORAGE.get()) return; // PHASE 8: toggle if (!(player instanceof net.minecraft.server.level.ServerPlayer sp)) return; List diskUuids = collectRS2DiskUuids(player); diff --git a/src/main/java/vip/fubuki/playersync/util/HeartbeatService.java b/src/main/java/vip/fubuki/playersync/util/HeartbeatService.java index 0d6b5d1..cc947e4 100644 --- a/src/main/java/vip/fubuki/playersync/util/HeartbeatService.java +++ b/src/main/java/vip/fubuki/playersync/util/HeartbeatService.java @@ -25,11 +25,16 @@ public final class HeartbeatService { private HeartbeatService() {} /** - * Heartbeat period: 30s. Paired with the 60s staleness threshold in - * {@code VanillaSync.isPeerServerStale}. Three orders of magnitude lower DB - * load than the previous 10s without sacrificing detection window. + * Heartbeat period: configurable via {@code heartbeat_interval_seconds}. + * Paired with {@code peer_stale_threshold_seconds}. */ - private static final long PERIOD_MS = 30_000L; + private static long currentPeriodMs() { + try { + return JdbcConfig.HEARTBEAT_INTERVAL_SECONDS.get() * 1000L; + } catch (Throwable t) { + return 30_000L; + } + } private static final AtomicBoolean RUNNING = new AtomicBoolean(false); private static ScheduledExecutorService scheduler; @@ -42,8 +47,9 @@ public final class HeartbeatService { t.setPriority(Thread.MIN_PRIORITY); return t; }); - scheduler.scheduleAtFixedRate(HeartbeatService::tick, PERIOD_MS, PERIOD_MS, TimeUnit.MILLISECONDS); - PlayerSync.LOGGER.info("[heartbeat] started (period={}ms, server_id={})", PERIOD_MS, JdbcConfig.SERVER_ID.get()); + long period = currentPeriodMs(); + scheduler.scheduleAtFixedRate(HeartbeatService::tick, period, period, TimeUnit.MILLISECONDS); + PlayerSync.LOGGER.info("[heartbeat] started (period={}ms, server_id={})", period, JdbcConfig.SERVER_ID.get()); } public static void stop() { diff --git a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java index a73163e..f5c618d 100644 --- a/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java +++ b/src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java @@ -199,13 +199,22 @@ public class JDBCsetUp { } public static void executePreparedUpdate(String sql, Object... params) throws SQLException { + executePreparedUpdateRet(sql, params); + } + + /** + * Variant of {@link #executePreparedUpdate(String, Object...)} that returns the + * number of rows affected. Used by admin commands (clearorphans, peerkill, wipe) + * to report meaningful counts to the operator. + */ + public static int executePreparedUpdateRet(String sql, Object... params) throws SQLException { LOGGER.trace(sql); try (Connection conn = getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { for (int i = 0; i < params.length; i++) { stmt.setObject(i + 1, params[i]); } - stmt.executeUpdate(); + return stmt.executeUpdate(); } } diff --git a/src/main/java/vip/fubuki/playersync/util/PoolStatsReporter.java b/src/main/java/vip/fubuki/playersync/util/PoolStatsReporter.java index dd10e40..c8b5af8 100644 --- a/src/main/java/vip/fubuki/playersync/util/PoolStatsReporter.java +++ b/src/main/java/vip/fubuki/playersync/util/PoolStatsReporter.java @@ -21,12 +21,20 @@ public final class PoolStatsReporter { private PoolStatsReporter() {} - private static final long PERIOD_MS = 5 * 60 * 1000L; - private static final AtomicBoolean RUNNING = new AtomicBoolean(false); private static ScheduledExecutorService scheduler; public static void start() { + int minutes; + try { + minutes = vip.fubuki.playersync.config.JdbcConfig.POOL_STATS_INTERVAL_MINUTES.get(); + } catch (Throwable t) { + minutes = 5; + } + if (minutes <= 0) { + PlayerSync.LOGGER.info("[pool-stats] disabled (pool_stats_interval_minutes=0)"); + return; + } if (!RUNNING.compareAndSet(false, true)) return; scheduler = Executors.newSingleThreadScheduledExecutor(r -> { Thread t = new Thread(r, "PlayerSync-pool-stats"); @@ -34,8 +42,9 @@ public final class PoolStatsReporter { t.setPriority(Thread.MIN_PRIORITY); return t; }); - scheduler.scheduleAtFixedRate(PoolStatsReporter::tick, PERIOD_MS, PERIOD_MS, TimeUnit.MILLISECONDS); - PlayerSync.LOGGER.info("[pool-stats] reporter started (period={}ms)", PERIOD_MS); + long periodMs = minutes * 60_000L; + scheduler.scheduleAtFixedRate(PoolStatsReporter::tick, periodMs, periodMs, TimeUnit.MILLISECONDS); + PlayerSync.LOGGER.info("[pool-stats] reporter started (period={}ms)", periodMs); } public static void stop() {