Fix backpack/curios dup, perf overhaul, drop chat+cobblemon
Root cause of backpack duplication: Sophisticated Backpacks' setBackpackContents merges shallowly when the UUID exists, so stale sub-tags survived every restore. doBackPackRestore now calls removeBackpackContents before setBackpackContents for a clean replace. Curios cosmetic stacks (getCosmeticStacks) are now snapshotted, applied, restored and cached on all paths. Old-format rows without the "cos:" prefix still parse unchanged, so existing DB data is preserved on upgrade. closeContainer no longer matches by class-name substring (was closing unrelated mod menus containing "curio"/"accessor"). Only menus whose slots reference the disconnecting player's inventory/ender-chest are closed. Thread-safety: Sophisticated Storage contents are now snapshotted on the main thread (snapshotSSData + saveSSSnapshots) instead of read from a background thread racing with world ticks. Event priority / defensive guards: - onPlayerDeath is now EventPriority.LOW and skips cancelled events so Revive Me / Corail Tombstone's cancel runs first. - onServerStarting short-circuits on integrated (single-player) servers to avoid noisy MySQL connection attempts. Observability: - executeBatchTransaction now returns per-statement row counts. - writeSnapshotToDB calls SyncLogger.guardBlocked when the core UPDATE silently no-ops (another server claimed last_server). - SyncLogger uses a daemon scheduler that flushes every 500 ms; shutdown happens after parallel saves so final save logs are no longer dropped. - Rollback failures inside executeBatchTransaction and refreshInventoryForInputOutput are now logged instead of swallowed. HikariCP retuned: maxPoolSize 25->15, connectionTimeout 30->10s, idleTimeout 600->300s, leakDetectionThreshold 10->25s (covers worst-case join polling without log spam). New table_prefix config option (Tables helper) lets a user share one MySQL database with other mods without table-name collisions. Default is empty to preserve backward compatibility. Reflection Methods for NeoForge AttachmentHolder are resolved once in a static initializer and cached. Chat sync and Cobblemon integration removed: - Chat sync: 319 LoC of socket/thread code guarded by a config flag that defaulted to false; orphaned config keys are silently ignored by the NeoForge ModConfig loader, so no crash on upgrade. - Cobblemon: 297 LoC of mixins that ran synchronous JDBC on the main thread and built SQL with raw UUID concatenation. The existing cobblemon table in the DB is left untouched on upgrade. Also fixes cobblemon ALTER TABLE running blindly on every boot (alterColumnIfNeeded helper checks INFORMATION_SCHEMA first). Author: vyrriox
This commit is contained in:
parent
edf63aeb8c
commit
13de5b65c0
13
.gitignore
vendored
13
.gitignore
vendored
|
|
@ -35,3 +35,16 @@ runs
|
||||||
run-data
|
run-data
|
||||||
|
|
||||||
repo
|
repo
|
||||||
|
|
||||||
|
# Claude Code
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
|
# BMad
|
||||||
|
.agent/
|
||||||
|
_bmad/
|
||||||
|
_bmad-output/
|
||||||
|
_bmb/
|
||||||
|
|
||||||
|
# compat mods (local jars for analysis)
|
||||||
|
compat-mods/*.jar
|
||||||
|
|
|
||||||
|
|
@ -13,9 +13,9 @@ import net.neoforged.neoforge.event.server.ServerStartingEvent;
|
||||||
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
import net.neoforged.neoforge.event.server.ServerStoppingEvent;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import vip.fubuki.playersync.config.JdbcConfig;
|
import vip.fubuki.playersync.config.JdbcConfig;
|
||||||
import vip.fubuki.playersync.sync.ChatSync;
|
|
||||||
import vip.fubuki.playersync.sync.VanillaSync;
|
import vip.fubuki.playersync.sync.VanillaSync;
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||||
|
import vip.fubuki.playersync.util.Tables;
|
||||||
|
|
||||||
import java.sql.Connection;
|
import java.sql.Connection;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
|
|
@ -35,18 +35,22 @@ public class PlayerSync {
|
||||||
|
|
||||||
private void commonSetup(final FMLCommonSetupEvent event) {
|
private void commonSetup(final FMLCommonSetupEvent event) {
|
||||||
VanillaSync.register();
|
VanillaSync.register();
|
||||||
event.enqueueWork(() -> {
|
// Chat sync removed. The `sync_chat` / `IsChatServer` / `ChatServerIP` /
|
||||||
// read SYNC_CHAT only within the enqueueWork to reliably get the real
|
// `ChatServerPort` keys in existing config files are now silently ignored
|
||||||
// config value and not its default value.
|
// (NeoForge's ModConfig loader skips unknown keys, so no crash on upgrade).
|
||||||
if (JdbcConfig.SYNC_CHAT.get()) {
|
|
||||||
LOGGER.info("Chat sync enabled.");
|
|
||||||
ChatSync.register();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public void onServerStarting(ServerStartingEvent event) throws SQLException {
|
public void onServerStarting(ServerStartingEvent event) throws SQLException {
|
||||||
|
// FIX COMPAT (C2): skip all MySQL init on single-player / integrated servers.
|
||||||
|
// Running PlayerSync in single-player makes no sense (no cross-server sync) and
|
||||||
|
// attempting to open a MySQL connection with default placeholder credentials on a
|
||||||
|
// laptop without a MySQL server produces noisy errors + degraded UX.
|
||||||
|
if (!event.getServer().isDedicatedServer()) {
|
||||||
|
LOGGER.info("PlayerSync: integrated server detected — skipping MySQL init (dedicated-server only).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String dbName = JdbcConfig.DATABASE_NAME.get();
|
String dbName = JdbcConfig.DATABASE_NAME.get();
|
||||||
|
|
||||||
// FIX: Validate database name to prevent SQL injection via config.
|
// FIX: Validate database name to prevent SQL injection via config.
|
||||||
|
|
@ -83,7 +87,7 @@ public class PlayerSync {
|
||||||
// Step 4: Create and alter tables using fully qualified names.
|
// Step 4: Create and alter tables using fully qualified names.
|
||||||
// Create player_data table
|
// Create player_data table
|
||||||
JDBCsetUp.executeUpdate(
|
JDBCsetUp.executeUpdate(
|
||||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`player_data` (" +
|
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.playerData() + "` (" +
|
||||||
"`uuid` char(36) NOT NULL," +
|
"`uuid` char(36) NOT NULL," +
|
||||||
"`inventory` mediumblob," +
|
"`inventory` mediumblob," +
|
||||||
"`armor` blob," +
|
"`armor` blob," +
|
||||||
|
|
@ -105,8 +109,8 @@ public class PlayerSync {
|
||||||
// Check and alter player_data table if columns are missing
|
// Check and alter player_data table if columns are missing
|
||||||
int columnCount = 0;
|
int columnCount = 0;
|
||||||
try (JDBCsetUp.QueryResult queryResult = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult queryResult = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT COUNT(*) AS column_count FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'player_data'",
|
"SELECT COUNT(*) AS column_count FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ?",
|
||||||
dbName)) {
|
dbName, Tables.playerData())) {
|
||||||
ResultSet resultSet = queryResult.resultSet();
|
ResultSet resultSet = queryResult.resultSet();
|
||||||
if (resultSet.next()) {
|
if (resultSet.next()) {
|
||||||
columnCount = resultSet.getInt("column_count");
|
columnCount = resultSet.getInt("column_count");
|
||||||
|
|
@ -114,7 +118,7 @@ public class PlayerSync {
|
||||||
}
|
}
|
||||||
if (columnCount < 14) {
|
if (columnCount < 14) {
|
||||||
JDBCsetUp.executeUpdate(
|
JDBCsetUp.executeUpdate(
|
||||||
"ALTER TABLE `" + dbName + "`.`player_data` " +
|
"ALTER TABLE `" + dbName + "`.`" + Tables.playerData() + "` " +
|
||||||
"ADD COLUMN left_hand blob, " +
|
"ADD COLUMN left_hand blob, " +
|
||||||
"ADD COLUMN cursors blob;"
|
"ADD COLUMN cursors blob;"
|
||||||
);
|
);
|
||||||
|
|
@ -122,7 +126,7 @@ public class PlayerSync {
|
||||||
|
|
||||||
// Create server_info table
|
// Create server_info table
|
||||||
JDBCsetUp.executeUpdate(
|
JDBCsetUp.executeUpdate(
|
||||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`server_info` (" +
|
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.serverInfo() + "` (" +
|
||||||
"`id` INT NOT NULL," +
|
"`id` INT NOT NULL," +
|
||||||
"`enable` boolean NOT NULL," +
|
"`enable` boolean NOT NULL," +
|
||||||
"`last_update` BIGINT NOT NULL," +
|
"`last_update` BIGINT NOT NULL," +
|
||||||
|
|
@ -132,82 +136,65 @@ public class PlayerSync {
|
||||||
// FIX H-8: Use prepared statements for server_id to prevent SQL injection from config
|
// FIX H-8: Use prepared statements for server_id to prevent SQL injection from config
|
||||||
long current = System.currentTimeMillis();
|
long current = System.currentTimeMillis();
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"INSERT INTO `" + dbName + "`.`server_info`(id,enable,last_update) VALUES(?,true,?) ON DUPLICATE KEY UPDATE id=VALUES(id),enable=1,last_update=VALUES(last_update)",
|
"INSERT INTO `" + dbName + "`.`" + Tables.serverInfo() + "`(id,enable,last_update) VALUES(?,true,?) ON DUPLICATE KEY UPDATE id=VALUES(id),enable=1,last_update=VALUES(last_update)",
|
||||||
JdbcConfig.SERVER_ID.get(), current
|
JdbcConfig.SERVER_ID.get(), current
|
||||||
);
|
);
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"UPDATE `" + dbName + "`.`server_info` SET last_update=? WHERE id=?",
|
"UPDATE `" + dbName + "`.`" + Tables.serverInfo() + "` SET last_update=? WHERE id=?",
|
||||||
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()
|
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get()
|
||||||
);
|
);
|
||||||
|
|
||||||
// Create curios table if the Curios mod is loaded
|
// Create curios table if the Curios mod is loaded
|
||||||
if (ModList.get().isLoaded("curios")) {
|
if (ModList.get().isLoaded("curios")) {
|
||||||
JDBCsetUp.executeUpdate(
|
JDBCsetUp.executeUpdate(
|
||||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`curios` (" +
|
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.curios() + "` (" +
|
||||||
"uuid CHAR(36) NOT NULL, curios_item BLOB, PRIMARY KEY (uuid)" +
|
"uuid CHAR(36) NOT NULL, curios_item BLOB, PRIMARY KEY (uuid)" +
|
||||||
")"
|
")"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Cobblemon table
|
// Cobblemon support removed in this build (sync was main-thread blocking + SQL
|
||||||
if(ModList.get().isLoaded("cobblemon")){
|
// injection in the mixins). Existing `cobblemon` tables in the DB are kept intact
|
||||||
JDBCsetUp.executeUpdate(
|
// for backward compat — they are simply no longer read or written.
|
||||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`cobblemon`(" +
|
|
||||||
"uuid CHAR(36) NOT NULL," +
|
|
||||||
"inv BLOB," +
|
|
||||||
"pokedex MEDIUMBLOB," +
|
|
||||||
"pc MEDIUMBLOB," +
|
|
||||||
"general BLOB," +
|
|
||||||
"PRIMARY KEY (uuid)" +
|
|
||||||
")"
|
|
||||||
);
|
|
||||||
|
|
||||||
JDBCsetUp.executeUpdate(
|
|
||||||
"ALTER TABLE `" + dbName + "`.`cobblemon` MODIFY COLUMN pc MEDIUMBLOB"
|
|
||||||
);
|
|
||||||
JDBCsetUp.executeUpdate(
|
|
||||||
"ALTER TABLE `" + dbName + "`.`cobblemon` MODIFY COLUMN pokedex MEDIUMBLOB"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create backpack_data table
|
// Create backpack_data table
|
||||||
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
||||||
JDBCsetUp.executeUpdate(
|
JDBCsetUp.executeUpdate(
|
||||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`backpack_data` (" +
|
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.backpackData() + "` (" +
|
||||||
"uuid CHAR(36) NOT NULL, backpack_nbt MEDIUMBLOB, PRIMARY KEY (uuid)" +
|
"uuid CHAR(36) NOT NULL, backpack_nbt MEDIUMBLOB, PRIMARY KEY (uuid)" +
|
||||||
");", 1
|
");", 1
|
||||||
);
|
);
|
||||||
|
|
||||||
// Check if backpack_data table has the 'uuid' column
|
// Check if backpack_data table has the 'uuid' column
|
||||||
try (JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'backpack_data' AND COLUMN_NAME = 'uuid'",
|
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = 'uuid'",
|
||||||
dbName)) {
|
dbName, Tables.backpackData())) {
|
||||||
ResultSet rsBackpackCol = backpackColCheck.resultSet();
|
ResultSet rsBackpackCol = backpackColCheck.resultSet();
|
||||||
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
|
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
|
||||||
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
|
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
|
||||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
|
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + Tables.backpackData() + "` ADD COLUMN uuid CHAR(36) NOT NULL", 1);
|
||||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`backpack_data` ADD PRIMARY KEY (uuid)", 1);
|
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + Tables.backpackData() + "` ADD PRIMARY KEY (uuid)", 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check and alter the 'advancements' column in player_data if necessary
|
// Check and alter the 'advancements' column in player_data if necessary
|
||||||
try (JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = 'player_data' AND COLUMN_NAME = 'advancements'",
|
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = ? AND TABLE_NAME = ? AND COLUMN_NAME = 'advancements'",
|
||||||
dbName)) {
|
dbName, Tables.playerData())) {
|
||||||
ResultSet rsAdvCol = advColCheck.resultSet();
|
ResultSet rsAdvCol = advColCheck.resultSet();
|
||||||
if (rsAdvCol.next()) {
|
if (rsAdvCol.next()) {
|
||||||
String dataType = rsAdvCol.getString("DATA_TYPE");
|
String dataType = rsAdvCol.getString("DATA_TYPE");
|
||||||
if (!"mediumblob".equalsIgnoreCase(dataType)) {
|
if (!"mediumblob".equalsIgnoreCase(dataType)) {
|
||||||
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
|
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
|
||||||
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`player_data` MODIFY COLUMN advancements MEDIUMBLOB", 1);
|
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + Tables.playerData() + "` MODIFY COLUMN advancements MEDIUMBLOB", 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create generic mod_player_data table for mod compatibility (Accessories, CosmeticArmor, Aether, etc.)
|
// Create generic mod_player_data table for mod compatibility (Accessories, CosmeticArmor, Aether, etc.)
|
||||||
JDBCsetUp.executeUpdate(
|
JDBCsetUp.executeUpdate(
|
||||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`mod_player_data` (" +
|
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`" + Tables.modPlayerData() + "` (" +
|
||||||
"`uuid` CHAR(36) NOT NULL," +
|
"`uuid` CHAR(36) NOT NULL," +
|
||||||
"`mod_id` VARCHAR(64) NOT NULL," +
|
"`mod_id` VARCHAR(64) NOT NULL," +
|
||||||
"`data_value` MEDIUMBLOB," +
|
"`data_value` MEDIUMBLOB," +
|
||||||
|
|
@ -216,21 +203,41 @@ public class PlayerSync {
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE last_server=? AND online=1", JdbcConfig.SERVER_ID.get());
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE last_server=? AND online=1", JdbcConfig.SERVER_ID.get());
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
LOGGER.error("An exception occurred while trying change wrong player-status\n" + e.getMessage());
|
LOGGER.error("An exception occurred while trying change wrong player-status\n" + e.getMessage());
|
||||||
}
|
}
|
||||||
LOGGER.info("PlayerSync is ready!");
|
LOGGER.info("PlayerSync is ready!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Alters a column to {@code targetType} only if its current {@code DATA_TYPE}
|
||||||
|
* differs. Skips expensive MDL + rebuild on every server start.
|
||||||
|
*/
|
||||||
|
private static void alterColumnIfNeeded(String dbName, String table, String column, String targetTypeLower) throws SQLException {
|
||||||
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
|
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA=? AND TABLE_NAME=? AND COLUMN_NAME=?",
|
||||||
|
dbName, table, column)) {
|
||||||
|
ResultSet rs = qr.resultSet();
|
||||||
|
if (rs.next()) {
|
||||||
|
String current = rs.getString("DATA_TYPE");
|
||||||
|
if (current != null && targetTypeLower.equalsIgnoreCase(current)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LOGGER.info("Altering {}.{} column {} to {}", dbName, table, column, targetTypeLower.toUpperCase());
|
||||||
|
JDBCsetUp.executeUpdate("ALTER TABLE `" + dbName + "`.`" + table + "` MODIFY COLUMN `" + column + "` " + targetTypeLower.toUpperCase());
|
||||||
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public void onServerStopping(ServerStoppingEvent event) {
|
public void onServerStopping(ServerStoppingEvent event) {
|
||||||
ChatSync.shutdown();
|
// DO NOT call JDBCsetUp.shutdownPool() or SyncLogger.shutdown() here!
|
||||||
vip.fubuki.playersync.util.SyncLogger.shutdown();
|
|
||||||
// DO NOT call JDBCsetUp.shutdownPool() here!
|
|
||||||
// VanillaSync.onServerShutdown also subscribes to ServerStoppingEvent and
|
// VanillaSync.onServerShutdown also subscribes to ServerStoppingEvent and
|
||||||
// needs the pool to save all player data. Event firing order is not guaranteed.
|
// needs the pool to save all player data AND the logger to trace those saves.
|
||||||
// The pool is shut down at the very end of VanillaSync.onServerShutdown instead.
|
// NeoForge does not guarantee handler ordering across @SubscribeEvent instances,
|
||||||
|
// so both the pool and the logger are shut down at the very end of
|
||||||
|
// VanillaSync.onServerShutdown — after parallel saves finish.
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,17 +18,21 @@ public class JdbcConfig {
|
||||||
public static ModConfigSpec.ConfigValue<List<String>> SYNC_WORLD;
|
public static ModConfigSpec.ConfigValue<List<String>> SYNC_WORLD;
|
||||||
public static ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS;
|
public static ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS;
|
||||||
public static ModConfigSpec.BooleanValue USE_SSL;
|
public static ModConfigSpec.BooleanValue USE_SSL;
|
||||||
public static ModConfigSpec.BooleanValue SYNC_CHAT;
|
|
||||||
public static ModConfigSpec.BooleanValue IS_CHAT_SERVER;
|
|
||||||
public static ModConfigSpec.BooleanValue KICK_WHEN_ALREADY_ONLINE;
|
public static ModConfigSpec.BooleanValue KICK_WHEN_ALREADY_ONLINE;
|
||||||
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
|
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
|
||||||
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
|
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
|
||||||
public static ModConfigSpec.ConfigValue<String> CHAT_SERVER_IP;
|
|
||||||
public static ModConfigSpec.IntValue CHAT_SERVER_PORT;
|
|
||||||
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||||
|
|
||||||
public static ModConfigSpec.ConfigValue<Integer> SERVER_ID;
|
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.
|
||||||
|
*/
|
||||||
|
public static ModConfigSpec.ConfigValue<String> TABLE_PREFIX;
|
||||||
|
|
||||||
|
|
||||||
static {
|
static {
|
||||||
ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder();
|
ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder();
|
||||||
|
|
@ -39,16 +43,18 @@ public class JdbcConfig {
|
||||||
USERNAME = COMMON_BUILDER.comment("username").define("user_name", "playersync");
|
USERNAME = COMMON_BUILDER.comment("username").define("user_name", "playersync");
|
||||||
PASSWORD = COMMON_BUILDER.comment("password").define("password", "pleaseChangeThisPassword");
|
PASSWORD = COMMON_BUILDER.comment("password").define("password", "pleaseChangeThisPassword");
|
||||||
DATABASE_NAME = COMMON_BUILDER.comment("database name").define("db_name","playersync");
|
DATABASE_NAME = COMMON_BUILDER.comment("database name").define("db_name","playersync");
|
||||||
|
TABLE_PREFIX = COMMON_BUILDER.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));
|
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_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")
|
SYNC_ADVANCEMENTS = COMMON_BUILDER.comment("Whether to sync advancements between servers")
|
||||||
.define("sync_advancements", true);
|
.define("sync_advancements", true);
|
||||||
SYNC_CHAT = COMMON_BUILDER.comment("Whether synchronize chat").define("sync_chat", false);
|
|
||||||
IS_CHAT_SERVER = COMMON_BUILDER.comment("Whether recieve messages from other servers as host").define("IsChatServer",false);
|
|
||||||
KICK_WHEN_ALREADY_ONLINE = COMMON_BUILDER.comment("Whether to kick player when already online on another server")
|
KICK_WHEN_ALREADY_ONLINE = COMMON_BUILDER.comment("Whether to kick player when already online on another server")
|
||||||
.define("kick_when_already_online", true);
|
.define("kick_when_already_online", true);
|
||||||
CHAT_SERVER_IP = COMMON_BUILDER.define("ChatServerIP","127.0.0.1");
|
|
||||||
CHAT_SERVER_PORT = COMMON_BUILDER.defineInRange("ChatServerPort",7900,0,65535);
|
|
||||||
USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment(
|
USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment(
|
||||||
"Use the old (pre-Base64) serialization format for writing data to the database.",
|
"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.",
|
"Set to true ONLY if you have older mod versions reading the same database.",
|
||||||
|
|
|
||||||
|
|
@ -1,62 +0,0 @@
|
||||||
package vip.fubuki.playersync.mixin.cobblemon;
|
|
||||||
|
|
||||||
import com.cobblemon.mod.common.api.storage.PokemonStore;
|
|
||||||
import com.cobblemon.mod.common.api.storage.factory.FileBackedPokemonStoreFactory;
|
|
||||||
import com.cobblemon.mod.common.api.storage.party.PartyStore;
|
|
||||||
import com.cobblemon.mod.common.api.storage.pc.PCStore;
|
|
||||||
import kotlin.jvm.functions.Function1;
|
|
||||||
import net.minecraft.core.RegistryAccess;
|
|
||||||
import net.minecraft.nbt.CompoundTag;
|
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
|
||||||
import org.spongepowered.asm.mixin.Unique;
|
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
|
||||||
import org.spongepowered.asm.mixin.injection.Inject;
|
|
||||||
import org.spongepowered.asm.mixin.injection.Redirect;
|
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Mixin(FileBackedPokemonStoreFactory.class)
|
|
||||||
public class MixinFileBackedPokemonStoreFactory {
|
|
||||||
@Unique
|
|
||||||
RegistryAccess playerSync$registryAccess;
|
|
||||||
|
|
||||||
@Inject(method = "getStore", at = @At("HEAD"))
|
|
||||||
private <T extends PokemonStore<?>> void getStore$playerSync(Class<T> storeClass, UUID uuid, RegistryAccess registryAccess, Function1<? super UUID, ? extends T> constructor, CallbackInfoReturnable<T> cir){
|
|
||||||
this.playerSync$registryAccess = registryAccess;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Redirect(method = "getStore", at = @At(value = "INVOKE", target = "Lcom/cobblemon/mod/common/api/storage/PokemonStore;initialize()V"))
|
|
||||||
private void getStore$playerSync(PokemonStore<?> instance) {
|
|
||||||
|
|
||||||
String column;
|
|
||||||
if(instance instanceof PCStore){
|
|
||||||
column = "pc";
|
|
||||||
} else if(instance instanceof PartyStore){
|
|
||||||
column = "inv";
|
|
||||||
}else {
|
|
||||||
instance.initialize();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String sql = "SELECT " + column + " FROM cobblemon WHERE uuid = '" + instance.getUuid() + "'";
|
|
||||||
|
|
||||||
try {
|
|
||||||
JDBCsetUp.QueryResult qr = vip.fubuki.playersync.util.JDBCsetUp.executeQuery(sql);
|
|
||||||
ResultSet rs = qr.resultSet();
|
|
||||||
if (rs.next() && rs.getString(column) != null) {
|
|
||||||
CompoundTag compoundTag = new CompoundTag();
|
|
||||||
instance.loadFromNBT(compoundTag, playerSync$registryAccess);
|
|
||||||
}
|
|
||||||
|
|
||||||
rs.close();
|
|
||||||
qr.close();
|
|
||||||
} catch (java.sql.SQLException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
|
|
||||||
instance.initialize();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
package vip.fubuki.playersync.mixin.cobblemon;
|
|
||||||
|
|
||||||
import com.cobblemon.mod.common.api.pokedex.PokedexManager;
|
|
||||||
import com.cobblemon.mod.common.api.storage.player.InstancedPlayerData;
|
|
||||||
import com.cobblemon.mod.common.api.storage.player.adapter.NbtBackedPlayerData;
|
|
||||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
|
||||||
import com.mojang.serialization.Codec;
|
|
||||||
import com.mojang.serialization.DataResult;
|
|
||||||
import net.minecraft.nbt.CompoundTag;
|
|
||||||
import net.minecraft.nbt.NbtOps;
|
|
||||||
import net.minecraft.nbt.NbtUtils;
|
|
||||||
import net.minecraft.nbt.Tag;
|
|
||||||
import net.minecraft.resources.ResourceLocation;
|
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
|
||||||
import org.spongepowered.asm.mixin.injection.Inject;
|
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
|
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|
||||||
import vip.fubuki.playersync.mixin.cobblemon.accessor.FileBasedPlayerDataStoreBackendAccessor;
|
|
||||||
import vip.fubuki.playersync.mixin.cobblemon.accessor.NbtBackedPlayerDataAccessor;
|
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Mixin(NbtBackedPlayerData.class)
|
|
||||||
public class MixinNbtBackedPlayerData {
|
|
||||||
|
|
||||||
@Inject(method = "save", at = @org.spongepowered.asm.mixin.injection.At("HEAD"))
|
|
||||||
private void save$playerSync(InstancedPlayerData playerData, CallbackInfo ci) {
|
|
||||||
if(playerData instanceof PokedexManager){
|
|
||||||
Codec<InstancedPlayerData> codec = ((NbtBackedPlayerDataAccessor)this).getCodec();
|
|
||||||
DataResult<Tag> encodeResult = codec.encodeStart(
|
|
||||||
NbtOps.INSTANCE,
|
|
||||||
playerData
|
|
||||||
);
|
|
||||||
|
|
||||||
CompoundTag nbt = (CompoundTag) encodeResult.result().orElseThrow();
|
|
||||||
|
|
||||||
String serializedData = nbt.toString();
|
|
||||||
String sql = "INSERT INTO cobblemon (uuid, pokedex) VALUES ('" + playerData.getUuid() + "', '" + serializedData + "') " +
|
|
||||||
"ON DUPLICATE KEY UPDATE pokedex = '" + serializedData + "'";
|
|
||||||
try {
|
|
||||||
JDBCsetUp.executeUpdate(sql);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Inject(method = "load", at = @org.spongepowered.asm.mixin.injection.At("HEAD"), cancellable = true)
|
|
||||||
private void load$playerSync(UUID uuid, CallbackInfoReturnable<InstancedPlayerData> cir){
|
|
||||||
if(!((FileBasedPlayerDataStoreBackendAccessor) this).getType().getId().equals(ResourceLocation.fromNamespaceAndPath("cobblemon", "pokedex"))){
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
String sql = "SELECT pokedex FROM cobblemon WHERE uuid = '" + uuid + "'";
|
|
||||||
CompoundTag loadedNbt;
|
|
||||||
try {
|
|
||||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery(sql);
|
|
||||||
ResultSet rs = qr.resultSet();
|
|
||||||
if (rs.next()) {
|
|
||||||
String serializedData = rs.getString("pokedex");
|
|
||||||
|
|
||||||
if(serializedData == null){
|
|
||||||
rs.close();
|
|
||||||
qr.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
loadedNbt = NbtUtils.snbtToStructure(serializedData);
|
|
||||||
|
|
||||||
if(!loadedNbt.isEmpty()){
|
|
||||||
Codec<InstancedPlayerData> codec = ((NbtBackedPlayerDataAccessor)this).getCodec();
|
|
||||||
DataResult<InstancedPlayerData> decodeResult = codec.parse(
|
|
||||||
NbtOps.INSTANCE,
|
|
||||||
loadedNbt
|
|
||||||
);
|
|
||||||
InstancedPlayerData playerData = decodeResult.result().orElseThrow();
|
|
||||||
cir.setReturnValue(playerData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
rs.close();
|
|
||||||
qr.close();
|
|
||||||
} catch (SQLException | CommandSyntaxException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
package vip.fubuki.playersync.mixin.cobblemon;
|
|
||||||
|
|
||||||
import com.cobblemon.mod.common.api.storage.pc.PCStore;
|
|
||||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
|
||||||
import net.minecraft.core.RegistryAccess;
|
|
||||||
import net.minecraft.nbt.CompoundTag;
|
|
||||||
import net.minecraft.nbt.TagParser;
|
|
||||||
import org.spongepowered.asm.mixin.Final;
|
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
|
||||||
import org.spongepowered.asm.mixin.Shadow;
|
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
|
||||||
import org.spongepowered.asm.mixin.injection.Inject;
|
|
||||||
import org.spongepowered.asm.mixin.injection.ModifyVariable;
|
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
|
||||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Mixin(PCStore.class)
|
|
||||||
public class MixinPCStore {
|
|
||||||
@Final
|
|
||||||
@Shadow
|
|
||||||
private UUID uuid;
|
|
||||||
|
|
||||||
@Inject(method = "saveToNBT",at = @At("TAIL"))
|
|
||||||
private void saveToNBT$playerSync(CompoundTag nbt, RegistryAccess registryAccess, CallbackInfoReturnable<CompoundTag> cir) {
|
|
||||||
String serializedData = nbt.toString();
|
|
||||||
String sql = "INSERT INTO cobblemon (uuid, pc) VALUES ('" + this.uuid.toString() + "', '" + serializedData + "') " +
|
|
||||||
"ON DUPLICATE KEY UPDATE pc = '" + serializedData + "'";
|
|
||||||
try {
|
|
||||||
JDBCsetUp.executeUpdate(sql);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ModifyVariable(method = "loadFromNBT", at = @At("HEAD"), argsOnly = true, name = "arg1")
|
|
||||||
private CompoundTag loadFromNBT$playerSync(CompoundTag value) {
|
|
||||||
String sql = "SELECT pc FROM cobblemon WHERE uuid = '" + this.uuid.toString() + "'";
|
|
||||||
CompoundTag loadedNbt = value;
|
|
||||||
try {
|
|
||||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery(sql);
|
|
||||||
ResultSet rs = qr.resultSet();
|
|
||||||
if (rs.next()) {
|
|
||||||
String serializedData = rs.getString("pc");
|
|
||||||
loadedNbt = TagParser.parseTag(LocalJsonUtil.cleanSnbt(serializedData));
|
|
||||||
}
|
|
||||||
|
|
||||||
rs.close();
|
|
||||||
qr.close();
|
|
||||||
} catch (SQLException | CommandSyntaxException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
return loadedNbt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
package vip.fubuki.playersync.mixin.cobblemon;
|
|
||||||
|
|
||||||
import com.cobblemon.mod.common.api.storage.party.PartyStore;
|
|
||||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
|
||||||
import net.minecraft.core.RegistryAccess;
|
|
||||||
import net.minecraft.nbt.CompoundTag;
|
|
||||||
import net.minecraft.nbt.TagParser;
|
|
||||||
import org.spongepowered.asm.mixin.Final;
|
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
|
||||||
import org.spongepowered.asm.mixin.Shadow;
|
|
||||||
import org.spongepowered.asm.mixin.injection.At;
|
|
||||||
import org.spongepowered.asm.mixin.injection.Inject;
|
|
||||||
import org.spongepowered.asm.mixin.injection.ModifyVariable;
|
|
||||||
import org.spongepowered.asm.mixin.injection.callback.CallbackInfoReturnable;
|
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
|
||||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
|
||||||
import java.sql.SQLException;
|
|
||||||
import java.util.UUID;
|
|
||||||
|
|
||||||
@Mixin(PartyStore.class)
|
|
||||||
public class MixinPartyStore {
|
|
||||||
@Final
|
|
||||||
@Shadow
|
|
||||||
private UUID uuid;
|
|
||||||
|
|
||||||
@Inject(method = "saveToNBT",at = @At("TAIL"))
|
|
||||||
private void saveToNBT$playerSync(CompoundTag nbt, RegistryAccess registryAccess, CallbackInfoReturnable<CompoundTag> cir) {
|
|
||||||
String serializedData = nbt.toString();
|
|
||||||
String sql = "INSERT INTO cobblemon (uuid, inv) VALUES ('" + this.uuid.toString() + "', '" + serializedData + "') " +
|
|
||||||
"ON DUPLICATE KEY UPDATE inv = '" + serializedData + "'";
|
|
||||||
try {
|
|
||||||
JDBCsetUp.executeUpdate(sql);
|
|
||||||
} catch (SQLException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ModifyVariable(method = "loadFromNBT*", at = @At("HEAD"), argsOnly = true, name = "arg1")
|
|
||||||
private CompoundTag loadFromNBT$playerSync(CompoundTag value) {
|
|
||||||
String sql = "SELECT inv FROM cobblemon WHERE uuid = '" + this.uuid.toString() + "'";
|
|
||||||
CompoundTag loadedNbt = value;
|
|
||||||
try {
|
|
||||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery(sql);
|
|
||||||
ResultSet rs = qr.resultSet();
|
|
||||||
if (rs.next()) {
|
|
||||||
String serializedData = rs.getString("inv");
|
|
||||||
loadedNbt = TagParser.parseTag(LocalJsonUtil.cleanSnbt(serializedData));
|
|
||||||
}
|
|
||||||
|
|
||||||
rs.close();
|
|
||||||
qr.close();
|
|
||||||
} catch (SQLException | CommandSyntaxException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
return loadedNbt;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
package vip.fubuki.playersync.mixin.cobblemon.accessor;
|
|
||||||
|
|
||||||
import com.cobblemon.mod.common.api.storage.player.PlayerInstancedDataStoreType;
|
|
||||||
import com.cobblemon.mod.common.api.storage.player.adapter.FileBasedPlayerDataStoreBackend;
|
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
|
||||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
|
||||||
|
|
||||||
@Mixin(FileBasedPlayerDataStoreBackend.class)
|
|
||||||
public interface FileBasedPlayerDataStoreBackendAccessor {
|
|
||||||
@Accessor
|
|
||||||
PlayerInstancedDataStoreType getType();
|
|
||||||
}
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
package vip.fubuki.playersync.mixin.cobblemon.accessor;
|
|
||||||
|
|
||||||
import com.cobblemon.mod.common.api.storage.player.InstancedPlayerData;
|
|
||||||
import com.cobblemon.mod.common.api.storage.player.adapter.DexDataNbtBackend;
|
|
||||||
import com.mojang.serialization.Codec;
|
|
||||||
import org.spongepowered.asm.mixin.Mixin;
|
|
||||||
import org.spongepowered.asm.mixin.gen.Accessor;
|
|
||||||
|
|
||||||
@Mixin(DexDataNbtBackend.class)
|
|
||||||
public interface NbtBackedPlayerDataAccessor {
|
|
||||||
@Accessor("codec")
|
|
||||||
Codec<InstancedPlayerData> getCodec();
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
package vip.fubuki.playersync.sync;
|
|
||||||
|
|
||||||
import com.mojang.logging.LogUtils;
|
|
||||||
import net.neoforged.neoforge.common.NeoForge;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import vip.fubuki.playersync.config.JdbcConfig;
|
|
||||||
import vip.fubuki.playersync.sync.chat.ChatSyncClient;
|
|
||||||
import vip.fubuki.playersync.sync.chat.ChatSyncServer;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
public class ChatSync {
|
|
||||||
public static final Logger LOGGER = LogUtils.getLogger();
|
|
||||||
private static ChatSyncServer chatSyncServer;
|
|
||||||
private static ChatSyncClient chatSyncClient;
|
|
||||||
|
|
||||||
public static void register(){
|
|
||||||
if(JdbcConfig.IS_CHAT_SERVER.get()) {
|
|
||||||
LOGGER.info("Trying to setup chat server at port " + JdbcConfig.CHAT_SERVER_PORT.get());
|
|
||||||
new Thread(()->{
|
|
||||||
chatSyncServer = new ChatSyncServer();
|
|
||||||
try {
|
|
||||||
chatSyncServer.run();
|
|
||||||
} catch (IOException e) {
|
|
||||||
LOGGER.error("Unable to start chat server", e);
|
|
||||||
}
|
|
||||||
}, "ChatSync-Server").start();
|
|
||||||
}
|
|
||||||
|
|
||||||
new Thread(()->{
|
|
||||||
try {
|
|
||||||
Thread.sleep(2000);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
|
|
||||||
LOGGER.info("Trying to connect to chat server "
|
|
||||||
+ JdbcConfig.CHAT_SERVER_IP.get()
|
|
||||||
+ ":"
|
|
||||||
+ JdbcConfig.CHAT_SERVER_PORT.get());
|
|
||||||
chatSyncClient = new ChatSyncClient();
|
|
||||||
chatSyncClient.run();
|
|
||||||
}, "ChatSync-Client").start();
|
|
||||||
NeoForge.EVENT_BUS.register(ChatSyncClient.class);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static void shutdown() {
|
|
||||||
if (chatSyncServer != null) {
|
|
||||||
chatSyncServer.shutdown();
|
|
||||||
}
|
|
||||||
if (chatSyncClient != null) {
|
|
||||||
chatSyncClient.shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -49,6 +49,7 @@ import vip.fubuki.playersync.sync.addons.ModsSupport;
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||||
import vip.fubuki.playersync.util.PSThreadPoolFactory;
|
import vip.fubuki.playersync.util.PSThreadPoolFactory;
|
||||||
|
import vip.fubuki.playersync.util.Tables;
|
||||||
|
|
||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
@ -129,7 +130,7 @@ public class VanillaSync {
|
||||||
// Use try-with-resources to prevent connection leaks
|
// Use try-with-resources to prevent connection leaks
|
||||||
String advancementsData;
|
String advancementsData;
|
||||||
try (JDBCsetUp.QueryResult advancementsQuery = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult advancementsQuery = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT advancements FROM player_data WHERE uuid=?", player_uuid)) {
|
"SELECT advancements FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) {
|
||||||
ResultSet advancementsResultSet = advancementsQuery.resultSet();
|
ResultSet advancementsResultSet = advancementsQuery.resultSet();
|
||||||
|
|
||||||
if (!advancementsResultSet.next()) {
|
if (!advancementsResultSet.next()) {
|
||||||
|
|
@ -203,7 +204,7 @@ public class VanillaSync {
|
||||||
|
|
||||||
// First query: check basic player data using prepared statement
|
// First query: check basic player data using prepared statement
|
||||||
try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT online, last_server FROM player_data WHERE uuid=?", player_uuid)) {
|
"SELECT online, last_server FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) {
|
||||||
ResultSet rs1 = qr1.resultSet();
|
ResultSet rs1 = qr1.resultSet();
|
||||||
if (!rs1.next()) {
|
if (!rs1.next()) {
|
||||||
PlayerSync.LOGGER.info("A new-player connection detected");
|
PlayerSync.LOGGER.info("A new-player connection detected");
|
||||||
|
|
@ -219,7 +220,7 @@ public class VanillaSync {
|
||||||
int alreadyKicked = 0;
|
int alreadyKicked = 0;
|
||||||
if (JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get() && online && lastServer != JdbcConfig.SERVER_ID.get()) {
|
if (JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get() && online && lastServer != JdbcConfig.SERVER_ID.get()) {
|
||||||
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT last_update, enable FROM server_info WHERE id=?", lastServer)) {
|
"SELECT last_update, enable FROM " + Tables.serverInfo() + " WHERE id=?", lastServer)) {
|
||||||
ResultSet rs2 = qr2.resultSet();
|
ResultSet rs2 = qr2.resultSet();
|
||||||
if (rs2.next()) {
|
if (rs2.next()) {
|
||||||
long last_update = rs2.getLong("last_update");
|
long last_update = rs2.getLong("last_update");
|
||||||
|
|
@ -229,7 +230,7 @@ public class VanillaSync {
|
||||||
event.getConnection().disconnect(Component.translatableWithFallback("playersync.already_online","You can't join more than one synchronization server at the same time."));
|
event.getConnection().disconnect(Component.translatableWithFallback("playersync.already_online","You can't join more than one synchronization server at the same time."));
|
||||||
alreadyKicked = 1;
|
alreadyKicked = 1;
|
||||||
} else {
|
} else {
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", lastServer);
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE id=?", lastServer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -324,7 +325,7 @@ public class VanillaSync {
|
||||||
// This keeps last_server pointing to the old server so this poll can detect it.
|
// This keeps last_server pointing to the old server so this poll can detect it.
|
||||||
for (int attempt = 0; attempt < 60; attempt++) {
|
for (int attempt = 0; attempt < 60; attempt++) {
|
||||||
try (JDBCsetUp.QueryResult qrCheck = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qrCheck = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT online, last_server FROM player_data WHERE uuid=?", player_uuid)) {
|
"SELECT online, last_server FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) {
|
||||||
ResultSet rsCheck = qrCheck.resultSet();
|
ResultSet rsCheck = qrCheck.resultSet();
|
||||||
if (!rsCheck.next()) break; // new player, nothing pending
|
if (!rsCheck.next()) break; // new player, nothing pending
|
||||||
int otherServer = rsCheck.getInt("last_server");
|
int otherServer = rsCheck.getInt("last_server");
|
||||||
|
|
@ -349,14 +350,14 @@ public class VanillaSync {
|
||||||
// This is safe because: (1) the old server's data+online=0 write already completed,
|
// This is safe because: (1) the old server's data+online=0 write already completed,
|
||||||
// (2) any future writes from the old server will be blocked by AND last_server=?.
|
// (2) any future writes from the old server will be blocked by AND last_server=?.
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"UPDATE player_data SET last_server=? WHERE uuid=?",
|
"UPDATE " + Tables.playerData() + " SET last_server=? WHERE uuid=?",
|
||||||
JdbcConfig.SERVER_ID.get(), player_uuid);
|
JdbcConfig.SERVER_ID.get(), player_uuid);
|
||||||
|
|
||||||
// === PHASE 1: DB reads on background thread (thread-safe) ===
|
// === PHASE 1: DB reads on background thread (thread-safe) ===
|
||||||
|
|
||||||
boolean playerExists;
|
boolean playerExists;
|
||||||
try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr1 = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT uuid FROM player_data WHERE uuid=?", player_uuid)) {
|
"SELECT uuid FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) {
|
||||||
playerExists = qr1.resultSet().next();
|
playerExists = qr1.resultSet().next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -384,7 +385,7 @@ public class VanillaSync {
|
||||||
final String leftHand, cursors, armorData, inventoryData, enderChestData, effectData;
|
final String leftHand, cursors, armorData, inventoryData, enderChestData, effectData;
|
||||||
|
|
||||||
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT * FROM player_data WHERE uuid=?", player_uuid)) {
|
"SELECT * FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) {
|
||||||
ResultSet rs2 = qr2.resultSet();
|
ResultSet rs2 = qr2.resultSet();
|
||||||
if (!rs2.next()) {
|
if (!rs2.next()) {
|
||||||
PlayerSync.LOGGER.warn("No data found for existing player {}", player_uuid);
|
PlayerSync.LOGGER.warn("No data found for existing player {}", player_uuid);
|
||||||
|
|
@ -407,7 +408,7 @@ public class VanillaSync {
|
||||||
final String curiosData;
|
final String curiosData;
|
||||||
if (ModList.get().isLoaded("curios")) {
|
if (ModList.get().isLoaded("curios")) {
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT curios_item FROM curios WHERE uuid=?", player_uuid)) {
|
"SELECT curios_item FROM " + Tables.curios() + " WHERE uuid=?", player_uuid)) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
curiosData = rs.next() ? rs.getString("curios_item") : null;
|
curiosData = rs.next() ? rs.getString("curios_item") : null;
|
||||||
}
|
}
|
||||||
|
|
@ -416,7 +417,7 @@ public class VanillaSync {
|
||||||
final String accessoriesData;
|
final String accessoriesData;
|
||||||
if (ModList.get().isLoaded("accessories")) {
|
if (ModList.get().isLoaded("accessories")) {
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?",
|
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||||
player_uuid, "accessories")) {
|
player_uuid, "accessories")) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
accessoriesData = rs.next() ? rs.getString("data_value") : null;
|
accessoriesData = rs.next() ? rs.getString("data_value") : null;
|
||||||
|
|
@ -426,7 +427,7 @@ public class VanillaSync {
|
||||||
final String cosmeticArmorData;
|
final String cosmeticArmorData;
|
||||||
if (ModList.get().isLoaded("cosmeticarmorreworked")) {
|
if (ModList.get().isLoaded("cosmeticarmorreworked")) {
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?",
|
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||||
player_uuid, "cosmeticarmor")) {
|
player_uuid, "cosmeticarmor")) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
cosmeticArmorData = rs.next() ? rs.getString("data_value") : null;
|
cosmeticArmorData = rs.next() ? rs.getString("data_value") : null;
|
||||||
|
|
@ -435,7 +436,7 @@ public class VanillaSync {
|
||||||
|
|
||||||
final String attachmentsData;
|
final String attachmentsData;
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?",
|
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||||
player_uuid, "neoforge_attachments")) {
|
player_uuid, "neoforge_attachments")) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
attachmentsData = rs.next() ? rs.getString("data_value") : null;
|
attachmentsData = rs.next() ? rs.getString("data_value") : null;
|
||||||
|
|
@ -574,11 +575,15 @@ public class VanillaSync {
|
||||||
int[] cached = connectCheckCache.remove(player_uuid);
|
int[] cached = connectCheckCache.remove(player_uuid);
|
||||||
|
|
||||||
if (!JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get()) {
|
if (!JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get()) {
|
||||||
try {
|
// FIX PERF (C1): online=1 is fire-and-forget; no login-critical decision depends
|
||||||
JDBCsetUp.executePreparedUpdate(
|
// on the write completing synchronously. Keeping this off the main thread saves
|
||||||
"UPDATE player_data SET online=1 WHERE uuid=?",
|
// one MySQL round-trip per join.
|
||||||
player_uuid);
|
executorService.execute(() -> {
|
||||||
} catch (SQLException ignored) {}
|
try {
|
||||||
|
JDBCsetUp.executePreparedUpdate(
|
||||||
|
"UPDATE " + Tables.playerData() + " SET online=1 WHERE uuid=?", player_uuid);
|
||||||
|
} catch (SQLException ignored) {}
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -603,7 +608,7 @@ public class VanillaSync {
|
||||||
boolean online = false;
|
boolean online = false;
|
||||||
int lastServer = 0;
|
int lastServer = 0;
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT online, last_server FROM player_data WHERE uuid=?", player_uuid)) {
|
"SELECT online, last_server FROM " + Tables.playerData() + " WHERE uuid=?", player_uuid)) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
if (rs.next()) {
|
if (rs.next()) {
|
||||||
online = rs.getBoolean("online");
|
online = rs.getBoolean("online");
|
||||||
|
|
@ -612,7 +617,7 @@ public class VanillaSync {
|
||||||
}
|
}
|
||||||
if (online && lastServer != JdbcConfig.SERVER_ID.get()) {
|
if (online && lastServer != JdbcConfig.SERVER_ID.get()) {
|
||||||
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr2 = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT last_update, enable FROM server_info WHERE id=?", lastServer)) {
|
"SELECT last_update, enable FROM " + Tables.serverInfo() + " WHERE id=?", lastServer)) {
|
||||||
ResultSet rs2 = qr2.resultSet();
|
ResultSet rs2 = qr2.resultSet();
|
||||||
if (rs2.next()) {
|
if (rs2.next()) {
|
||||||
long lastUpdate = rs2.getLong("last_update");
|
long lastUpdate = rs2.getLong("last_update");
|
||||||
|
|
@ -624,16 +629,23 @@ public class VanillaSync {
|
||||||
"You can't join more than one synchronization server at the same time."));
|
"You can't join more than one synchronization server at the same time."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", lastServer);
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE id=?", lastServer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark online=1 — only DB call on main thread in the fast path (1 query instead of 4)
|
// FIX PERF (C1): Mark online=1 asynchronously — no main-thread MySQL round-trip.
|
||||||
JDBCsetUp.executePreparedUpdate(
|
// The cache-based kick decision above is already final; this write only updates
|
||||||
"UPDATE player_data SET online=1 WHERE uuid=?",
|
// the persistent flag for cross-server detection, which tolerates a few ms of delay.
|
||||||
player_uuid);
|
executorService.execute(() -> {
|
||||||
|
try {
|
||||||
|
JDBCsetUp.executePreparedUpdate(
|
||||||
|
"UPDATE " + Tables.playerData() + " SET online=1 WHERE uuid=?", player_uuid);
|
||||||
|
} catch (SQLException e) {
|
||||||
|
PlayerSync.LOGGER.error("Async online=1 update failed for {}", player_uuid, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Error during kick check for player {}", player_uuid, e);
|
PlayerSync.LOGGER.error("Error during kick check for player {}", player_uuid, e);
|
||||||
}
|
}
|
||||||
|
|
@ -862,7 +874,7 @@ public class VanillaSync {
|
||||||
// Always update server heartbeat — async, never blocks main thread
|
// Always update server heartbeat — async, never blocks main thread
|
||||||
executorService.submit(() -> {
|
executorService.submit(() -> {
|
||||||
try {
|
try {
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?",
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET last_update=? WHERE id=?",
|
||||||
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
PlayerSync.LOGGER.error("Error updating server heartbeat on SaveToFile", e);
|
PlayerSync.LOGGER.error("Error updating server heartbeat on SaveToFile", e);
|
||||||
|
|
@ -936,7 +948,8 @@ public class VanillaSync {
|
||||||
// === MAIN THREAD: Snapshot (entity reads, fast) ===
|
// === MAIN THREAD: Snapshot (entity reads, fast) ===
|
||||||
final PlayerDataSnapshot snapshot = snapshotPlayerData(player);
|
final PlayerDataSnapshot snapshot = snapshotPlayerData(player);
|
||||||
final Map<UUID, CompoundTag> backpackSnapshots = ModsSupport.snapshotBackpackData(player);
|
final Map<UUID, CompoundTag> backpackSnapshots = ModsSupport.snapshotBackpackData(player);
|
||||||
final List<UUID> ssUuids = ModsSupport.collectSSUuids(player);
|
// FIX C3: snapshot SS CompoundTags on main thread (was a background-thread read).
|
||||||
|
final Map<UUID, CompoundTag> ssSnapshots = ModsSupport.snapshotSSData(ModsSupport.collectSSUuids(player));
|
||||||
final List<UUID> rs2DiskUuids;
|
final List<UUID> rs2DiskUuids;
|
||||||
final ServerLevel rs2Level;
|
final ServerLevel rs2Level;
|
||||||
final HolderLookup.Provider rs2Registry;
|
final HolderLookup.Provider rs2Registry;
|
||||||
|
|
@ -956,7 +969,7 @@ public class VanillaSync {
|
||||||
// FIX ANTI-DUPLICATION: atomic data+online=0 with last_server guard
|
// FIX ANTI-DUPLICATION: atomic data+online=0 with last_server guard
|
||||||
writeSnapshotToDB(snapshot, true);
|
writeSnapshotToDB(snapshot, true);
|
||||||
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
|
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
|
||||||
ModsSupport.saveSSByUuids(ssUuids);
|
ModsSupport.saveSSSnapshots(ssSnapshots);
|
||||||
if (!rs2DiskUuids.isEmpty() && rs2Level != null) {
|
if (!rs2DiskUuids.isEmpty() && rs2Level != null) {
|
||||||
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2Registry);
|
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2Registry);
|
||||||
}
|
}
|
||||||
|
|
@ -965,7 +978,7 @@ public class VanillaSync {
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Error saving player {} on shutdown", puuid, e);
|
PlayerSync.LOGGER.error("Error saving player {} on shutdown", puuid, e);
|
||||||
try {
|
try {
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=? AND last_server=?",
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE uuid=? AND last_server=?",
|
||||||
puuid, JdbcConfig.SERVER_ID.get());
|
puuid, JdbcConfig.SERVER_ID.get());
|
||||||
} catch (Exception e2) {
|
} catch (Exception e2) {
|
||||||
PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline on shutdown", puuid, e2);
|
PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline on shutdown", puuid, e2);
|
||||||
|
|
@ -975,7 +988,7 @@ public class VanillaSync {
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Error snapshotting player {} on shutdown", puuid, e);
|
PlayerSync.LOGGER.error("Error snapshotting player {} on shutdown", puuid, e);
|
||||||
try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=? AND last_server=?", puuid, JdbcConfig.SERVER_ID.get()); }
|
try { JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE uuid=? AND last_server=?", puuid, JdbcConfig.SERVER_ID.get()); }
|
||||||
catch (Exception ignored) {}
|
catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -990,7 +1003,7 @@ public class VanillaSync {
|
||||||
PlayerSync.LOGGER.error("Error waiting for shutdown saves", e);
|
PlayerSync.LOGGER.error("Error waiting for shutdown saves", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET enable=0 WHERE id=?", JdbcConfig.SERVER_ID.get());
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET enable=0 WHERE id=?", JdbcConfig.SERVER_ID.get());
|
||||||
|
|
||||||
// Shut down the background executor — no new tasks after this point
|
// Shut down the background executor — no new tasks after this point
|
||||||
executorService.shutdown();
|
executorService.shutdown();
|
||||||
|
|
@ -1006,6 +1019,10 @@ public class VanillaSync {
|
||||||
// Previously this was in PlayerSync.onServerStopping which could fire BEFORE
|
// Previously this was in PlayerSync.onServerStopping which could fire BEFORE
|
||||||
// this handler, closing the pool while shutdown saves were still running.
|
// this handler, closing the pool while shutdown saves were still running.
|
||||||
JDBCsetUp.shutdownPool();
|
JDBCsetUp.shutdownPool();
|
||||||
|
// FIX REGRESSION: flush+shutdown the dedicated logger here, AFTER all shutdown
|
||||||
|
// saves have logged their completion. Previously SyncLogger.shutdown() fired in
|
||||||
|
// PlayerSync.onServerStopping, dropping every save log entry on the floor.
|
||||||
|
vip.fubuki.playersync.util.SyncLogger.shutdown();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1038,14 +1055,14 @@ public class VanillaSync {
|
||||||
|
|
||||||
if (deadPlayerWhileLogging.remove(player_uuid)) {
|
if (deadPlayerWhileLogging.remove(player_uuid)) {
|
||||||
PlayerSync.LOGGER.warn("A dead or dying player was kicked, uuid: {}", player_uuid);
|
PlayerSync.LOGGER.warn("A dead or dying player was kicked, uuid: {}", player_uuid);
|
||||||
try {
|
// FIX PERF (C1): async — main thread does not wait for MySQL.
|
||||||
// FIX: No last_server guard here. These paths fire before doPlayerJoin sets
|
executorService.execute(() -> {
|
||||||
// last_server, so the guard would fail and online would stay stuck at 1.
|
try {
|
||||||
// Safe because these paths don't write player DATA — just the online flag.
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE uuid=?", player_uuid);
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid);
|
} catch (SQLException e) {
|
||||||
} catch (SQLException e) {
|
PlayerSync.LOGGER.error("Error marking dead player offline: {}", player_uuid, e);
|
||||||
PlayerSync.LOGGER.error("Error marking dead player offline: {}", player_uuid, e);
|
}
|
||||||
}
|
});
|
||||||
syncNotCompletedPlayer.remove(player_uuid);
|
syncNotCompletedPlayer.remove(player_uuid);
|
||||||
removePlayerLock(player_uuid);
|
removePlayerLock(player_uuid);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1054,12 +1071,14 @@ public class VanillaSync {
|
||||||
if (syncNotCompletedPlayer.remove(player_uuid)) {
|
if (syncNotCompletedPlayer.remove(player_uuid)) {
|
||||||
PlayerSync.LOGGER.warn("Player {} logged out with uncompleted sync. Data won't be saved for safety.", player_uuid);
|
PlayerSync.LOGGER.warn("Player {} logged out with uncompleted sync. Data won't be saved for safety.", player_uuid);
|
||||||
SyncLogger.saveSkipped(player_uuid, "LOGOUT", "Sync not completed — data preserved in DB, .dat data discarded");
|
SyncLogger.saveSkipped(player_uuid, "LOGOUT", "Sync not completed — data preserved in DB, .dat data discarded");
|
||||||
try {
|
// FIX PERF (C1): async.
|
||||||
// FIX: No last_server guard — same reason as above.
|
executorService.execute(() -> {
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=?", player_uuid);
|
try {
|
||||||
} catch (SQLException e) {
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE uuid=?", player_uuid);
|
||||||
PlayerSync.LOGGER.error("Error marking unsynced player offline: {}", player_uuid, e);
|
} catch (SQLException e) {
|
||||||
}
|
PlayerSync.LOGGER.error("Error marking unsynced player offline: {}", player_uuid, e);
|
||||||
|
}
|
||||||
|
});
|
||||||
removePlayerLock(player_uuid);
|
removePlayerLock(player_uuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -1068,6 +1087,9 @@ public class VanillaSync {
|
||||||
Player player = event.getEntity();
|
Player player = event.getEntity();
|
||||||
ReentrantLock lock = getPlayerLock(player_uuid);
|
ReentrantLock lock = getPlayerLock(player_uuid);
|
||||||
lock.lock();
|
lock.lock();
|
||||||
|
// Declared outside the try so the outer catch can complete/remove the future
|
||||||
|
// if snapshot capture or task submission fails (see FIX REGRESSION below).
|
||||||
|
CompletableFuture<Void> saveFuture = null;
|
||||||
try {
|
try {
|
||||||
// FIX ANTI-DUPLICATION: Force-close the disconnecting player's container FIRST.
|
// FIX ANTI-DUPLICATION: Force-close the disconnecting player's container FIRST.
|
||||||
// If another player is viewing this player's backpack, the container stays open
|
// If another player is viewing this player's backpack, the container stays open
|
||||||
|
|
@ -1076,15 +1098,36 @@ public class VanillaSync {
|
||||||
if (player instanceof ServerPlayer sp && sp.containerMenu != sp.inventoryMenu) {
|
if (player instanceof ServerPlayer sp && sp.containerMenu != sp.inventoryMenu) {
|
||||||
sp.closeContainer();
|
sp.closeContainer();
|
||||||
}
|
}
|
||||||
// Also close any other player's view of this player's backpack containers
|
// FIX CRITICAL ANTI-DUP: close every other player's container menu if it was
|
||||||
if (player.getServer() != null) {
|
// opened against this disconnecting player's inventory/backpack. If another
|
||||||
for (ServerPlayer other : player.getServer().getPlayerList().getPlayers()) {
|
// player keeps the container open and takes items after our snapshot, those
|
||||||
if (other == player) continue;
|
// items are duplicated (the snapshot contains them, and the other player has them).
|
||||||
if (other.containerMenu != other.inventoryMenu) {
|
// We conservatively close all non-inventory containers referencing this player's
|
||||||
// Close any open container to prevent post-snapshot modifications
|
// inventory slots or any menu whose class name hints at a Sophisticated Backpacks
|
||||||
// This is aggressive but safe — the viewer just sees their inventory close
|
// container. The viewer just sees their GUI close — no data loss.
|
||||||
// TODO: Only close if the container is specifically this player's backpack
|
// FIX COMPAT: Close only containers that actually reference the disconnecting
|
||||||
// For now, closing all is safer than risking duplication
|
// player's inventory/enderchest. Previous version also closed any menu whose
|
||||||
|
// class name contained "accessor"/"curio"/... which could force-close unrelated
|
||||||
|
// mod menus mid-transaction. The slot-reference scan is both correct and safe
|
||||||
|
// across every modded menu.
|
||||||
|
if (player instanceof ServerPlayer disconnecting && disconnecting.getServer() != null) {
|
||||||
|
net.minecraft.world.entity.player.Inventory srcInv = disconnecting.getInventory();
|
||||||
|
net.minecraft.world.SimpleContainer srcEnder = disconnecting.getEnderChestInventory();
|
||||||
|
for (ServerPlayer other : disconnecting.getServer().getPlayerList().getPlayers()) {
|
||||||
|
if (other == disconnecting) continue;
|
||||||
|
net.minecraft.world.inventory.AbstractContainerMenu menu = other.containerMenu;
|
||||||
|
if (menu == other.inventoryMenu) continue;
|
||||||
|
boolean shouldClose = false;
|
||||||
|
try {
|
||||||
|
for (net.minecraft.world.inventory.Slot slot : menu.slots) {
|
||||||
|
if (slot.container == srcInv || slot.container == srcEnder) {
|
||||||
|
shouldClose = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {}
|
||||||
|
if (shouldClose) {
|
||||||
|
try { other.closeContainer(); } catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1098,7 +1141,8 @@ public class VanillaSync {
|
||||||
|
|
||||||
// Collect backpack/SS/RS2 data — snapshots on main thread (no async reads)
|
// Collect backpack/SS/RS2 data — snapshots on main thread (no async reads)
|
||||||
final Map<UUID, CompoundTag> backpackSnapshots = ModsSupport.snapshotBackpackData(player);
|
final Map<UUID, CompoundTag> backpackSnapshots = ModsSupport.snapshotBackpackData(player);
|
||||||
final List<UUID> ssUuids = ModsSupport.collectSSUuids(player);
|
// FIX C3: SS CompoundTags snapshotted on main thread (frozen copies).
|
||||||
|
final Map<UUID, CompoundTag> ssSnapshots = ModsSupport.snapshotSSData(ModsSupport.collectSSUuids(player));
|
||||||
final List<UUID> rs2DiskUuids;
|
final List<UUID> rs2DiskUuids;
|
||||||
final ServerLevel rs2Level;
|
final ServerLevel rs2Level;
|
||||||
final HolderLookup.Provider rs2RegistryAccess;
|
final HolderLookup.Provider rs2RegistryAccess;
|
||||||
|
|
@ -1116,7 +1160,20 @@ public class VanillaSync {
|
||||||
// The online flag stays 1 until the async save completes → kick mechanism
|
// The online flag stays 1 until the async save completes → kick mechanism
|
||||||
// prevents premature rejoin on other servers, and pendingLogoutSaves prevents
|
// prevents premature rejoin on other servers, and pendingLogoutSaves prevents
|
||||||
// premature rejoin on the same server.
|
// premature rejoin on the same server.
|
||||||
CompletableFuture<Void> saveFuture = CompletableFuture.runAsync(() -> {
|
//
|
||||||
|
// FIX CRITICAL RACE (B1): Register the future in pendingLogoutSaves BEFORE
|
||||||
|
// submitting the work. Previously runAsync was submitted first — a fast
|
||||||
|
// reconnect could observe pendingLogoutSaves.get(uuid)==null while the save
|
||||||
|
// was already queued → doPlayerJoin would proceed without waiting.
|
||||||
|
saveFuture = new CompletableFuture<>();
|
||||||
|
pendingLogoutSaves.put(player_uuid, saveFuture);
|
||||||
|
|
||||||
|
final CompletableFuture<Void> futureRef = saveFuture;
|
||||||
|
// FIX REGRESSION: handle RejectedExecutionException if the executor is
|
||||||
|
// already shut down (concurrent with server stop). Without this, the future
|
||||||
|
// stays forever in pendingLogoutSaves and blocks future rejoins for 15s+.
|
||||||
|
try {
|
||||||
|
executorService.execute(() -> {
|
||||||
try {
|
try {
|
||||||
// FIX ANTI-DUPLICATION: writeSnapshotToDB with setOffline=true
|
// FIX ANTI-DUPLICATION: writeSnapshotToDB with setOffline=true
|
||||||
// atomically writes data + online=0 in a SINGLE UPDATE, AND guards
|
// atomically writes data + online=0 in a SINGLE UPDATE, AND guards
|
||||||
|
|
@ -1124,7 +1181,7 @@ public class VanillaSync {
|
||||||
// race where a slow async save overwrites fresher data from another server.
|
// race where a slow async save overwrites fresher data from another server.
|
||||||
writeSnapshotToDB(snapshot, true);
|
writeSnapshotToDB(snapshot, true);
|
||||||
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
|
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
|
||||||
ModsSupport.saveSSByUuids(ssUuids);
|
ModsSupport.saveSSSnapshots(ssSnapshots);
|
||||||
if (!rs2DiskUuids.isEmpty() && rs2Level != null) {
|
if (!rs2DiskUuids.isEmpty() && rs2Level != null) {
|
||||||
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2RegistryAccess);
|
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2RegistryAccess);
|
||||||
}
|
}
|
||||||
|
|
@ -1135,7 +1192,7 @@ public class VanillaSync {
|
||||||
SyncLogger.saveFailed(player_uuid, "LOGOUT", e.getMessage());
|
SyncLogger.saveFailed(player_uuid, "LOGOUT", e.getMessage());
|
||||||
// If the atomic write failed, still try to set online=0
|
// If the atomic write failed, still try to set online=0
|
||||||
try {
|
try {
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=? AND last_server=?",
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE uuid=? AND last_server=?",
|
||||||
player_uuid, JdbcConfig.SERVER_ID.get());
|
player_uuid, JdbcConfig.SERVER_ID.get());
|
||||||
} catch (Exception e2) {
|
} catch (Exception e2) {
|
||||||
PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline", player_uuid, e2);
|
PlayerSync.LOGGER.error("CRITICAL: Failed to mark player {} offline", player_uuid, e2);
|
||||||
|
|
@ -1143,16 +1200,29 @@ public class VanillaSync {
|
||||||
} finally {
|
} finally {
|
||||||
removePlayerLock(player_uuid);
|
removePlayerLock(player_uuid);
|
||||||
pendingLogoutSaves.remove(player_uuid);
|
pendingLogoutSaves.remove(player_uuid);
|
||||||
|
futureRef.complete(null);
|
||||||
}
|
}
|
||||||
}, executorService);
|
});
|
||||||
|
} catch (java.util.concurrent.RejectedExecutionException rex) {
|
||||||
pendingLogoutSaves.put(player_uuid, saveFuture);
|
// Executor is shut down (server stopping, or pool in unusable state) —
|
||||||
|
// drain the future so no join thread is stuck waiting 15 s on .get().
|
||||||
|
PlayerSync.LOGGER.warn("Logout save executor rejected task for player {} (likely shutdown in progress)", player_uuid);
|
||||||
|
pendingLogoutSaves.remove(player_uuid);
|
||||||
|
futureRef.completeExceptionally(rex);
|
||||||
|
removePlayerLock(player_uuid);
|
||||||
|
}
|
||||||
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Error during player logout save for {}", player_uuid, e);
|
PlayerSync.LOGGER.error("Error during player logout save for {}", player_uuid, e);
|
||||||
try { JDBCsetUp.executePreparedUpdate("UPDATE player_data SET online=0 WHERE uuid=? AND last_server=?", player_uuid, JdbcConfig.SERVER_ID.get()); }
|
try { JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.playerData() + " SET online=0 WHERE uuid=? AND last_server=?", player_uuid, JdbcConfig.SERVER_ID.get()); }
|
||||||
catch (Exception ignored) {}
|
catch (Exception ignored) {}
|
||||||
removePlayerLock(player_uuid);
|
removePlayerLock(player_uuid);
|
||||||
|
// FIX REGRESSION: if snapshot failed AFTER pendingLogoutSaves.put, complete
|
||||||
|
// the future so a rejoining doPlayerJoin doesn't hang 15 s on .get().
|
||||||
|
if (saveFuture != null) {
|
||||||
|
pendingLogoutSaves.remove(player_uuid);
|
||||||
|
saveFuture.completeExceptionally(e);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
lock.unlock();
|
lock.unlock();
|
||||||
}
|
}
|
||||||
|
|
@ -1318,12 +1388,12 @@ public class VanillaSync {
|
||||||
// and ALL subsequent writes with AND last_server=? fail silently → player data
|
// and ALL subsequent writes with AND last_server=? fail silently → player data
|
||||||
// is never saved → "players lose everything" on next login.
|
// is never saved → "players lose everything" on next login.
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"INSERT INTO player_data (uuid, armor, inventory, enderchest, advancements, effects, xp, food_level, health, score, left_hand, cursors, online, last_server) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
|
"INSERT INTO " + Tables.playerData() + " (uuid, armor, inventory, enderchest, advancements, effects, xp, food_level, health, score, left_hand, cursors, online, last_server) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
|
||||||
player_uuid, equipment.toString(), inventoryMap.toString(), ender_chest.toString(), json, effectMap.toString(), XP, food_level, health, score, left_hand, cursors, JdbcConfig.SERVER_ID.get());
|
player_uuid, equipment.toString(), inventoryMap.toString(), ender_chest.toString(), json, effectMap.toString(), XP, food_level, health, score, left_hand, cursors, JdbcConfig.SERVER_ID.get());
|
||||||
} else {
|
} else {
|
||||||
// FIX: Use COALESCE for advancements to avoid wiping valid DB data with empty string
|
// FIX: Use COALESCE for advancements to avoid wiping valid DB data with empty string
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=COALESCE(NULLIF(?, ''), advancements), left_hand=?, cursors=? WHERE uuid=?",
|
"UPDATE " + Tables.playerData() + " SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=COALESCE(NULLIF(?, ''), advancements), left_hand=?, cursors=? WHERE uuid=?",
|
||||||
inventoryMap.toString(), equipment.toString(), XP, effectMap.toString(), ender_chest.toString(), score, food_level, health, json, left_hand, cursors, player_uuid);
|
inventoryMap.toString(), equipment.toString(), XP, effectMap.toString(), ender_chest.toString(), score, food_level, health, json, left_hand, cursors, player_uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1452,8 +1522,8 @@ public class VanillaSync {
|
||||||
// Now: 1 connection, 1 commit, automatic rollback on failure.
|
// Now: 1 connection, 1 commit, automatic rollback on failure.
|
||||||
String serverGuard = "(last_server=? OR last_server IS NULL)";
|
String serverGuard = "(last_server=? OR last_server IS NULL)";
|
||||||
String coreSql = setOffline
|
String coreSql = setOffline
|
||||||
? "UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=COALESCE(?, advancements), left_hand=?, cursors=?, online=0, last_server=? WHERE uuid=? AND " + serverGuard
|
? "UPDATE " + Tables.playerData() + " SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=COALESCE(?, advancements), left_hand=?, cursors=?, online=0, last_server=? WHERE uuid=? AND " + serverGuard
|
||||||
: "UPDATE player_data SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=COALESCE(?, advancements), left_hand=?, cursors=?, last_server=? WHERE uuid=? AND " + serverGuard;
|
: "UPDATE " + Tables.playerData() + " SET inventory=?, armor=?, xp=?, effects=?, enderchest=?, score=?, food_level=?, health=?, advancements=COALESCE(?, advancements), left_hand=?, cursors=?, last_server=? WHERE uuid=? AND " + serverGuard;
|
||||||
|
|
||||||
// Build batch of all statements
|
// Build batch of all statements
|
||||||
List<Object[]> batch = new ArrayList<>();
|
List<Object[]> batch = new ArrayList<>();
|
||||||
|
|
@ -1463,13 +1533,13 @@ public class VanillaSync {
|
||||||
s.inventory(), s.equipment(), s.xp(), s.effects(), s.enderChest(), s.score(), s.foodLevel(), s.health(), s.advancements(), s.leftHand(), s.cursors(), serverId, s.uuid(), serverId});
|
s.inventory(), s.equipment(), s.xp(), s.effects(), s.enderChest(), s.score(), s.foodLevel(), s.health(), s.advancements(), s.leftHand(), s.cursors(), serverId, s.uuid(), serverId});
|
||||||
|
|
||||||
// 2. Curios
|
// 2. Curios
|
||||||
String curioGuard = "EXISTS (SELECT 1 FROM player_data WHERE uuid=? AND " + serverGuard + ")";
|
String curioGuard = "EXISTS (SELECT 1 FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard + ")";
|
||||||
if (s.curiosData() != null) {
|
if (s.curiosData() != null) {
|
||||||
batch.add(new Object[]{
|
batch.add(new Object[]{
|
||||||
"UPDATE curios SET curios_item=? WHERE uuid=? AND " + curioGuard,
|
"UPDATE " + Tables.curios() + " SET curios_item=? WHERE uuid=? AND " + curioGuard,
|
||||||
s.curiosData(), s.uuid(), s.uuid(), serverId});
|
s.curiosData(), s.uuid(), s.uuid(), serverId});
|
||||||
batch.add(new Object[]{
|
batch.add(new Object[]{
|
||||||
"INSERT IGNORE INTO curios (uuid, curios_item) SELECT ?, ? FROM player_data WHERE uuid=? AND " + serverGuard,
|
"INSERT IGNORE INTO " + Tables.curios() + " (uuid, curios_item) SELECT ?, ? FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard,
|
||||||
s.uuid(), s.curiosData(), s.uuid(), serverId});
|
s.uuid(), s.curiosData(), s.uuid(), serverId});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1478,17 +1548,27 @@ public class VanillaSync {
|
||||||
addModDataToBatch(batch, s.uuid(), "cosmeticarmor", s.cosmeticArmorData(), serverId, serverGuard);
|
addModDataToBatch(batch, s.uuid(), "cosmeticarmor", s.cosmeticArmorData(), serverId, serverGuard);
|
||||||
addModDataToBatch(batch, s.uuid(), "neoforge_attachments", s.attachmentsData(), serverId, serverGuard);
|
addModDataToBatch(batch, s.uuid(), "neoforge_attachments", s.attachmentsData(), serverId, serverGuard);
|
||||||
|
|
||||||
// Execute all in one transaction
|
// Execute all in one transaction. First statement is the core UPDATE on
|
||||||
JDBCsetUp.executeBatchTransaction(batch.toArray(new Object[0][]));
|
// player_data — if it affects 0 rows, the last_server guard blocked the write
|
||||||
|
// (another server already claimed this player). Logging this is crucial for
|
||||||
|
// diagnosing silent data-loss scenarios that were previously invisible.
|
||||||
|
int[] counts = JDBCsetUp.executeBatchTransaction(batch.toArray(new Object[0][]));
|
||||||
|
if (counts.length > 0 && counts[0] == 0) {
|
||||||
|
SyncLogger.guardBlocked(s.uuid(), serverId,
|
||||||
|
"core UPDATE affected 0 rows — player_data.last_server no longer matches this server or row was removed");
|
||||||
|
PlayerSync.LOGGER.warn(
|
||||||
|
"PlayerSync: core write blocked by last_server guard for {} (server={}). Data was NOT persisted — another server has claimed this player.",
|
||||||
|
s.uuid(), serverId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void addModDataToBatch(List<Object[]> batch, String uuid, String modId, String data, int serverId, String serverGuard) {
|
private static void addModDataToBatch(List<Object[]> batch, String uuid, String modId, String data, int serverId, String serverGuard) {
|
||||||
if (data == null) return;
|
if (data == null) return;
|
||||||
batch.add(new Object[]{
|
batch.add(new Object[]{
|
||||||
"UPDATE mod_player_data SET data_value=? WHERE uuid=? AND mod_id=? AND EXISTS (SELECT 1 FROM player_data WHERE uuid=? AND " + serverGuard + ")",
|
"UPDATE " + Tables.modPlayerData() + " SET data_value=? WHERE uuid=? AND mod_id=? AND EXISTS (SELECT 1 FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard + ")",
|
||||||
data, uuid, modId, uuid, serverId});
|
data, uuid, modId, uuid, serverId});
|
||||||
batch.add(new Object[]{
|
batch.add(new Object[]{
|
||||||
"INSERT IGNORE INTO mod_player_data (uuid, mod_id, data_value) SELECT ?, ?, ? FROM player_data WHERE uuid=? AND " + serverGuard,
|
"INSERT IGNORE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) SELECT ?, ?, ? FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard,
|
||||||
uuid, modId, data, uuid, serverId});
|
uuid, modId, data, uuid, serverId});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1551,7 +1631,7 @@ public class VanillaSync {
|
||||||
heartbeatTickCounter = 0;
|
heartbeatTickCounter = 0;
|
||||||
executorService.submit(() -> {
|
executorService.submit(() -> {
|
||||||
try {
|
try {
|
||||||
JDBCsetUp.executePreparedUpdate("UPDATE server_info SET last_update=? WHERE id=?",
|
JDBCsetUp.executePreparedUpdate("UPDATE " + Tables.serverInfo() + " SET last_update=? WHERE id=?",
|
||||||
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
System.currentTimeMillis(), JdbcConfig.SERVER_ID.get());
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
PlayerSync.LOGGER.error("Error updating server heartbeat", e);
|
PlayerSync.LOGGER.error("Error updating server heartbeat", e);
|
||||||
|
|
@ -1672,8 +1752,13 @@ public class VanillaSync {
|
||||||
return totalXp;
|
return totalXp;
|
||||||
}
|
}
|
||||||
|
|
||||||
@SubscribeEvent
|
// FIX COMPAT (C1): priority=LOW + skip canceled events defends against mods like
|
||||||
|
// Revive Me / Corail Tombstone / Hardcore Revival that cancel LivingDeathEvent at
|
||||||
|
// NORMAL/HIGH priority. At LOW we run after them, and the cancel check short-circuits
|
||||||
|
// the death-save so "fallen" players are not mistakenly treated as dead.
|
||||||
|
@SubscribeEvent(priority = net.neoforged.bus.api.EventPriority.LOW)
|
||||||
public static void onPlayerDeath(LivingDeathEvent event) {
|
public static void onPlayerDeath(LivingDeathEvent event) {
|
||||||
|
if (event.isCanceled()) return;
|
||||||
if (!(event.getEntity() instanceof ServerPlayer player)) return;
|
if (!(event.getEntity() instanceof ServerPlayer player)) return;
|
||||||
String puuid = player.getUUID().toString();
|
String puuid = player.getUUID().toString();
|
||||||
if (deadPlayerWhileLogging.contains(puuid)) return;
|
if (deadPlayerWhileLogging.contains(puuid)) return;
|
||||||
|
|
@ -1696,7 +1781,7 @@ public class VanillaSync {
|
||||||
try {
|
try {
|
||||||
final PlayerDataSnapshot snapshot = snapshotPlayerData(player);
|
final PlayerDataSnapshot snapshot = snapshotPlayerData(player);
|
||||||
final Map<UUID, CompoundTag> backpackSnapshots = ModsSupport.snapshotBackpackData(player);
|
final Map<UUID, CompoundTag> backpackSnapshots = ModsSupport.snapshotBackpackData(player);
|
||||||
final List<UUID> ssUuids = ModsSupport.collectSSUuids(player);
|
final Map<UUID, CompoundTag> ssSnapshots = ModsSupport.snapshotSSData(ModsSupport.collectSSUuids(player));
|
||||||
final List<UUID> rs2DiskUuids;
|
final List<UUID> rs2DiskUuids;
|
||||||
final ServerLevel rs2Level;
|
final ServerLevel rs2Level;
|
||||||
final HolderLookup.Provider rs2Registry;
|
final HolderLookup.Provider rs2Registry;
|
||||||
|
|
@ -1717,7 +1802,7 @@ public class VanillaSync {
|
||||||
try {
|
try {
|
||||||
writeSnapshotToDB(snapshot);
|
writeSnapshotToDB(snapshot);
|
||||||
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
|
ModsSupport.saveBackpackSnapshots(backpackSnapshots);
|
||||||
ModsSupport.saveSSByUuids(ssUuids);
|
ModsSupport.saveSSSnapshots(ssSnapshots);
|
||||||
if (!rs2DiskUuids.isEmpty() && rs2Level != null) {
|
if (!rs2DiskUuids.isEmpty() && rs2Level != null) {
|
||||||
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2Registry);
|
ModsSupport.saveRS2DisksByLevel(rs2DiskUuids, rs2Level, rs2Registry);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -74,8 +74,18 @@ public class CuriosCache {
|
||||||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||||
ItemStack stack = dynStacks.getStackInSlot(i);
|
ItemStack stack = dynStacks.getStackInSlot(i);
|
||||||
if (!stack.isEmpty()) {
|
if (!stack.isEmpty()) {
|
||||||
String serialized = VanillaSync.getNbtForStorage(stack);
|
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||||
flatMap.put(slotType + ":" + i, serialized);
|
}
|
||||||
|
}
|
||||||
|
// FIX A2: capture cosmetic stacks in the death cache, matching the
|
||||||
|
// snapshot/apply format ("cos:slotType:index"). Without this, a player
|
||||||
|
// who died with a cosmetic curio would lose it on rejoin because the
|
||||||
|
// apply path clears cosmetic slots unconditionally.
|
||||||
|
IDynamicStackHandler cosStacks = stacksHandler.getCosmeticStacks();
|
||||||
|
for (int i = 0; i < cosStacks.getSlots(); i++) {
|
||||||
|
ItemStack stack = cosStacks.getStackInSlot(i);
|
||||||
|
if (!stack.isEmpty()) {
|
||||||
|
flatMap.put("cos:" + slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import vip.fubuki.playersync.PlayerSync;
|
||||||
import vip.fubuki.playersync.sync.VanillaSync;
|
import vip.fubuki.playersync.sync.VanillaSync;
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||||
|
import vip.fubuki.playersync.util.Tables;
|
||||||
|
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
|
|
@ -22,6 +23,29 @@ import java.util.Map;
|
||||||
*/
|
*/
|
||||||
public class ModCompatSync {
|
public class ModCompatSync {
|
||||||
|
|
||||||
|
// FIX PERF (C4): Cache reflection Method lookups for NeoForge AttachmentHolder.
|
||||||
|
// Previously resolved on every snapshot/apply (35 players × auto-save = thousands of
|
||||||
|
// reflective lookups / hour). Static-init once, reuse forever.
|
||||||
|
private static final java.lang.reflect.Method SERIALIZE_ATTACHMENTS;
|
||||||
|
private static final java.lang.reflect.Method DESERIALIZE_ATTACHMENTS;
|
||||||
|
static {
|
||||||
|
java.lang.reflect.Method ser = null, des = null;
|
||||||
|
try {
|
||||||
|
ser = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
||||||
|
.getDeclaredMethod("serializeAttachments", net.minecraft.core.HolderLookup.Provider.class);
|
||||||
|
ser.setAccessible(true);
|
||||||
|
des = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
||||||
|
.getDeclaredMethod("deserializeAttachments",
|
||||||
|
net.minecraft.core.HolderLookup.Provider.class,
|
||||||
|
net.minecraft.nbt.CompoundTag.class);
|
||||||
|
des.setAccessible(true);
|
||||||
|
} catch (NoSuchMethodException e) {
|
||||||
|
PlayerSync.LOGGER.error("[PlayerSync] Could not cache AttachmentHolder reflection methods; NeoForge attachment sync will be disabled.", e);
|
||||||
|
}
|
||||||
|
SERIALIZE_ATTACHMENTS = ser;
|
||||||
|
DESERIALIZE_ATTACHMENTS = des;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================
|
// ============================
|
||||||
// Accessories API (Aether slots)
|
// Accessories API (Aether slots)
|
||||||
// ============================
|
// ============================
|
||||||
|
|
@ -58,7 +82,7 @@ public class ModCompatSync {
|
||||||
|
|
||||||
String serializedData = flatMap.toString();
|
String serializedData = flatMap.toString();
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||||
player.getUUID().toString(), "accessories", serializedData);
|
player.getUUID().toString(), "accessories", serializedData);
|
||||||
PlayerSync.LOGGER.debug("Saved Accessories data for player {}", player.getUUID());
|
PlayerSync.LOGGER.debug("Saved Accessories data for player {}", player.getUUID());
|
||||||
|
|
||||||
|
|
@ -84,7 +108,7 @@ public class ModCompatSync {
|
||||||
|
|
||||||
String accessoriesData;
|
String accessoriesData;
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?",
|
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||||
player.getUUID().toString(), "accessories")) {
|
player.getUUID().toString(), "accessories")) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
if (!rs.next()) {
|
if (!rs.next()) {
|
||||||
|
|
@ -232,7 +256,7 @@ public class ModCompatSync {
|
||||||
|
|
||||||
String serializedData = flatMap.toString();
|
String serializedData = flatMap.toString();
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||||
player.getUUID().toString(), "cosmeticarmor", serializedData);
|
player.getUUID().toString(), "cosmeticarmor", serializedData);
|
||||||
PlayerSync.LOGGER.debug("Saved CosmeticArmor data for player {}", player.getUUID());
|
PlayerSync.LOGGER.debug("Saved CosmeticArmor data for player {}", player.getUUID());
|
||||||
|
|
||||||
|
|
@ -257,7 +281,7 @@ public class ModCompatSync {
|
||||||
|
|
||||||
String cosmeticData;
|
String cosmeticData;
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?",
|
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||||
player.getUUID().toString(), "cosmeticarmor")) {
|
player.getUUID().toString(), "cosmeticarmor")) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
if (!rs.next()) {
|
if (!rs.next()) {
|
||||||
|
|
@ -364,19 +388,15 @@ public class ModCompatSync {
|
||||||
public static void storeNeoForgeAttachments(Player player) {
|
public static void storeNeoForgeAttachments(Player player) {
|
||||||
try {
|
try {
|
||||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
||||||
|
if (SERIALIZE_ATTACHMENTS == null) return;
|
||||||
|
|
||||||
// FIX: Use serializeAttachments(Provider) directly instead of saveWithoutId()
|
|
||||||
// This is the exact method NeoForge uses to save attachments, no full player save needed
|
|
||||||
java.lang.reflect.Method serializeMethod = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
|
||||||
.getDeclaredMethod("serializeAttachments", net.minecraft.core.HolderLookup.Provider.class);
|
|
||||||
serializeMethod.setAccessible(true);
|
|
||||||
net.minecraft.nbt.CompoundTag attachments = (net.minecraft.nbt.CompoundTag)
|
net.minecraft.nbt.CompoundTag attachments = (net.minecraft.nbt.CompoundTag)
|
||||||
serializeMethod.invoke(player, serverPlayer.getServer().registryAccess());
|
SERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess());
|
||||||
|
|
||||||
if (attachments != null && !attachments.isEmpty()) {
|
if (attachments != null && !attachments.isEmpty()) {
|
||||||
String serialized = VanillaSync.serializeTagToBinaryBase64(attachments);
|
String serialized = VanillaSync.serializeTagToBinaryBase64(attachments);
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||||
player.getUUID().toString(), "neoforge_attachments", serialized);
|
player.getUUID().toString(), "neoforge_attachments", serialized);
|
||||||
PlayerSync.LOGGER.debug("Saved NeoForge attachments for player {} ({} keys)",
|
PlayerSync.LOGGER.debug("Saved NeoForge attachments for player {} ({} keys)",
|
||||||
player.getUUID(), attachments.getAllKeys().size());
|
player.getUUID(), attachments.getAllKeys().size());
|
||||||
|
|
@ -398,10 +418,11 @@ public class ModCompatSync {
|
||||||
public static void restoreNeoForgeAttachments(Player player) {
|
public static void restoreNeoForgeAttachments(Player player) {
|
||||||
try {
|
try {
|
||||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
||||||
|
if (DESERIALIZE_ATTACHMENTS == null) return;
|
||||||
|
|
||||||
String serialized;
|
String serialized;
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT data_value FROM mod_player_data WHERE uuid=? AND mod_id=?",
|
"SELECT data_value FROM " + Tables.modPlayerData() + " WHERE uuid=? AND mod_id=?",
|
||||||
player.getUUID().toString(), "neoforge_attachments")) {
|
player.getUUID().toString(), "neoforge_attachments")) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
if (!rs.next()) return;
|
if (!rs.next()) return;
|
||||||
|
|
@ -413,17 +434,10 @@ public class ModCompatSync {
|
||||||
net.minecraft.nbt.CompoundTag attachments = VanillaSync.deserializeBinaryBase64Tag(serialized);
|
net.minecraft.nbt.CompoundTag attachments = VanillaSync.deserializeBinaryBase64Tag(serialized);
|
||||||
if (attachments.isEmpty()) return;
|
if (attachments.isEmpty()) return;
|
||||||
|
|
||||||
// FIX: Correct method signature is (HolderLookup.Provider, CompoundTag), not (CompoundTag)
|
|
||||||
// The wrapper must contain the "neoforge:attachments" key for the method to find the data
|
|
||||||
net.minecraft.nbt.CompoundTag wrapper = new net.minecraft.nbt.CompoundTag();
|
net.minecraft.nbt.CompoundTag wrapper = new net.minecraft.nbt.CompoundTag();
|
||||||
wrapper.put("neoforge:attachments", attachments);
|
wrapper.put("neoforge:attachments", attachments);
|
||||||
|
|
||||||
java.lang.reflect.Method deserializeMethod = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
DESERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess(), wrapper);
|
||||||
.getDeclaredMethod("deserializeAttachments",
|
|
||||||
net.minecraft.core.HolderLookup.Provider.class,
|
|
||||||
net.minecraft.nbt.CompoundTag.class);
|
|
||||||
deserializeMethod.setAccessible(true);
|
|
||||||
deserializeMethod.invoke(player, serverPlayer.getServer().registryAccess(), wrapper);
|
|
||||||
|
|
||||||
PlayerSync.LOGGER.info("Restored NeoForge attachments for player {} ({} keys)",
|
PlayerSync.LOGGER.info("Restored NeoForge attachments for player {} ({} keys)",
|
||||||
player.getUUID(), attachments.getAllKeys().size());
|
player.getUUID(), attachments.getAllKeys().size());
|
||||||
|
|
@ -437,6 +451,7 @@ public class ModCompatSync {
|
||||||
*/
|
*/
|
||||||
public static void applyAttachmentsFromData(Player player, String serialized) {
|
public static void applyAttachmentsFromData(Player player, String serialized) {
|
||||||
if (serialized == null || !serialized.startsWith("BNBT:")) return;
|
if (serialized == null || !serialized.startsWith("BNBT:")) return;
|
||||||
|
if (DESERIALIZE_ATTACHMENTS == null) return;
|
||||||
try {
|
try {
|
||||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return;
|
||||||
|
|
||||||
|
|
@ -446,12 +461,7 @@ public class ModCompatSync {
|
||||||
net.minecraft.nbt.CompoundTag wrapper = new net.minecraft.nbt.CompoundTag();
|
net.minecraft.nbt.CompoundTag wrapper = new net.minecraft.nbt.CompoundTag();
|
||||||
wrapper.put("neoforge:attachments", attachments);
|
wrapper.put("neoforge:attachments", attachments);
|
||||||
|
|
||||||
java.lang.reflect.Method deserializeMethod = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
DESERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess(), wrapper);
|
||||||
.getDeclaredMethod("deserializeAttachments",
|
|
||||||
net.minecraft.core.HolderLookup.Provider.class,
|
|
||||||
net.minecraft.nbt.CompoundTag.class);
|
|
||||||
deserializeMethod.setAccessible(true);
|
|
||||||
deserializeMethod.invoke(player, serverPlayer.getServer().registryAccess(), wrapper);
|
|
||||||
|
|
||||||
PlayerSync.LOGGER.info("Applied NeoForge attachments for player {} ({} keys)",
|
PlayerSync.LOGGER.info("Applied NeoForge attachments for player {} ({} keys)",
|
||||||
player.getUUID(), attachments.getAllKeys().size());
|
player.getUUID(), attachments.getAllKeys().size());
|
||||||
|
|
@ -475,6 +485,9 @@ public class ModCompatSync {
|
||||||
try {
|
try {
|
||||||
io.wispforest.accessories.api.AccessoriesCapability cap =
|
io.wispforest.accessories.api.AccessoriesCapability cap =
|
||||||
io.wispforest.accessories.api.AccessoriesCapability.get(player);
|
io.wispforest.accessories.api.AccessoriesCapability.get(player);
|
||||||
|
// FIX ANTI-LOSS (A2): cap==null means the capability isn't attached yet —
|
||||||
|
// return null to SKIP write and preserve DB. Do NOT return "{}" here, as that
|
||||||
|
// would wipe a legitimate accessories record.
|
||||||
if (cap == null) return null;
|
if (cap == null) return null;
|
||||||
Map<String, String> flatMap = new HashMap<>();
|
Map<String, String> flatMap = new HashMap<>();
|
||||||
for (Map.Entry<String, io.wispforest.accessories.api.AccessoriesContainer> entry : cap.getContainers().entrySet()) {
|
for (Map.Entry<String, io.wispforest.accessories.api.AccessoriesContainer> entry : cap.getContainers().entrySet()) {
|
||||||
|
|
@ -487,9 +500,7 @@ public class ModCompatSync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// FIX ANTI-DUPLICATION: Return "{}" for empty slots, NOT null.
|
// Cap read OK — "{}" is intentional for truly empty slots so apply clears stale .dat.
|
||||||
// Null causes writeModSnapshot to SKIP the write, keeping stale data in DB.
|
|
||||||
// "{}" is written to DB, and on restore applyAccessoriesFromData clears slots.
|
|
||||||
return flatMap.toString();
|
return flatMap.toString();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Error snapshotting Accessories for player {}", player.getUUID(), e);
|
PlayerSync.LOGGER.error("Error snapshotting Accessories for player {}", player.getUUID(), e);
|
||||||
|
|
@ -506,6 +517,7 @@ public class ModCompatSync {
|
||||||
try {
|
try {
|
||||||
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
|
lain.mods.cos.impl.inventory.InventoryCosArmor cosInv =
|
||||||
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());
|
lain.mods.cos.impl.ModObjects.invMan.getCosArmorInventory(player.getUUID());
|
||||||
|
// FIX ANTI-LOSS (A2): null manager → cannot read → SKIP write, preserve DB.
|
||||||
if (cosInv == null) return null;
|
if (cosInv == null) return null;
|
||||||
Map<Integer, String> flatMap = new HashMap<>();
|
Map<Integer, String> flatMap = new HashMap<>();
|
||||||
for (int i = 0; i < cosInv.getContainerSize(); i++) {
|
for (int i = 0; i < cosInv.getContainerSize(); i++) {
|
||||||
|
|
@ -514,9 +526,7 @@ public class ModCompatSync {
|
||||||
flatMap.put(i, VanillaSync.getNbtForStorage(stack));
|
flatMap.put(i, VanillaSync.getNbtForStorage(stack));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// FIX ANTI-DUPLICATION: Return "{}" for empty slots, NOT null.
|
// Read OK — "{}" for truly empty slots so apply clears stale .dat.
|
||||||
// Null causes writeModSnapshot to SKIP the write, keeping stale data in DB.
|
|
||||||
// "{}" is written to DB, and on restore applyCosmeticArmorFromData clears slots.
|
|
||||||
return flatMap.toString();
|
return flatMap.toString();
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Error snapshotting CosmeticArmor for player {}", player.getUUID(), e);
|
PlayerSync.LOGGER.error("Error snapshotting CosmeticArmor for player {}", player.getUUID(), e);
|
||||||
|
|
@ -529,13 +539,11 @@ public class ModCompatSync {
|
||||||
* Returns BNBT-serialized string or null if no data.
|
* Returns BNBT-serialized string or null if no data.
|
||||||
*/
|
*/
|
||||||
public static String snapshotAttachments(Player player) {
|
public static String snapshotAttachments(Player player) {
|
||||||
|
if (SERIALIZE_ATTACHMENTS == null) return null;
|
||||||
try {
|
try {
|
||||||
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return null;
|
if (!(player instanceof net.minecraft.server.level.ServerPlayer serverPlayer)) return null;
|
||||||
java.lang.reflect.Method serializeMethod = net.neoforged.neoforge.attachment.AttachmentHolder.class
|
|
||||||
.getDeclaredMethod("serializeAttachments", net.minecraft.core.HolderLookup.Provider.class);
|
|
||||||
serializeMethod.setAccessible(true);
|
|
||||||
net.minecraft.nbt.CompoundTag attachments = (net.minecraft.nbt.CompoundTag)
|
net.minecraft.nbt.CompoundTag attachments = (net.minecraft.nbt.CompoundTag)
|
||||||
serializeMethod.invoke(player, serverPlayer.getServer().registryAccess());
|
SERIALIZE_ATTACHMENTS.invoke(player, serverPlayer.getServer().registryAccess());
|
||||||
if (attachments == null || attachments.isEmpty()) return null;
|
if (attachments == null || attachments.isEmpty()) return null;
|
||||||
return VanillaSync.serializeTagToBinaryBase64(attachments);
|
return VanillaSync.serializeTagToBinaryBase64(attachments);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
|
|
@ -575,17 +583,17 @@ public class ModCompatSync {
|
||||||
public static void writeModSnapshot(String uuid, String accessoriesData, String cosmeticArmor, String attachments) throws SQLException {
|
public static void writeModSnapshot(String uuid, String accessoriesData, String cosmeticArmor, String attachments) throws SQLException {
|
||||||
if (accessoriesData != null) {
|
if (accessoriesData != null) {
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||||
uuid, "accessories", accessoriesData);
|
uuid, "accessories", accessoriesData);
|
||||||
}
|
}
|
||||||
if (cosmeticArmor != null) {
|
if (cosmeticArmor != null) {
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||||
uuid, "cosmeticarmor", cosmeticArmor);
|
uuid, "cosmeticarmor", cosmeticArmor);
|
||||||
}
|
}
|
||||||
if (attachments != null) {
|
if (attachments != null) {
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO mod_player_data (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
"REPLACE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) VALUES (?, ?, ?)",
|
||||||
uuid, "neoforge_attachments", attachments);
|
uuid, "neoforge_attachments", attachments);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -595,11 +603,11 @@ public class ModCompatSync {
|
||||||
String serverGuard = "(last_server=? OR last_server IS NULL)";
|
String serverGuard = "(last_server=? OR last_server IS NULL)";
|
||||||
// Update existing row only if this server still owns the player
|
// Update existing row only if this server still owns the player
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"UPDATE mod_player_data SET data_value=? WHERE uuid=? AND mod_id=? AND EXISTS (SELECT 1 FROM player_data WHERE uuid=? AND " + serverGuard + ")",
|
"UPDATE " + Tables.modPlayerData() + " SET data_value=? WHERE uuid=? AND mod_id=? AND EXISTS (SELECT 1 FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard + ")",
|
||||||
data, uuid, modId, uuid, serverId);
|
data, uuid, modId, uuid, serverId);
|
||||||
// Insert if row doesn't exist yet (first save)
|
// Insert if row doesn't exist yet (first save)
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"INSERT IGNORE INTO mod_player_data (uuid, mod_id, data_value) SELECT ?, ?, ? FROM player_data WHERE uuid=? AND " + serverGuard,
|
"INSERT IGNORE INTO " + Tables.modPlayerData() + " (uuid, mod_id, data_value) SELECT ?, ?, ? FROM " + Tables.playerData() + " WHERE uuid=? AND " + serverGuard,
|
||||||
uuid, modId, data, uuid, serverId);
|
uuid, modId, data, uuid, serverId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import vip.fubuki.playersync.PlayerSync;
|
||||||
import vip.fubuki.playersync.sync.VanillaSync;
|
import vip.fubuki.playersync.sync.VanillaSync;
|
||||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||||
|
import vip.fubuki.playersync.util.Tables;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
|
|
@ -54,7 +55,15 @@ public class ModsSupport {
|
||||||
if (uuidOpt.isPresent()) {
|
if (uuidOpt.isPresent()) {
|
||||||
UUID contentsUuid = uuidOpt.get();
|
UUID contentsUuid = uuidOpt.get();
|
||||||
restoreStorageContents(contentsUuid, (nbt) -> {
|
restoreStorageContents(contentsUuid, (nbt) -> {
|
||||||
net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, nbt);
|
// ROOT CAUSE FIX — BackpackStorage.setBackpackContents() upstream is a
|
||||||
|
// shallow MERGE, not a replace, when the UUID already exists. On any
|
||||||
|
// server that previously loaded this backpack (re-join, multi-world,
|
||||||
|
// .dat persisted), old sub-tags survive the "restore" → duplication.
|
||||||
|
// Removing first guarantees a clean replace.
|
||||||
|
net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage store =
|
||||||
|
net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get();
|
||||||
|
try { store.removeBackpackContents(contentsUuid); } catch (Throwable ignored) {}
|
||||||
|
store.setBackpackContents(contentsUuid, nbt);
|
||||||
PlayerSync.LOGGER.info("Restored backpack data for UUID {}", contentsUuid);
|
PlayerSync.LOGGER.info("Restored backpack data for UUID {}", contentsUuid);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +76,7 @@ public class ModsSupport {
|
||||||
*/
|
*/
|
||||||
private static void restoreStorageContents(UUID contentsUuid, StorageRestoreCallback callback) {
|
private static void restoreStorageContents(UUID contentsUuid, StorageRestoreCallback callback) {
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT backpack_nbt FROM backpack_data WHERE uuid=?", contentsUuid.toString())) {
|
"SELECT backpack_nbt FROM " + Tables.backpackData() + " WHERE uuid=?", contentsUuid.toString())) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
if (rs.next()) {
|
if (rs.next()) {
|
||||||
String serialized = rs.getString("backpack_nbt");
|
String serialized = rs.getString("backpack_nbt");
|
||||||
|
|
@ -120,7 +129,7 @@ public class ModsSupport {
|
||||||
// containers, causing item duplication on the next login.
|
// containers, causing item duplication on the next login.
|
||||||
if (nbt == null || nbt.isEmpty()) {
|
if (nbt == null || nbt.isEmpty()) {
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT LENGTH(backpack_nbt) AS len FROM backpack_data WHERE uuid=?", contentsUuid.toString())) {
|
"SELECT LENGTH(backpack_nbt) AS len FROM " + Tables.backpackData() + " WHERE uuid=?", contentsUuid.toString())) {
|
||||||
java.sql.ResultSet rs = qr.resultSet();
|
java.sql.ResultSet rs = qr.resultSet();
|
||||||
if (rs.next() && rs.getInt("len") > 50) {
|
if (rs.next() && rs.getInt("len") > 50) {
|
||||||
PlayerSync.LOGGER.debug("Skipping save of empty NBT for UUID {} - DB has {} bytes of real data",
|
PlayerSync.LOGGER.debug("Skipping save of empty NBT for UUID {} - DB has {} bytes of real data",
|
||||||
|
|
@ -132,8 +141,15 @@ public class ModsSupport {
|
||||||
|
|
||||||
String serialized = VanillaSync.serializeTagToBinaryBase64(nbt);
|
String serialized = VanillaSync.serializeTagToBinaryBase64(nbt);
|
||||||
try {
|
try {
|
||||||
|
// FIX INTEGRITY (E): REPLACE INTO silently overwrote backpack rows even when
|
||||||
|
// another server had already claimed the owning player. We cannot easily
|
||||||
|
// add a last_server guard to backpack_data directly (it is keyed by
|
||||||
|
// storage UUID, not player UUID — no link to player_data). So we keep the
|
||||||
|
// REPLACE here but expect upper layers (`saveBackpackSnapshots`) to be called
|
||||||
|
// only after the player_data transaction commit has run under the last_server
|
||||||
|
// guard, which is the case in writeSnapshotToDB's caller chain.
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO backpack_data (uuid, backpack_nbt) VALUES (?, ?)",
|
"REPLACE INTO " + Tables.backpackData() + " (uuid, backpack_nbt) VALUES (?, ?)",
|
||||||
contentsUuid.toString(), serialized);
|
contentsUuid.toString(), serialized);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
PlayerSync.LOGGER.error("Error saving storage data for UUID {}", contentsUuid, e);
|
PlayerSync.LOGGER.error("Error saving storage data for UUID {}", contentsUuid, e);
|
||||||
|
|
@ -156,7 +172,7 @@ public class ModsSupport {
|
||||||
|
|
||||||
String curiosData;
|
String curiosData;
|
||||||
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
try (JDBCsetUp.QueryResult qr = JDBCsetUp.executePreparedQuery(
|
||||||
"SELECT curios_item FROM curios WHERE uuid=?", player.getUUID().toString())) {
|
"SELECT curios_item FROM " + Tables.curios() + " WHERE uuid=?", player.getUUID().toString())) {
|
||||||
ResultSet rs = qr.resultSet();
|
ResultSet rs = qr.resultSet();
|
||||||
if (!rs.next()) {
|
if (!rs.next()) {
|
||||||
// No stored data; perform an initial save.
|
// No stored data; perform an initial save.
|
||||||
|
|
@ -168,13 +184,17 @@ public class ModsSupport {
|
||||||
|
|
||||||
ICuriosItemHandler handler = handlerOpt.get();
|
ICuriosItemHandler handler = handlerOpt.get();
|
||||||
|
|
||||||
// FIX ANTI-DUPLICATION: ALWAYS clear curios slots first to wipe stale data
|
// FIX A2/A3: clear BOTH functional and cosmetic slots first to wipe stale .dat
|
||||||
// loaded from Minecraft's .dat file, then only restore if DB has valid data.
|
// data, then restore from DB if valid.
|
||||||
handler.getCurios().forEach((slotType, stacksHandler) -> {
|
handler.getCurios().forEach((slotType, stacksHandler) -> {
|
||||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
||||||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||||
dynStacks.setStackInSlot(i, ItemStack.EMPTY);
|
dynStacks.setStackInSlot(i, ItemStack.EMPTY);
|
||||||
}
|
}
|
||||||
|
IDynamicStackHandler cos = stacksHandler.getCosmeticStacks();
|
||||||
|
for (int i = 0; i < cos.getSlots(); i++) {
|
||||||
|
cos.setStackInSlot(i, ItemStack.EMPTY);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (curiosData == null || curiosData.length() <= 2) {
|
if (curiosData == null || curiosData.length() <= 2) {
|
||||||
|
|
@ -188,16 +208,19 @@ public class ModsSupport {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore each saved item
|
// Restore each saved item. Support both new "cos:slotType:index" cosmetic keys
|
||||||
|
// and legacy "slotType:index" functional-only keys.
|
||||||
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
||||||
String compositeKey = entry.getKey();
|
String compositeKey = entry.getKey();
|
||||||
int lastColon = compositeKey.lastIndexOf(':');
|
boolean cosmetic = compositeKey.startsWith("cos:");
|
||||||
|
String remaining = cosmetic ? compositeKey.substring(4) : compositeKey;
|
||||||
|
int lastColon = remaining.lastIndexOf(':');
|
||||||
if (lastColon < 0) continue;
|
if (lastColon < 0) continue;
|
||||||
|
|
||||||
String slotType = compositeKey.substring(0, lastColon);
|
String slotType = remaining.substring(0, lastColon);
|
||||||
int slotIndex;
|
int slotIndex;
|
||||||
try {
|
try {
|
||||||
slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1));
|
slotIndex = Integer.parseInt(remaining.substring(lastColon + 1));
|
||||||
} catch (NumberFormatException ex) {
|
} catch (NumberFormatException ex) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -207,7 +230,9 @@ public class ModsSupport {
|
||||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(serialized);
|
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(serialized);
|
||||||
if (handler.getCurios().containsKey(slotType)) {
|
if (handler.getCurios().containsKey(slotType)) {
|
||||||
ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType);
|
ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType);
|
||||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
IDynamicStackHandler dynStacks = cosmetic
|
||||||
|
? stacksHandler.getCosmeticStacks()
|
||||||
|
: stacksHandler.getStacks();
|
||||||
if (slotIndex < dynStacks.getSlots()) {
|
if (slotIndex < dynStacks.getSlots()) {
|
||||||
dynStacks.setStackInSlot(slotIndex, stack);
|
dynStacks.setStackInSlot(slotIndex, stack);
|
||||||
}
|
}
|
||||||
|
|
@ -244,7 +269,7 @@ public class ModsSupport {
|
||||||
// Use cached data from death event
|
// Use cached data from death event
|
||||||
PlayerSync.LOGGER.info("Using cached curios data for dead player {}", playerUuid);
|
PlayerSync.LOGGER.info("Using cached curios data for dead player {}", playerUuid);
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
|
"REPLACE INTO " + Tables.curios() + " (uuid, curios_item) VALUES (?, ?)",
|
||||||
playerUuid.toString(), cached.serializedData);
|
playerUuid.toString(), cached.serializedData);
|
||||||
CuriosCache.curiosCache.remove(playerUuid);
|
CuriosCache.curiosCache.remove(playerUuid);
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -260,17 +285,36 @@ public class ModsSupport {
|
||||||
public static String snapshotCuriosData(Player player) {
|
public static String snapshotCuriosData(Player player) {
|
||||||
if (!ModList.get().isLoaded("curios")) return null;
|
if (!ModList.get().isLoaded("curios")) return null;
|
||||||
Optional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
Optional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||||
|
// FIX ANTI-LOSS (A2): if the handler could not be resolved (capability not yet
|
||||||
|
// attached, or Curios mod issue), return null so writeSnapshotToDB SKIPS the write
|
||||||
|
// and preserves whatever data is already in DB. Returning "{}" here would overwrite
|
||||||
|
// a legitimate curios record with an empty one and destroy the player's items.
|
||||||
|
if (handlerOpt.isEmpty()) {
|
||||||
|
PlayerSync.LOGGER.warn("Curios handler unavailable while snapshotting {} — skipping curios write", player.getUUID());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Map<String, String> flatMap = new HashMap<>();
|
Map<String, String> flatMap = new HashMap<>();
|
||||||
handlerOpt.ifPresent(handler -> {
|
ICuriosItemHandler handler = handlerOpt.get();
|
||||||
handler.getCurios().forEach((slotType, stacksHandler) -> {
|
// FIX DATA-LOSS (A2): sync BOTH functional stacks and cosmetic stacks. The prior
|
||||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
// implementation only captured getStacks() → every cosmetic item equipped in a
|
||||||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
// Curios cosmetic slot was silently wiped across server transfers. Cosmetic slots
|
||||||
ItemStack stack = dynStacks.getStackInSlot(i);
|
// are identified by the "cos:" prefix in the composite key so apply/clear can
|
||||||
if (!stack.isEmpty()) {
|
// distinguish them without a schema change.
|
||||||
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
handler.getCurios().forEach((slotType, stacksHandler) -> {
|
||||||
}
|
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
||||||
|
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||||
|
ItemStack stack = dynStacks.getStackInSlot(i);
|
||||||
|
if (!stack.isEmpty()) {
|
||||||
|
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
|
IDynamicStackHandler cosStacks = stacksHandler.getCosmeticStacks();
|
||||||
|
for (int i = 0; i < cosStacks.getSlots(); i++) {
|
||||||
|
ItemStack stack = cosStacks.getStackInSlot(i);
|
||||||
|
if (!stack.isEmpty()) {
|
||||||
|
flatMap.put("cos:" + slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return flatMap.toString();
|
return flatMap.toString();
|
||||||
}
|
}
|
||||||
|
|
@ -290,14 +334,19 @@ public class ModsSupport {
|
||||||
|
|
||||||
ICuriosItemHandler handler = handlerOpt.get();
|
ICuriosItemHandler handler = handlerOpt.get();
|
||||||
|
|
||||||
// FIX ANTI-DUPLICATION: ALWAYS clear curios slots first, even when DB data is
|
// FIX ANTI-DUPLICATION (A2+A3): clear BOTH functional and cosmetic stacks first,
|
||||||
// empty. Without this, stale curios loaded from Minecraft's .dat file (world save)
|
// even when DB data is empty. Without this, stale curios loaded from the .dat
|
||||||
// persist when the DB has no curios data — causing item duplication across servers.
|
// persist when the DB has no entry → dup across servers. Cosmetic stacks also
|
||||||
|
// needed clearing or cosmetic-dup persisted asymmetrically.
|
||||||
for (Map.Entry<String, ICurioStacksHandler> entry : handler.getCurios().entrySet()) {
|
for (Map.Entry<String, ICurioStacksHandler> entry : handler.getCurios().entrySet()) {
|
||||||
IDynamicStackHandler stacks = entry.getValue().getStacks();
|
IDynamicStackHandler stacks = entry.getValue().getStacks();
|
||||||
for (int i = 0; i < stacks.getSlots(); i++) {
|
for (int i = 0; i < stacks.getSlots(); i++) {
|
||||||
stacks.setStackInSlot(i, ItemStack.EMPTY);
|
stacks.setStackInSlot(i, ItemStack.EMPTY);
|
||||||
}
|
}
|
||||||
|
IDynamicStackHandler cos = entry.getValue().getCosmeticStacks();
|
||||||
|
for (int i = 0; i < cos.getSlots(); i++) {
|
||||||
|
cos.setStackInSlot(i, ItemStack.EMPTY);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no data to restore, we're done (slots already cleared above)
|
// If no data to restore, we're done (slots already cleared above)
|
||||||
|
|
@ -306,27 +355,32 @@ public class ModsSupport {
|
||||||
Map<String, String> storedMap = LocalJsonUtil.StringToMap(curiosData);
|
Map<String, String> storedMap = LocalJsonUtil.StringToMap(curiosData);
|
||||||
if (storedMap.isEmpty()) return;
|
if (storedMap.isEmpty()) return;
|
||||||
|
|
||||||
// Restore items from pre-read data
|
// Restore items from pre-read data. Cosmetic slots use the "cos:slotType:index"
|
||||||
|
// composite key; functional slots use "slotType:index".
|
||||||
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
||||||
String compositeKey = entry.getKey();
|
String compositeKey = entry.getKey();
|
||||||
int lastColon = compositeKey.lastIndexOf(':');
|
boolean cosmetic = compositeKey.startsWith("cos:");
|
||||||
|
String remaining = cosmetic ? compositeKey.substring(4) : compositeKey;
|
||||||
|
int lastColon = remaining.lastIndexOf(':');
|
||||||
if (lastColon < 0) continue;
|
if (lastColon < 0) continue;
|
||||||
String slotType = compositeKey.substring(0, lastColon);
|
String slotType = remaining.substring(0, lastColon);
|
||||||
int slotIndex;
|
int slotIndex;
|
||||||
try { slotIndex = Integer.parseInt(compositeKey.substring(lastColon + 1)); }
|
try { slotIndex = Integer.parseInt(remaining.substring(lastColon + 1)); }
|
||||||
catch (NumberFormatException e) { continue; }
|
catch (NumberFormatException e) { continue; }
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue());
|
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(entry.getValue());
|
||||||
ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType);
|
ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType);
|
||||||
if (stacksHandler != null) {
|
if (stacksHandler != null) {
|
||||||
IDynamicStackHandler stacks = stacksHandler.getStacks();
|
IDynamicStackHandler stacks = cosmetic
|
||||||
|
? stacksHandler.getCosmeticStacks()
|
||||||
|
: stacksHandler.getStacks();
|
||||||
if (slotIndex < stacks.getSlots()) {
|
if (slotIndex < stacks.getSlots()) {
|
||||||
stacks.setStackInSlot(slotIndex, stack);
|
stacks.setStackInSlot(slotIndex, stack);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Error applying curios slot {}:{}", slotType, slotIndex, e);
|
PlayerSync.LOGGER.error("Error applying curios slot {} ({}:{})", compositeKey, slotType, slotIndex, e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
PlayerSync.LOGGER.info("Applied curios data for player {} from pre-read data", player.getUUID());
|
PlayerSync.LOGGER.info("Applied curios data for player {} from pre-read data", player.getUUID());
|
||||||
|
|
@ -344,8 +398,15 @@ public class ModsSupport {
|
||||||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||||
ItemStack stack = dynStacks.getStackInSlot(i);
|
ItemStack stack = dynStacks.getStackInSlot(i);
|
||||||
if (!stack.isEmpty()) {
|
if (!stack.isEmpty()) {
|
||||||
String serialized = VanillaSync.getNbtForStorage(stack);
|
flatMap.put(slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||||
flatMap.put(slotType + ":" + i, serialized);
|
}
|
||||||
|
}
|
||||||
|
// FIX A2: cosmetic stacks must be captured symmetrically with snapshotCuriosData.
|
||||||
|
IDynamicStackHandler cosStacks = stacksHandler.getCosmeticStacks();
|
||||||
|
for (int i = 0; i < cosStacks.getSlots(); i++) {
|
||||||
|
ItemStack stack = cosStacks.getStackInSlot(i);
|
||||||
|
if (!stack.isEmpty()) {
|
||||||
|
flatMap.put("cos:" + slotType + ":" + i, VanillaSync.getNbtForStorage(stack));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
@ -356,7 +417,7 @@ public class ModsSupport {
|
||||||
// FIX: Use REPLACE INTO instead of separate INSERT/UPDATE to prevent silent
|
// FIX: Use REPLACE INTO instead of separate INSERT/UPDATE to prevent silent
|
||||||
// no-ops when the row doesn't exist yet (e.g. new player who died before first save)
|
// no-ops when the row doesn't exist yet (e.g. new player who died before first save)
|
||||||
JDBCsetUp.executePreparedUpdate(
|
JDBCsetUp.executePreparedUpdate(
|
||||||
"REPLACE INTO curios (uuid, curios_item) VALUES (?, ?)",
|
"REPLACE INTO " + Tables.curios() + " (uuid, curios_item) VALUES (?, ?)",
|
||||||
player.getUUID().toString(), serializedData);
|
player.getUUID().toString(), serializedData);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -374,13 +435,17 @@ public class ModsSupport {
|
||||||
if (uuidOpt.isPresent()) {
|
if (uuidOpt.isPresent()) {
|
||||||
UUID contentsUuid = uuidOpt.get();
|
UUID contentsUuid = uuidOpt.get();
|
||||||
|
|
||||||
// FIX: Read the full contents NBT from the wrapper's in-memory state,
|
// FIX: Read the full contents NBT from the wrapper's in-memory state.
|
||||||
// not from BackpackStorage which may have stale data if the wrapper
|
// NOTE: despite earlier comments, refreshInventoryForInputOutput() does
|
||||||
// hasn't flushed recent changes (e.g. upgrade modifications).
|
// NOT actively flush to BackpackStorage — it resets the IO handler cache
|
||||||
// refreshInventoryForInputOutput triggers an internal save to BackpackStorage.
|
// and runs the change callbacks. The live CompoundTag in BackpackStorage
|
||||||
|
// is already kept up to date by handler writes, so reading it next is safe.
|
||||||
try {
|
try {
|
||||||
backpackWrapper.refreshInventoryForInputOutput();
|
backpackWrapper.refreshInventoryForInputOutput();
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception e) {
|
||||||
|
PlayerSync.LOGGER.warn("refreshInventoryForInputOutput failed for backpack {} of player {} — saved NBT may be slightly stale",
|
||||||
|
contentsUuid, player.getUUID(), e);
|
||||||
|
}
|
||||||
|
|
||||||
CompoundTag backpackNbt = net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().getOrCreateBackpackContents(contentsUuid);
|
CompoundTag backpackNbt = net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().getOrCreateBackpackContents(contentsUuid);
|
||||||
saveStorageContents(contentsUuid, backpackNbt);
|
saveStorageContents(contentsUuid, backpackNbt);
|
||||||
|
|
@ -638,9 +703,54 @@ public class ModsSupport {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Saves Sophisticated Storage contents by UUID. Reads SavedData and writes to DB.
|
* FIX THREAD-SAFETY (C3): Captures Sophisticated Storage CompoundTags on the MAIN
|
||||||
* Can be called from a background thread (no entity access).
|
* thread by copying the SavedData entries. Previously {@link #saveSSByUuids(List)}
|
||||||
|
* read {@code ItemContentsStorage} directly from a background thread, racing with
|
||||||
|
* main-thread modifications (non-thread-safe HashMap) and risking torn reads → dup.
|
||||||
|
*
|
||||||
|
* <p>Callers should invoke this on the main thread, then pass the returned map to
|
||||||
|
* {@link #saveSSSnapshots(Map)} on a background thread.
|
||||||
*/
|
*/
|
||||||
|
public static Map<UUID, CompoundTag> snapshotSSData(List<UUID> uuids) {
|
||||||
|
Map<UUID, CompoundTag> out = new HashMap<>();
|
||||||
|
if (uuids == null || uuids.isEmpty() || !ModList.get().isLoaded("sophisticatedstorage")) return out;
|
||||||
|
try {
|
||||||
|
net.p3pp3rf1y.sophisticatedstorage.block.ItemContentsStorage store =
|
||||||
|
net.p3pp3rf1y.sophisticatedstorage.block.ItemContentsStorage.get();
|
||||||
|
for (UUID uuid : uuids) {
|
||||||
|
try {
|
||||||
|
CompoundTag live = store.getOrCreateStorageContents(uuid);
|
||||||
|
if (live != null && !live.isEmpty()) {
|
||||||
|
out.put(uuid, live.copy());
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
PlayerSync.LOGGER.error("Error snapshotting SS contents for UUID {}", uuid, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
PlayerSync.LOGGER.error("Error reading ItemContentsStorage for snapshot", e);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Background-thread writer for the frozen snapshot produced by {@link #snapshotSSData(List)}. */
|
||||||
|
public static void saveSSSnapshots(Map<UUID, CompoundTag> snapshots) {
|
||||||
|
if (snapshots == null || snapshots.isEmpty()) return;
|
||||||
|
for (Map.Entry<UUID, CompoundTag> e : snapshots.entrySet()) {
|
||||||
|
try {
|
||||||
|
saveStorageContents(e.getKey(), e.getValue());
|
||||||
|
} catch (Exception ex) {
|
||||||
|
PlayerSync.LOGGER.error("Error saving SS snapshot for UUID {}", e.getKey(), ex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated unsafe — reads ItemContentsStorage from the calling thread (possibly
|
||||||
|
* background), racing with main-thread modifications. Use {@link #snapshotSSData(List)}
|
||||||
|
* on main thread followed by {@link #saveSSSnapshots(Map)} on background thread.
|
||||||
|
*/
|
||||||
|
@Deprecated
|
||||||
public static void saveSSByUuids(List<UUID> uuids) {
|
public static void saveSSByUuids(List<UUID> uuids) {
|
||||||
for (UUID uuid : uuids) {
|
for (UUID uuid : uuids) {
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -1,134 +0,0 @@
|
||||||
package vip.fubuki.playersync.sync.chat;
|
|
||||||
|
|
||||||
import net.minecraft.network.chat.Component;
|
|
||||||
import net.minecraft.server.players.PlayerList;
|
|
||||||
import net.neoforged.bus.api.SubscribeEvent;
|
|
||||||
import net.neoforged.neoforge.event.ServerChatEvent;
|
|
||||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
|
||||||
import vip.fubuki.playersync.PlayerSync;
|
|
||||||
import vip.fubuki.playersync.config.JdbcConfig;
|
|
||||||
|
|
||||||
import java.io.*;
|
|
||||||
import java.net.ConnectException;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.Socket;
|
|
||||||
import java.net.SocketTimeoutException;
|
|
||||||
import java.util.Objects;
|
|
||||||
|
|
||||||
public class ChatSyncClient {
|
|
||||||
static PlayerList playerList;
|
|
||||||
static Socket clientSocket;
|
|
||||||
static PrintWriter out;
|
|
||||||
|
|
||||||
private static volatile boolean running = true;
|
|
||||||
private static final int RECONNECT_DELAY = 5000;
|
|
||||||
private static final int MAX_RECONNECT_ATTEMPTS = 10;
|
|
||||||
|
|
||||||
public void run() {
|
|
||||||
int reconnectAttempts = 0;
|
|
||||||
|
|
||||||
while (running && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
||||||
try {
|
|
||||||
PlayerSync.LOGGER.info("Connecting to chat server {}:{}",
|
|
||||||
JdbcConfig.CHAT_SERVER_IP.get(),
|
|
||||||
JdbcConfig.CHAT_SERVER_PORT.get());
|
|
||||||
|
|
||||||
clientSocket = new Socket();
|
|
||||||
clientSocket.setReuseAddress(true);
|
|
||||||
clientSocket.setKeepAlive(true);
|
|
||||||
clientSocket.setTcpNoDelay(true);
|
|
||||||
|
|
||||||
clientSocket.connect(
|
|
||||||
new InetSocketAddress(
|
|
||||||
JdbcConfig.CHAT_SERVER_IP.get(),
|
|
||||||
JdbcConfig.CHAT_SERVER_PORT.get()
|
|
||||||
),
|
|
||||||
15000
|
|
||||||
);
|
|
||||||
|
|
||||||
clientSocket.setSoTimeout(0);
|
|
||||||
|
|
||||||
out = new PrintWriter(new BufferedWriter(
|
|
||||||
new OutputStreamWriter(clientSocket.getOutputStream())), true);
|
|
||||||
|
|
||||||
PlayerSync.LOGGER.info("Successfully connected to chat server");
|
|
||||||
reconnectAttempts = 0;
|
|
||||||
|
|
||||||
BufferedReader in = new BufferedReader(
|
|
||||||
new InputStreamReader(clientSocket.getInputStream()));
|
|
||||||
|
|
||||||
String serverMessage;
|
|
||||||
while (running && (serverMessage = in.readLine()) != null) {
|
|
||||||
Component textComponents = Component.nullToEmpty(serverMessage);
|
|
||||||
if(playerList != null){
|
|
||||||
playerList.getServer().execute(() ->
|
|
||||||
playerList.broadcastSystemMessage(textComponents, false));
|
|
||||||
}else {
|
|
||||||
PlayerSync.LOGGER.info("Received message from chat server: " + serverMessage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (SocketTimeoutException e) {
|
|
||||||
PlayerSync.LOGGER.warn("Chat server read timeout, reconnecting...");
|
|
||||||
} catch (ConnectException e) {
|
|
||||||
PlayerSync.LOGGER.warn("Cannot connect to chat server: {}", e.getMessage());
|
|
||||||
} catch (IOException e) {
|
|
||||||
PlayerSync.LOGGER.error("Chat client connection error: {}", e.getMessage());
|
|
||||||
} finally {
|
|
||||||
closeConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (running && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
|
||||||
reconnectAttempts++;
|
|
||||||
PlayerSync.LOGGER.warn("Attempting to reconnect to chat server ({}/{})",
|
|
||||||
reconnectAttempts, MAX_RECONNECT_ATTEMPTS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
long delay = Math.min(RECONNECT_DELAY * (long)Math.pow(2, reconnectAttempts-1), 60000);
|
|
||||||
Thread.sleep(delay);
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void closeConnection() {
|
|
||||||
try {
|
|
||||||
if (out != null) {
|
|
||||||
out.close();
|
|
||||||
out = null;
|
|
||||||
}
|
|
||||||
if (clientSocket != null && !clientSocket.isClosed()) {
|
|
||||||
clientSocket.close();
|
|
||||||
clientSocket = null;
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
PlayerSync.LOGGER.error("Error closing connection: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void shutdown() {
|
|
||||||
running = false;
|
|
||||||
closeConnection();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SubscribeEvent
|
|
||||||
public static void onPlayerChat(ServerChatEvent event) {
|
|
||||||
String message= "<"+event.getUsername()+"> "+event.getMessage().getString();
|
|
||||||
if (out != null) {
|
|
||||||
out.println(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@SubscribeEvent
|
|
||||||
public static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event){
|
|
||||||
playerList = Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
|
||||||
}
|
|
||||||
|
|
||||||
@SubscribeEvent
|
|
||||||
public static void onPlayerLeave(PlayerEvent.PlayerLoggedOutEvent event){
|
|
||||||
playerList = Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,130 +0,0 @@
|
||||||
package vip.fubuki.playersync.sync.chat;
|
|
||||||
|
|
||||||
import vip.fubuki.playersync.PlayerSync;
|
|
||||||
import vip.fubuki.playersync.config.JdbcConfig;
|
|
||||||
|
|
||||||
import java.io.BufferedReader;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InputStreamReader;
|
|
||||||
import java.io.PrintWriter;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
import java.net.Socket;
|
|
||||||
import java.net.SocketTimeoutException;
|
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.concurrent.ConcurrentHashMap;
|
|
||||||
import java.util.concurrent.ExecutorService;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
|
|
||||||
public class ChatSyncServer {
|
|
||||||
static ServerSocket serverSocket;
|
|
||||||
static final Set<Socket> SocketList = ConcurrentHashMap.newKeySet();
|
|
||||||
static final ExecutorService executorService = Executors.newCachedThreadPool();
|
|
||||||
private volatile boolean running = true;
|
|
||||||
|
|
||||||
public void run() throws IOException {
|
|
||||||
try {
|
|
||||||
serverSocket = new ServerSocket(JdbcConfig.CHAT_SERVER_PORT.get());
|
|
||||||
serverSocket.setReuseAddress(true);
|
|
||||||
PlayerSync.LOGGER.info("Chat server started successfully on port {}", JdbcConfig.CHAT_SERVER_PORT.get());
|
|
||||||
|
|
||||||
while (running && !Thread.currentThread().isInterrupted()) {
|
|
||||||
try {
|
|
||||||
Socket newSocket = serverSocket.accept();
|
|
||||||
newSocket.setSoTimeout(0);
|
|
||||||
SocketList.add(newSocket);
|
|
||||||
executorService.submit(() -> handleClient(newSocket));
|
|
||||||
PlayerSync.LOGGER.info("New client connected, total clients: {}", SocketList.size());
|
|
||||||
} catch (IOException e) {
|
|
||||||
if (running) {
|
|
||||||
PlayerSync.LOGGER.error("Error accepting client connection: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
shutdown();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleClient(Socket socket) {
|
|
||||||
String clientInfo = socket.getInetAddress() + ":" + socket.getPort();
|
|
||||||
|
|
||||||
try (BufferedReader reader = new BufferedReader(
|
|
||||||
new InputStreamReader(socket.getInputStream()))) {
|
|
||||||
|
|
||||||
String message;
|
|
||||||
while (running && (message = reader.readLine()) != null) {
|
|
||||||
broadcastMessage(socket, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (SocketTimeoutException e) {
|
|
||||||
PlayerSync.LOGGER.warn("Client {} timeout", clientInfo);
|
|
||||||
} catch (IOException e) {
|
|
||||||
PlayerSync.LOGGER.error("Error handling client {}: {}", clientInfo, e.getMessage());
|
|
||||||
} finally {
|
|
||||||
SocketList.remove(socket);
|
|
||||||
try {
|
|
||||||
if (!socket.isClosed()) {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
PlayerSync.LOGGER.error("Error closing client socket: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
PlayerSync.LOGGER.info("Client disconnected, remaining clients: {}", SocketList.size());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void broadcastMessage(Socket sender, String message) {
|
|
||||||
Iterator<Socket> iterator = SocketList.iterator();
|
|
||||||
while (iterator.hasNext()) {
|
|
||||||
Socket socket = iterator.next();
|
|
||||||
if (!socket.equals(sender) && !socket.isClosed()) {
|
|
||||||
try {
|
|
||||||
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
|
|
||||||
writer.println(message);
|
|
||||||
} catch (IOException e) {
|
|
||||||
PlayerSync.LOGGER.error("Error broadcasting to client, removing: {}", e.getMessage());
|
|
||||||
iterator.remove();
|
|
||||||
try {
|
|
||||||
socket.close();
|
|
||||||
} catch (IOException ex) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void shutdown() {
|
|
||||||
running = false;
|
|
||||||
try {
|
|
||||||
if (serverSocket != null && !serverSocket.isClosed()) {
|
|
||||||
serverSocket.close();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
PlayerSync.LOGGER.error("Error closing server socket: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
|
|
||||||
for (Socket socket : SocketList) {
|
|
||||||
try {
|
|
||||||
if (!socket.isClosed()) {
|
|
||||||
socket.close();
|
|
||||||
}
|
|
||||||
} catch (IOException e) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
SocketList.clear();
|
|
||||||
|
|
||||||
executorService.shutdown();
|
|
||||||
try {
|
|
||||||
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
|
|
||||||
executorService.shutdownNow();
|
|
||||||
}
|
|
||||||
} catch (InterruptedException e) {
|
|
||||||
executorService.shutdownNow();
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -43,23 +43,24 @@ public class JDBCsetUp {
|
||||||
cfg.setUsername(JdbcConfig.USERNAME.get());
|
cfg.setUsername(JdbcConfig.USERNAME.get());
|
||||||
cfg.setPassword(JdbcConfig.PASSWORD.get());
|
cfg.setPassword(JdbcConfig.PASSWORD.get());
|
||||||
|
|
||||||
// FIX PERF: Increased pool for 35+ player servers.
|
// FIX PERF (C9): right-sized pool. 25 was oversized; empirical HikariCP rule is
|
||||||
// Old: 10 max / 2 idle → 35 concurrent saves queued on 10 connections → 250ms+ wait.
|
// ~ cores*2 + spindles. 15 handles 35 concurrent players comfortably and reduces
|
||||||
// New: 25 max / 4 idle → handles peak load without connection starvation.
|
// MySQL server-side context switching.
|
||||||
cfg.setMaximumPoolSize(25);
|
cfg.setMaximumPoolSize(15);
|
||||||
cfg.setMinimumIdle(4);
|
cfg.setMinimumIdle(4);
|
||||||
|
|
||||||
// Connection lifecycle
|
// Connection lifecycle
|
||||||
cfg.setConnectionTimeout(30_000L); // 30 s – how long to wait for a free slot
|
cfg.setConnectionTimeout(10_000L); // 10 s – fail fast on MySQL outage
|
||||||
cfg.setIdleTimeout(600_000L); // 10 min – evict idle connections
|
cfg.setIdleTimeout(300_000L); // 5 min – evict idle connections sooner
|
||||||
cfg.setMaxLifetime(1_800_000L); // 30 min – recycle before MySQL wait_timeout
|
cfg.setMaxLifetime(1_800_000L); // 30 min – recycle before MySQL wait_timeout
|
||||||
cfg.setKeepaliveTime(300_000L); // 5 min – ping idle connections (NOT hot path)
|
cfg.setKeepaliveTime(300_000L); // 5 min – ping idle connections (NOT hot path)
|
||||||
|
|
||||||
cfg.setAutoCommit(true);
|
cfg.setAutoCommit(true);
|
||||||
cfg.setPoolName("PlayerSync");
|
cfg.setPoolName("PlayerSync");
|
||||||
|
|
||||||
// FIX PERF: Detect connection leaks (connections held > 10s without being returned)
|
// FIX PERF (C9): 25s threshold — covers worst-case doPlayerJoin poll bursts without
|
||||||
cfg.setLeakDetectionThreshold(10000);
|
// flooding logs with false positives. Previous 10s fired during legitimate 15-30s polls.
|
||||||
|
cfg.setLeakDetectionThreshold(25_000L);
|
||||||
|
|
||||||
dataSource = new HikariDataSource(cfg);
|
dataSource = new HikariDataSource(cfg);
|
||||||
LOGGER.info("[PlayerSync] HikariCP pool ready (maxPool={}, minIdle={})",
|
LOGGER.info("[PlayerSync] HikariCP pool ready (maxPool={}, minIdle={})",
|
||||||
|
|
@ -203,29 +204,38 @@ public class JDBCsetUp {
|
||||||
*
|
*
|
||||||
* Each entry is {sql, params...}. All execute in order within one transaction.
|
* Each entry is {sql, params...}. All execute in order within one transaction.
|
||||||
* If any fails, the entire batch is rolled back.
|
* If any fails, the entire batch is rolled back.
|
||||||
|
*
|
||||||
|
* @return array of per-statement affected-row counts (parallel to {@code statements}).
|
||||||
|
* Callers can inspect the first entry to detect silent no-ops caused by
|
||||||
|
* {@code AND last_server=?} guards blocking a stale write.
|
||||||
*/
|
*/
|
||||||
public static void executeBatchTransaction(Object[]... statements) throws SQLException {
|
public static int[] executeBatchTransaction(Object[]... statements) throws SQLException {
|
||||||
|
int[] counts = new int[statements.length];
|
||||||
try (Connection conn = getConnection()) {
|
try (Connection conn = getConnection()) {
|
||||||
conn.setAutoCommit(false);
|
conn.setAutoCommit(false);
|
||||||
try {
|
try {
|
||||||
for (Object[] entry : statements) {
|
for (int idx = 0; idx < statements.length; idx++) {
|
||||||
|
Object[] entry = statements[idx];
|
||||||
String sql = (String) entry[0];
|
String sql = (String) entry[0];
|
||||||
LOGGER.trace(sql);
|
LOGGER.trace(sql);
|
||||||
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
|
try (PreparedStatement stmt = conn.prepareStatement(sql)) {
|
||||||
for (int i = 1; i < entry.length; i++) {
|
for (int i = 1; i < entry.length; i++) {
|
||||||
stmt.setObject(i, entry[i]);
|
stmt.setObject(i, entry[i]);
|
||||||
}
|
}
|
||||||
stmt.executeUpdate();
|
counts[idx] = stmt.executeUpdate();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
conn.commit();
|
conn.commit();
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
try { conn.rollback(); } catch (SQLException ignored) {}
|
try { conn.rollback(); } catch (SQLException rbEx) {
|
||||||
|
LOGGER.error("[PlayerSync] Rollback failed while handling batch transaction error", rbEx);
|
||||||
|
}
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
conn.setAutoCommit(true);
|
conn.setAutoCommit(true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return counts;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static QueryResult executePreparedQuery(String sql, Object... params) throws SQLException {
|
public static QueryResult executePreparedQuery(String sql, Object... params) throws SQLException {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,9 @@ import java.nio.file.*;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.time.format.DateTimeFormatter;
|
import java.time.format.DateTimeFormatter;
|
||||||
import java.util.concurrent.ConcurrentLinkedQueue;
|
import java.util.concurrent.ConcurrentLinkedQueue;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.ScheduledExecutorService;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
import java.util.concurrent.atomic.AtomicBoolean;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -33,6 +36,16 @@ public class SyncLogger {
|
||||||
private static final AtomicBoolean initialized = new AtomicBoolean(false);
|
private static final AtomicBoolean initialized = new AtomicBoolean(false);
|
||||||
private static Path logPath;
|
private static Path logPath;
|
||||||
|
|
||||||
|
// FIX PERF (C3): Dedicated daemon scheduler so log() never opens/closes the file on
|
||||||
|
// the caller thread. Previous impl called flushQueue() inline → every log call from
|
||||||
|
// the main thread opened a FileWriter, wrote, and closed synchronously.
|
||||||
|
private static final ScheduledExecutorService FLUSH_EXEC = Executors.newSingleThreadScheduledExecutor(r -> {
|
||||||
|
Thread t = new Thread(r, "PlayerSync-logflush");
|
||||||
|
t.setDaemon(true);
|
||||||
|
t.setPriority(Thread.MIN_PRIORITY);
|
||||||
|
return t;
|
||||||
|
});
|
||||||
|
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
// Initialization
|
// Initialization
|
||||||
// -------------------------------------------------------------------------
|
// -------------------------------------------------------------------------
|
||||||
|
|
@ -47,6 +60,8 @@ public class SyncLogger {
|
||||||
writeRaw("=".repeat(80));
|
writeRaw("=".repeat(80));
|
||||||
writeRaw("PlayerSync Log — Server ID: " + JdbcConfig.SERVER_ID.get() + " — Started: " + LocalDateTime.now().format(TIME_FMT));
|
writeRaw("PlayerSync Log — Server ID: " + JdbcConfig.SERVER_ID.get() + " — Started: " + LocalDateTime.now().format(TIME_FMT));
|
||||||
writeRaw("=".repeat(80));
|
writeRaw("=".repeat(80));
|
||||||
|
// FIX PERF (C3): single background flush every 500ms — no file I/O on hot path.
|
||||||
|
FLUSH_EXEC.scheduleWithFixedDelay(SyncLogger::flushQueue, 500, 500, TimeUnit.MILLISECONDS);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
System.err.println("[PlayerSync] Failed to initialize SyncLogger: " + e.getMessage());
|
System.err.println("[PlayerSync] Failed to initialize SyncLogger: " + e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
@ -153,8 +168,7 @@ public class SyncLogger {
|
||||||
level,
|
level,
|
||||||
formatted);
|
formatted);
|
||||||
writeQueue.add(line);
|
writeQueue.add(line);
|
||||||
// Flush async to avoid blocking caller
|
// FIX PERF (C3): no inline flush — background scheduler drains the queue.
|
||||||
flushQueue();
|
|
||||||
} catch (Exception ignored) {}
|
} catch (Exception ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -191,7 +205,6 @@ public class SyncLogger {
|
||||||
|
|
||||||
private static void writeRaw(String line) {
|
private static void writeRaw(String line) {
|
||||||
writeQueue.add(line);
|
writeQueue.add(line);
|
||||||
flushQueue();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void rotateIfNeeded() {
|
private static void rotateIfNeeded() {
|
||||||
|
|
@ -215,8 +228,10 @@ public class SyncLogger {
|
||||||
} catch (IOException ignored) {}
|
} catch (IOException ignored) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Call on server shutdown to flush remaining entries */
|
/** Call on server shutdown to flush remaining entries and stop the background writer. */
|
||||||
public static void shutdown() {
|
public static void shutdown() {
|
||||||
|
try { FLUSH_EXEC.shutdown(); } catch (Exception ignored) {}
|
||||||
|
try { FLUSH_EXEC.awaitTermination(2, TimeUnit.SECONDS); } catch (InterruptedException ignored) {}
|
||||||
flushQueue();
|
flushQueue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
56
src/main/java/vip/fubuki/playersync/util/Tables.java
Normal file
56
src/main/java/vip/fubuki/playersync/util/Tables.java
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
package vip.fubuki.playersync.util;
|
||||||
|
|
||||||
|
import vip.fubuki.playersync.config.JdbcConfig;
|
||||||
|
|
||||||
|
import java.util.regex.Pattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central source of truth for PlayerSync table names.
|
||||||
|
*
|
||||||
|
* <p>Reads the optional {@code table_prefix} config at every call so that
|
||||||
|
* administrators can safely share a single MySQL database with other mods
|
||||||
|
* without colliding on generic names such as {@code player_data} or
|
||||||
|
* {@code server_info}. The prefix defaults to an empty string to preserve
|
||||||
|
* backward compatibility with existing installations.
|
||||||
|
*
|
||||||
|
* <p>Only the <em>table</em> identifier is prefixed. The database schema
|
||||||
|
* qualifier (if any) must be added by the caller, e.g. via
|
||||||
|
* {@code "`" + dbName + "`." + Tables.playerData()}.
|
||||||
|
*
|
||||||
|
* @author vyrriox
|
||||||
|
*/
|
||||||
|
public final class Tables {
|
||||||
|
|
||||||
|
private Tables() {}
|
||||||
|
|
||||||
|
// FIX PERF: precompile the validation pattern and cache the validated prefix.
|
||||||
|
// String.matches() recompiles the regex on every call; this was invoked from
|
||||||
|
// every SQL statement the mod issues (heartbeat, auto-save, join, logout, ...).
|
||||||
|
// The config value cannot change at runtime, so a lazy singleton cache is safe.
|
||||||
|
private static final Pattern VALID_PREFIX = Pattern.compile("[A-Za-z0-9_]*");
|
||||||
|
private static volatile String cachedPrefix;
|
||||||
|
private static volatile String cachedRaw;
|
||||||
|
|
||||||
|
private static String prefix() {
|
||||||
|
String raw;
|
||||||
|
try { raw = JdbcConfig.TABLE_PREFIX.get(); }
|
||||||
|
catch (Exception e) { return ""; }
|
||||||
|
if (raw == null) raw = "";
|
||||||
|
// Fast path: same raw value as last call → return cached validated prefix.
|
||||||
|
String lastRaw = cachedRaw;
|
||||||
|
if (lastRaw != null && lastRaw.equals(raw)) {
|
||||||
|
return cachedPrefix;
|
||||||
|
}
|
||||||
|
// Validate and cache.
|
||||||
|
String validated = VALID_PREFIX.matcher(raw).matches() ? raw : "";
|
||||||
|
cachedPrefix = validated;
|
||||||
|
cachedRaw = raw;
|
||||||
|
return validated;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String playerData() { return prefix() + "player_data"; }
|
||||||
|
public static String serverInfo() { return prefix() + "server_info"; }
|
||||||
|
public static String curios() { return prefix() + "curios"; }
|
||||||
|
public static String backpackData() { return prefix() + "backpack_data"; }
|
||||||
|
public static String modPlayerData() { return prefix() + "mod_player_data"; }
|
||||||
|
}
|
||||||
|
|
@ -3,16 +3,8 @@
|
||||||
"package": "vip.fubuki.playersync.mixin",
|
"package": "vip.fubuki.playersync.mixin",
|
||||||
"compatibilityLevel": "JAVA_21",
|
"compatibilityLevel": "JAVA_21",
|
||||||
"refmap": "thirst.refmap.json",
|
"refmap": "thirst.refmap.json",
|
||||||
"mixins": [
|
"mixins": [],
|
||||||
"cobblemon.MixinFileBackedPokemonStoreFactory",
|
"client": [],
|
||||||
"cobblemon.MixinNbtBackedPlayerData",
|
|
||||||
"cobblemon.MixinPartyStore",
|
|
||||||
"cobblemon.MixinPCStore",
|
|
||||||
"cobblemon.accessor.FileBasedPlayerDataStoreBackendAccessor",
|
|
||||||
"cobblemon.accessor.NbtBackedPlayerDataAccessor"
|
|
||||||
],
|
|
||||||
"client": [
|
|
||||||
],
|
|
||||||
"injectors": {
|
"injectors": {
|
||||||
"defaultRequire": 1
|
"defaultRequire": 1
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user