Phase 8: 20+ new config keys + 14 admin commands (/playersync)
Config (JdbcConfig.java completely restructured into sections):
connection
host, port, use_ssl, user_name, password, db_name, table_prefix, Server_id
general
sync_world, sync_advancements, kick_when_already_online,
kick_message, kick_grace_period_ms, use_legacy_serialization,
item_placeholder_title_override, item_placeholder_description_override
save_triggers
auto_save_interval_minutes (0-1440, default 10)
save_on_dimension_change (default false)
save_on_death (default true)
save_on_respawn (default true)
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)
performance
heartbeat_interval_seconds (5-600, default 30)
peer_stale_threshold_seconds (10-3600, default 60)
join_poll_max_attempts (10-600, default 120)
join_poll_interval_ms (100-5000, default 500)
pool_stats_interval_minutes (0-1440, default 5)
hikari_pool_max_size (1-200, default 15)
hikari_leak_threshold_ms (2000-600000, default 25000)
safety
refuse_empty_inventory_write (default true) — enforced in writeSnapshotToDB
max_inventory_size_bytes (default 10 MB)
skip_saves_when_tps_below (0-20, default 0 = never)
observability
log_structured_json (future use)
log_rotation_size_mb (default 10)
log_rotation_max_files (default 5)
Wiring
- HeartbeatService reads heartbeat_interval_seconds at start.
- PoolStatsReporter reads pool_stats_interval_minutes (0 disables).
- doPlayerJoin poll uses join_poll_max_attempts + join_poll_interval_ms +
peer_stale_threshold_seconds.
- writeSnapshotToDB: refuse_empty guard + max_inventory_size_bytes guard
before core UPDATE. Both log via SyncLogger.dataLoss / .nbtAnomaly.
- Restore-side toggles: applyCuriosFromData, applyAccessoriesFromData,
applyCosmeticArmorFromData, doBackPackRestore, restoreRefinedStorageDisks
all short-circuit when their toggle is false.
Commands — new /playersync tree (perm level 2 required):
status — server id + heartbeat age + exec/Hikari stats + online
poolstats — log current stats immediately
flush [player] — force save all / one
info <player> — DB row metadata
dump <player> — dump full DB row to server log
resync <player> — clear synced tag + kick to force re-restore
wipe <player> confirm — DELETE all rows (DANGER, double-keyword required)
orphans — list stuck online=1 rows on dead peers
clearorphans [id] — clear orphans (global or by server_id)
peers — list peer servers with ALIVE/STALE/STOPPED tag
peerkill <id> — force-disable a zombie peer
cleanup — orphans + stale peers in one shot
reload — note about runtime reload scope
help — in-chat command reference
Every command logs to SyncLogger as ADMIN_<OP> for audit trail.
Infrastructure
- JDBCsetUp.executePreparedUpdateRet(String, Object...) returns rows-affected
for commands that need meaningful counts.
- VanillaSync.getExecutor() exposes the thread pool for read-only stats access
from admin commands (replaces reflection use in PoolStatsReporter eventually).
This commit is contained in:
parent
44178e020e
commit
c7487196ec
44
CHANGELOG.md
44
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 <player>` — DB row metadata (last_server, online flag, data sizes)
|
||||
- `dump <player>` — full DB row dump into server log
|
||||
- `resync <player>` — clear player_synced tag and kick to force fresh restore
|
||||
- `wipe <player> 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 <server_id>` — 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)
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
*
|
||||
* <p>Root: {@code /playersync}
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code status} — server + pool + heartbeat summary</li>
|
||||
* <li>{@code flush [player]} — force an immediate save</li>
|
||||
* <li>{@code info <player>} — show DB row metadata</li>
|
||||
* <li>{@code reload} — reload config from disk</li>
|
||||
* <li>{@code orphans} — list stuck online=1 rows</li>
|
||||
* <li>{@code clearorphans [server_id]} — clear them</li>
|
||||
* <li>{@code peers} — list peer servers</li>
|
||||
* <li>{@code peerkill <id>} — force-disable a zombie peer</li>
|
||||
* <li>{@code cleanup} — clear orphans + stale peers in one go</li>
|
||||
* <li>{@code dump <player>} — dump DB row keys & sizes</li>
|
||||
* <li>{@code resync <player>} — force re-apply from DB</li>
|
||||
* <li>{@code poolstats} — immediate pool stats</li>
|
||||
* <li>{@code wipe <player>} — DANGER: delete all rows for a player</li>
|
||||
* <li>{@code version} — mod version</li>
|
||||
* </ul>
|
||||
*
|
||||
* @author vyrriox
|
||||
*/
|
||||
@EventBusSubscriber()
|
||||
public class CommandInit {
|
||||
|
||||
private static final int PERM_OP = 2;
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerCommand(RegisterCommandsEvent event){
|
||||
CommandDispatcher<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
Collection<com.mojang.authlib.GameProfile> 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<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
Collection<com.mojang.authlib.GameProfile> 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<CommandSourceStack> 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<CommandSourceStack> ctx)
|
||||
throws CommandSyntaxException {
|
||||
Collection<com.mojang.authlib.GameProfile> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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<CommandSourceStack> 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 <player> §7— DB row metadata",
|
||||
"§e/playersync dump <player> §7— dump DB row to server log",
|
||||
"§e/playersync resync <player> §7— kick to force re-sync",
|
||||
"§e/playersync wipe <player> 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 <id> §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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,91 +10,210 @@ import java.util.Random;
|
|||
|
||||
public class JdbcConfig {
|
||||
public static ModConfigSpec COMMON_CONFIG;
|
||||
|
||||
// ----- Connection -----
|
||||
public static ModConfigSpec.ConfigValue<String> HOST;
|
||||
public static ModConfigSpec.IntValue PORT;
|
||||
public static ModConfigSpec.ConfigValue<String> USERNAME;
|
||||
public static ModConfigSpec.ConfigValue<String> PASSWORD;
|
||||
public static ModConfigSpec.ConfigValue<String> DATABASE_NAME;
|
||||
public static ModConfigSpec.BooleanValue USE_SSL;
|
||||
|
||||
// ----- Core sync behaviour -----
|
||||
public static ModConfigSpec.ConfigValue<List<String>> 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<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
|
||||
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
|
||||
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||
|
||||
public static ModConfigSpec.ConfigValue<Integer> 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<String> 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<String> 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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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<ICuriosItemHandler> 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<UUID> diskUuids = collectRS2DiskUuids(player);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user