From ac20ad327c572656fd857dbb7dfbe8efd3f2e9a4 Mon Sep 17 00:00:00 2001 From: mlus <1319237806@qq.com> Date: Tue, 9 Jun 2026 10:22:39 +0800 Subject: [PATCH] =?UTF-8?q?Feat:=20=E6=B7=BB=E5=8A=A0=E4=BA=86Mek=E6=94=AF?= =?UTF-8?q?=E6=8C=81=20Merge=20pull=20request=20#172=20from=20mlus-asuka/f?= =?UTF-8?q?ix/169-bounded-thread-pool?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix/169 bounded thread pool --- build.gradle | 9 + docs/code-analysis.md | 406 ++++++++++++++++++ gradle.properties | 4 + .../vip/fubuki/playersync/PlayerSync.java | 13 + .../fubuki/playersync/config/JdbcConfig.java | 4 + .../fubuki/playersync/sync/VanillaSync.java | 12 + .../sync/addons/MekanismSupport.java | 221 ++++++++++ 7 files changed, 669 insertions(+) create mode 100644 docs/code-analysis.md create mode 100644 src/main/java/vip/fubuki/playersync/sync/addons/MekanismSupport.java diff --git a/build.gradle b/build.gradle index 74bc302..e4cc571 100644 --- a/build.gradle +++ b/build.gradle @@ -29,6 +29,7 @@ repositories { includeGroup "curse.maven" } } + maven { url 'https://modmaven.dev/' } } base { @@ -138,6 +139,14 @@ dependencies { compileOnly "curse.maven:curios-309927:5266541" compileOnly "curse.maven:sophisticated-backpacks-422301:7169843" compileOnly "curse.maven:sophisticated-core-618298:7169400" +// compileOnly "mekanism:Mekanism:${mekanism_version}:api" + + // If you want to test/use Mekanism & its modules during `runClient` invocation, use the following + modCompileOnly ("mekanism:Mekanism:${mekanism_version}")// core +// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:additions")// Mekanism: Additions +// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:generators")// Mekanism: Generators +// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:tools")// Mekanism: Tools + // Example mod dependency using a mod jar from ./libs with a flat dir repository // This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar diff --git a/docs/code-analysis.md b/docs/code-analysis.md new file mode 100644 index 0000000..f45f6b5 --- /dev/null +++ b/docs/code-analysis.md @@ -0,0 +1,406 @@ +# PlayerSync — Code Analysis + +## 1. Project Overview + +**PlayerSync** is a **Minecraft Forge 1.20.1 server-side mod** that synchronizes player data across multiple Forge servers sharing a centralized MySQL database. It is designed for **Forge 群组服 (Forge group server)** architectures where players can move between different physical sub-servers and their inventory, equipment, advancements, effects, and other data must follow them seamlessly. + +| Property | Value | +|---|---| +| Mod ID | `playersync` | +| Minecraft | 1.20.1 | +| Forge | 47.4.0+ | +| Java | 17 | +| License | GPL-3.0 | +| Author | mlus | +| DB | MySQL (via JDBC `mysql-connector-j`) | + +--- + +## 2. Project File Structure + +``` +src/main/java/vip/fubuki/playersync/ +├── PlayerSync.java # Main mod class, DB initialization +├── CommandInit.java # `/playersync` command registration +├── config/ +│ └── JdbcConfig.java # All mod configuration (ForgeConfigSpec) +├── sync/ +│ ├── VanillaSync.java # Core player data sync (inventory, effects, advancements, etc.) +│ ├── ChatSync.java # Cross-server chat sync orchestrator +│ ├── chat/ +│ │ ├── ChatSyncServer.java # TCP chat server (one designated server) +│ │ └── ChatSyncClient.java # TCP chat client (all servers) +│ └── addons/ +│ ├── ModsSupport.java # Curios, Sophisticated Backpacks & Mekanism integration +│ ├── CuriosCache.java # Death-safe Curios data caching +│ └── MekanismSupport.java # Mekanism personal chest inventory sync +└── util/ + ├── JDBCsetUp.java # MySQL JDBC connection & query helper + ├── LocalJsonUtil.java # Lightweight map⇔string parser + └── PSThreadPoolFactory.java # Named thread factory for async DB operations + +src/main/resources/ +└── assets/playersync/lang/ + ├── en_us.json # English translations + └── zh_cn.json # Chinese translations + +src/main/templates/ +├── META-INF/mods.toml # Mod metadata template +└── pack.mcmeta # Resource pack metadata +``` + +--- + +## 3. Architecture + +### 3.1 Deployment Topology + +``` +┌──────────────┐ ┌──────────────┐ ┌──────────────┐ +│ Server A │ │ Server B │ │ Server C │ +│ (forge-1) │ │ (forge-2) │ │ (forge-3) │ +│ PlayerSync │ │ PlayerSync │ │ PlayerSync │ +└──────┬───────┘ └──────┬───────┘ └──────┬───────┘ + │ │ │ + └──────────────────┼──────────────────┘ + │ + ┌──────▼──────┐ ┌──────────────┐ + │ MySQL DB │◄──────│ Chat Server │ + │ playersync │ │ (TCP:7900) │ + └─────────────┘ └──────────────┘ +``` + +- **All sub-servers** read/write player data to the same MySQL database. +- **One designated server** runs the chat sync server (TCP). All servers connect to it as clients to broadcast chat messages across servers. +- Each server gets a **unique `server_id`** (randomly generated on first config load). + +### 3.2 Data Flow Summary + +``` +Player Login: + 1. PlayerNegotiationEvent → doPlayerConnect() [check if already online on another server] + 2. OnDatapackSyncEvent → restore advancements [write JSON to disk] + 3. PlayerLoggedInEvent → doPlayerJoin() [restore all data from DB] + └─ ModsSupport.doCuriosRestore() + └─ ModsSupport.doBackPackRestore() + └─ MekanismSupport.restorePersonalChestData() + +Player Playing: + 4. PlayerEvent.SaveToFile → doPlayerSaveToFile() [periodic save] + 5. TickEvent.ServerTick → auto-save every 1200t [~1 min] + 6. ServerChatEvent → ChatSyncClient → TCP [chat broadcast] + +Player Death (with keepInventory): + 6. LivingDeathEvent → CuriosCache.tryStoreCuriosToCache() [snapshot curios] + +Player Logout: + 7. PlayerLoggedOutEvent → onPlayerLeave() → store() + curios save + └─ if dead: use CuriosCache, else normal save + +Server Shutdown: + 8. ServerStoppedEvent → mark server disabled in DB +``` + +--- + +## 4. Core Module Analysis + +### 4.1 `PlayerSync.java` — Main Mod Class + +**Path:** `src/main/java/vip/fubuki/playersync/PlayerSync.java` + +The entry point and lifecycle controller. + +| Method | Trigger | Responsibility | +|---|---|---| +| `PlayerSync()` | Mod construction | Register config, event bus | +| `commonSetup()` | `FMLCommonSetupEvent` | Register JDBC driver, init VanillaSync, conditionally init ChatSync | +| `onServerStarting()` | `ServerStartingEvent` | Create DB, create/alter tables, register server heartbeat, mark players offline | +| `onServerStopping()` | `ServerStoppingEvent` | Shutdown ChatSync | + +**Database tables created on server start:** + +| Table | Purpose | +|---|---| +| `player_data` | Core player state (inv, armor, enderchest, advancements, effects, XP, health, food, score, left_hand, cursors, online flag, last_server) | +| `server_info` | Server heartbeat (id, enable, data_version, last_update) | +| `curios` | Curios inventory (only if Curios mod loaded) | +| `backpack_data` | Sophisticated Backpacks NBT data (only if mod loaded) | +| `mekanism_personal_chest` | Mekanism personal chest inventory data (only if Mekanism loaded) | + +The mod runs **automatic schema migration** — it checks column counts and data types on startup and alters tables as needed. + +--- + +### 4.2 `VanillaSync.java` — Core Sync Engine + +**Path:** `src/main/java/vip/fubuki/playersync/sync/VanillaSync.java` + +The largest and most critical class. All methods are `static` and registered via `@Mod.EventBusSubscriber`. + +#### Thread Pool + +``` +CORE: 2 threads, MAX: 8 threads, QUEUE: 256 (LinkedBlockingQueue) +Rejection policy: CallerRunsPolicy (backpressure) +``` + +All DB operations run via `executorService.submit(...)` to avoid blocking the server thread. The bounded thread pool with `CallerRunsPolicy` was a fix for #169 — previously an unbounded `CachedThreadPool` could spawn 25000+ threads under load. + +#### Event Handlers + +| Event | Method | Behavior | +|---|---|---| +| `OnDatapackSyncEvent` | `onDataPackSyncEvent()` | Restore advancements JSON from DB to disk, then reload | +| `PlayerNegotiationEvent` | `onPlayerConnect()` | Pre-login check: kick if player is already online on another active server (5-minute heartbeat window) | +| `PlayerLoggedInEvent` | `onPlayerJoin()` | Full data restore: health, food, XP, score, inventory, armor, ender chest, effects, left hand, cursor. Returns generic placeholders for unknown modded items. | +| `PlayerEvent.SaveToFile` | `onPlayerSaveToFile()` | Save current state to DB (skipped if player not yet synced) | +| `PlayerLoggedOutEvent` | `onPlayerLogout()` | Mark offline in DB, save data, handle dead/dying edge cases | +| `LivingDeathEvent` | `onPlayerDeath()` | Cache Curios data (for keepInventory worlds) | +| `TickEvent.LevelTick` | `onUpdate()` | Server heartbeat: update `last_update` every 1800 ticks (~90s) | +| `TickEvent.ServerTick` | `onServerTick()` | Auto-save all online players every 1200 ticks (~1 min); clean expired Curios cache every 36000 ticks (~30 min) | +| `ServerStoppedEvent` | `onServerShutdown()` | Mark server as disabled in DB | + +#### Serialization + +- **New format (default):** Base64 with `B64:` prefix (`B64:`) +- **Legacy format:** Custom character replacement (`|`, `^`, `<`, `>`, `~` for `,`, `"`, `{`, `}`, `'`) +- The mod **reads both formats** but writes using the configured format (`use_legacy_serialization` config flag) +- NBT is always tagged with the current data version for forward compatibility + +#### Placeholder Items + +When an item from one server's mod set doesn't exist on another, it becomes a **paper item** with: +- Red italic name: "Item Voucher" (customizable) +- Lore showing the original item ID and stack count +- Stored `playersync:original_item_nbt` tag for later restoration +- Unique UUID to prevent stacking + +#### XP Calculation + +- `getTotalExperience()` — converts level+progress to absolute XP using Minecraft's level curve formulas (different for levels 0-15, 16-30, 31+) +- `setXpForPlayer()` — inverse: distributes absolute XP into level+progress, bypassing `giveExperience()` side effects (events, packets, score) + +#### Dead/Dying Player Edge Case + +When a player dies during login (`isDeadOrDying()`), the mod: +1. Clears the `player_synced` tag +2. Teleports to respawn point +3. Sets health to 1 +4. Updates online status +5. Disconnects player with a reconnect message + +This avoids saving corrupted "dead" state as the player's synced data. + +--- + +### 4.3 `ChatSync.java` — Chat Sync Orchestrator + +**Path:** `src/main/java/vip/fubuki/playersync/sync/ChatSync.java` + +Starts both the **TCP server** (if configured as chat server) and **TCP client** on separate threads. + +- `ChatSyncServer` (port 7900 default) — accepts client connections, broadcasts received messages to all other connected clients +- `ChatSyncClient` — connects to the chat server, relays `ServerChatEvent` messages, receives and broadcasts messages from the server to local players +- Uses exponential backoff for reconnect (5s base, up to 60s, max 10 attempts) + +--- + +### 4.4 `JdbcConfig.java` — Configuration + +**Path:** `src/main/java/vip/fubuki/playersync/config/JdbcConfig.java` + +All configuration is done via Forge's `ForgeConfigSpec` system (TOML file on server). Key entries: + +| Config Key | Default | Description | +|---|---|---| +| `host` | `localhost` | MySQL host | +| `db_port` | `3306` | MySQL port | +| `use_ssl` | `false` | SSL for DB connection | +| `user_name` | `playersync` | DB username | +| `password` | `pleaseChangeThisPassword` | DB password | +| `db_name` | `playersync` | Database name | +| `Server_id` | random int | Unique server identifier | +| `sync_world` | `[]` | World names for advancements (empty = auto-detect) | +| `sync_advancements` | `true` | Enable advancement sync | +| `sync_chat` | `false` | Enable cross-server chat | +| `IsChatServer` | `false` | This server acts as chat relay host | +| `ChatServerIP` | `127.0.0.1` | Chat server address | +| `ChatServerPort` | `7900` | Chat server port | +| `kick_when_already_online` | `true` | Prevent multi-server login | +| `use_legacy_serialization` | `false` | Use old serialization format | +| `item_placeholder_title_override` | `""` | Custom placeholder title | +| `item_placeholder_description_override` | `""` | Custom placeholder description | +| `sync_mekanism_personal_chest` | `false` | Sync Mekanism personal chest inventories | + +--- + +### 4.5 `JDBCsetUp.java` — Database Utility + +**Path:** `src/main/java/vip/fubuki/playersync/util/JDBCsetUp.java` + +Encapsulates all MySQL operations: + +| Method | Purpose | +|---|---| +| `getConnection(selectDatabase)` | Create JDBC connection; `selectDatabase=false` for DDL statements | +| `executeQuery()` | Run SELECT, returns `QueryResult` record | +| `executeUpdate()` | Run INSERT/UPDATE/DELETE/DDL with database | +| `executeUpdateWithoutDatabase()` | Run DDL without default database | +| `update()` | Parameterized update (anti-SQL-injection) | + +The `QueryResult` record (`Connection`, `PreparedStatement`, `ResultSet`) implements `AutoCloseable` for try-with-resources support. + +**Note:** SQL queries in `VanillaSync` use **string concatenation** for UUID values rather than parameterized queries — this is a potential SQL injection risk if UUIDs could be attacker-controlled (though Minecraft UUIDs are generally safe). The `JDBCsetUp.update()` method does provide parameterized queries but is not used for the main sync queries. + +--- + +### 4.6 `LocalJsonUtil.java` — Lightweight Map Parser + +**Path:** `src/main/java/vip/fubuki/playersync/util/LocalJsonUtil.java` + +A minimal parser that converts Java `Map.toString()` output back into `Map` or `Map`. Used because the item/effect data is stored as `{0=..., 1=..., 2=...}` format (Java map string representation), not standard JSON. + +Uses `=` as key-value separator (not `:`), matching Java's `Map.toString()` format. + +--- + +### 4.7 `PSThreadPoolFactory.java` — Named Thread Factory + +**Path:** `src/main/java/vip/fubuki/playersync/util/PSThreadPoolFactory.java` + +Simple `ThreadFactory` implementation producing threads named `PlayerSync-thread-N` for easier debugging. + +--- + +### 4.8 `CommandInit.java` — Command Registration + +**Path:** `src/main/java/vip/fubuki/playersync/CommandInit.java` + +Registers the `/playersync` command (permission level 2 required). Currently has a `reconnect` subcommand that is a stub (returns 0, implementation commented out). + +--- + +### 4.9 Addon Support (`ModsSupport.java` & `CuriosCache.java`) + +#### Curios Integration +- **Save:** Serializes all Curios slot items as `{slotType:index=serializedNbt, ...}` map, stored in `curios` table +- **Restore:** Clears all Curios slots, reads DB data, deserializes with placeholder support +- **Death handling:** `CuriosCache` snapshots Curios inventory on death (keepInventory worlds only), used on logout to prevent empty-save bug from the "Title Screen" disconnect case +- Cache entries expire after 1 hour + +#### Sophisticated Backpacks Integration +- **Save:** Iterates backpack items via `PlayerInventoryProvider.runOnBackpacks()`, extracts `contentsUuid` via `NBTHelper`, stores NBT in `backpack_data` table using `REPLACE INTO` +- **Restore:** On player join, reads backpack NBT from DB and calls `BackpackStorage.get().setBackpackContents()` to inject it + +#### Mekanism Integration + +**Jetpack Mode (already synced):** The jetpack mode (`NORMAL`/`HOVER`/`DISABLED`) is stored in the ItemStack NBT at path `mekData.mode`. PlayerSync's existing `serializeNBT()` → Base64 pipeline preserves all ItemStack NBT including `mekData.*`, so jetpack state is automatically synced without any additional code. The `PlayerState.activeJetpacks` set is purely runtime memory (used for sound/particle effects) and is cleared by Mekanism on player logout — it does not need persistence. + +**Personal Chest (new sync):** Personal chest inventories are stored in Mekanism's world-level `SavedData` files (`/data/mekanism_personal_storage/`), NOT on the ItemStack. The ItemStack only carries a UUID reference (`mekData.personalStorageId`). When a player moves between servers, the ItemStack is restored but the target server's `SavedData` has no inventory for that UUID — the chest appears empty. + +`MekanismSupport.java` solves this by: + +- **Save:** Iterates the player's inventory + ender chest, finds every item with a `mekData.personalStorageId` tag. Uses reflection to call `PersonalStorageManager.getInventoryIfPresent(stack)`, then serializes all 54 `IInventorySlot` entries via `slot.serializeNBT()` into a single NBT `CompoundTag`, which is Base64-encoded and stored in the `mekanism_personal_chest` table keyed by `storage_id`. + +- **Restore:** After inventory is restored (in `doPlayerJoin()`), iterates the player's items again to find personal chests. For each, queries the DB for saved inventory data. Uses reflection to call `PersonalStorageManager.getInventoryFor(stack)` to create/retrieve the `SavedData` entry on the target server, then calls `slot.deserializeNBT()` on each of the 54 slots. + +- **Reflection:** `PersonalStorageManager` and `PersonalStorageData` are in Mekanism's implementation JAR (not the API JAR), so access is via `Class.forName()` + `Method.invoke()`. The `IInventorySlot` interface IS in the API JAR and is used directly at compile time. + +- **Config:** Controlled by `sync_mekanism_personal_chest` (default `false`). + +| Method | Purpose | +|---|---| +| `collectPersonalChestItems(player)` | Scan inventory + ender chest for personal chest items, return map of storageId → ItemStack | +| `savePersonalChestData(player)` | Serialize each chest's 54 slots, write to DB via `REPLACE INTO` | +| `restorePersonalChestData(player)` | Read from DB, call `getInventoryFor()`, deserialize slots into target world's `SavedData` | +| `getInventoryIfPresent(stack)` | Reflection wrapper for `PersonalStorageManager.getInventoryIfPresent()` | +| `getInventoryFor(stack)` | Reflection wrapper for `PersonalStorageManager.getInventoryFor()` | +| `getInventorySlots(inventory)` | Reflection wrapper for `AbstractPersonalStorageItemInventory.getInventorySlots(null)` | +| `getPersonalStorageId(stack)` | Extract `personalStorageId` UUID from ItemStack NBT `mekData` | + +## 5. Synchronized Data + +| Data | Storage Column | Type | Format | +|---|---|---|---| +| Inventory (36 slots) | `inventory` | MEDIUMBLOB | `{0=B64:..., 1=B64:..., ...}` | +| Armor (4 slots) | `armor` | BLOB | `{0=B64:..., ...}` | +| Off-hand | `left_hand` | BLOB | Base64 NBT string | +| Cursor (carried) | `cursors` | BLOB | Base64 NBT string | +| Ender Chest (27 slots) | `enderchest` | MEDIUMBLOB | `{0=B64:..., ...}` | +| Advancements | `advancements` | MEDIUMBLOB | Raw JSON bytes | +| Active Effects | `effects` | BLOB | `{effectId=B64:nbt, ...}` | +| XP | `xp` | INT | Absolute XP value | +| Health | `health` | INT | HP as integer | +| Food Level | `food_level` | INT | 0-20 | +| Score | `score` | INT | Display score | +| Online Status | `online` | TINYINT | 0 or 1 | +| Last Server | `last_server` | INT | Server ID | +| Curios | `curios_item` (separate table) | BLOB | `{slotType:index=B64:..., ...}` | +| Backpacks | `backpack_nbt` (separate table) | MEDIUMBLOB | Base64 NBT string | +| Mekanism Personal Chest | `inventory_data` (separate table) | MEDIUMBLOB | Base64 NBT of 54 slots (via `IInventorySlot.serializeNBT()`) | + +--- + +## 6. Lifecycle Events Summary + +``` +Server Starting +├── commonSetup: register JDBC driver, register VanillaSync, (optionally) ChatSync +└── onServerStarting: + ├── CREATE DATABASE IF NOT EXISTS + ├── CREATE/ALTER player_data table + ├── CREATE/ALTER server_info table + ├── CREATE curios table (if Curios loaded) + ├── CREATE backpack_data table (if SophisticatedBackpacks loaded) + ├── CREATE mekanism_personal_chest table (if Mekanism loaded) + ├── INSERT/UPDATE server_info (heartbeat) + └── Reset stale online flags + +Player Connecting +├── onPlayerConnect: check already-online, kick if needed +├── onDataPackSyncEvent: write advancements JSON to disk +└── onPlayerJoin: + ├── Restore: health, food, XP, score + ├── Restore: left_hand, cursors, armor, inventory, enderchest, effects + ├── Restore: Curios items (ModsSupport) + ├── Restore: Backpack data (ModsSupport) + └── Restore: Mekanism personal chest data (MekanismSupport) + +Player Playing +├── onPlayerSaveToFile: save() to DB (per save event) +├── onServerTick: auto-save all players every ~1 min +├── onUpdate: heartbeat every ~90s +└── ServerChatEvent → ChatSyncClient → TCP + +Player Logging Out +├── onPlayerDeath: cache Curios (if keepInventory) +└── onPlayerLogout: + ├── Dead/dying: use CuriosCache, don't save new data + ├── Sync incomplete: skip save (safety) + └── Normal: store() + ModsSupport.onPlayerLeave() + +Server Shutting Down +├── onServerShutdown: mark server disabled +└── onServerStopping: shutdown ChatSync +``` + +--- + +## 7. Key Design Decisions + +1. **Thread pool bounded with CallerRunsPolicy** — Fix for #169. Prevents thread explosion under concurrent player load by limiting to 8 threads + 256 queue, with natural backpressure. + +2. **Base64 NBT serialization** — Replaced legacy character-replacement format. Backward-compatible read of both formats; config flag to force legacy writes. + +3. **Placeholder items for missing mods** — When a modded item's registry entry doesn't exist on the current server (e.g., player moved from Mekanism server to vanilla), a paper voucher preserves the original NBT so the item can be restored when the player returns. + +4. **Dead/dying player handling** — Special case for players who die during the login process or disconnect via "Title Screen" while dead. Uses `CuriosCache` to snapshot Curios data on death so it's not lost on logout. + +5. **Advancement sync via filesystem** — Rather than direct NBT manipulation, advancements are synced by writing the JSON file to the world's advancements directory and calling `reload()`. This leverages Minecraft's built-in advancement loading. + +6. **Schema auto-migration** — The mod checks column existence and data types on startup and alters tables as needed, supporting upgrades without manual SQL. + +7. **Server heartbeat system** — Each server writes `last_update` every ~90s. Other servers check this to determine if a player is genuinely online elsewhere (5-minute window) before kicking. + +8. **Reflection-based mod addon support** — Third-party mod data whose storage classes are not in the public API JAR (like Mekanism's `PersonalStorageManager`) are accessed via `Class.forName()` + `Method.invoke()`. This avoids hard-compile-dependencies on implementation JARs while still enabling deep data sync at runtime. The compile-time dependency stays on the API JAR only (`IInventorySlot`, `NBTConstants`). diff --git a/gradle.properties b/gradle.properties index bc39a45..9faa9c8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -47,3 +47,7 @@ mod_description=make multiserver players' data sync # JDBC driver version # see https://dev.mysql.com/doc/relnotes/connector-j/en/ for latest version jdbc_version=9.3.0 + +# Mek Version +# see https://modmaven.dev/mekanism/Mekanism/ for latest version +mekanism_version=1.20.1-10.4.9.61 diff --git a/src/main/java/vip/fubuki/playersync/PlayerSync.java b/src/main/java/vip/fubuki/playersync/PlayerSync.java index 1bf8d0f..def5797 100644 --- a/src/main/java/vip/fubuki/playersync/PlayerSync.java +++ b/src/main/java/vip/fubuki/playersync/PlayerSync.java @@ -173,6 +173,19 @@ public class PlayerSync { addColumnIfNotExists("backpack_data", "uuid", "CHAR(36) NOT NULL", true); } + // Create mekanism_personal_chest table if Mekanism is loaded + if (ModList.get().isLoaded("mekanism")) { + JDBCsetUp.executeUpdate( + "CREATE TABLE IF NOT EXISTS `" + dbName + "`.`mekanism_personal_chest` (" + + "player_uuid CHAR(36) NOT NULL," + + "storage_id CHAR(36) NOT NULL," + + "inventory_data MEDIUMBLOB," + + "PRIMARY KEY (storage_id)," + + "INDEX idx_player_uuid (player_uuid)" + + ");" + ); + } + // Check and alter the 'advancements' column in player_data if necessary JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executeQuery( "SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " + diff --git a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java index 6a472c1..fc205a6 100644 --- a/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java +++ b/src/main/java/vip/fubuki/playersync/config/JdbcConfig.java @@ -26,6 +26,7 @@ public class JdbcConfig { public static final ForgeConfigSpec.ConfigValue CHAT_SERVER_IP; public static final ForgeConfigSpec.IntValue CHAT_SERVER_PORT; public static final ForgeConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION; + public static final ForgeConfigSpec.BooleanValue SYNC_MEKANISM_PERSONAL_CHEST; public static final ForgeConfigSpec.ConfigValue SERVER_ID; @@ -61,6 +62,9 @@ public class JdbcConfig { ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER .comment("Override the description of placeholder items which are unavailable on the current server.") .define("item_placeholder_description_override", ""); + SYNC_MEKANISM_PERSONAL_CHEST = COMMON_BUILDER + .comment("Whether to sync Mekanism personal chest inventories across servers.") + .define("sync_mekanism_personal_chest", false); COMMON_BUILDER.pop(); COMMON_CONFIG = COMMON_BUILDER.build(); diff --git a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java index 978aac1..66514ec 100644 --- a/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java +++ b/src/main/java/vip/fubuki/playersync/sync/VanillaSync.java @@ -39,6 +39,7 @@ import net.minecraftforge.server.ServerLifecycleHooks; import vip.fubuki.playersync.PlayerSync; import vip.fubuki.playersync.config.JdbcConfig; import vip.fubuki.playersync.sync.addons.CuriosCache; +import vip.fubuki.playersync.sync.addons.MekanismSupport; import vip.fubuki.playersync.sync.addons.ModsSupport; import vip.fubuki.playersync.util.JDBCsetUp; import vip.fubuki.playersync.util.LocalJsonUtil; @@ -339,6 +340,8 @@ public class VanillaSync { modsSupport.doBackPackRestore(serverPlayer); + MekanismSupport.restorePersonalChestData(serverPlayer); + serverPlayer.addTag("player_synced"); rs2.close(); @@ -655,6 +658,8 @@ public class VanillaSync { ModsSupport.storeSophisticatedBackpacks(player); } + MekanismSupport.savePersonalChestData(player); + // Effects Map effects = player.getActiveEffectsMap(); Map effectMap = new HashMap<>(); @@ -783,6 +788,13 @@ public class VanillaSync { PlayerSync.LOGGER.error("Error auto-saving Curios data for player " + player.getUUID(), e); } }); + executorService.submit(() -> { + try { + MekanismSupport.savePersonalChestData(player); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error auto-saving Mekanism data for player " + player.getUUID(), e); + } + }); } } } diff --git a/src/main/java/vip/fubuki/playersync/sync/addons/MekanismSupport.java b/src/main/java/vip/fubuki/playersync/sync/addons/MekanismSupport.java new file mode 100644 index 0000000..3539671 --- /dev/null +++ b/src/main/java/vip/fubuki/playersync/sync/addons/MekanismSupport.java @@ -0,0 +1,221 @@ +package vip.fubuki.playersync.sync.addons; + +import mekanism.api.inventory.IInventorySlot; +import mekanism.common.lib.inventory.personalstorage.AbstractPersonalStorageItemInventory; +import mekanism.common.lib.inventory.personalstorage.PersonalStorageManager; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.nbt.ListTag; +import net.minecraft.nbt.Tag; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.ItemStack; +import net.minecraftforge.fml.ModList; +import vip.fubuki.playersync.PlayerSync; +import vip.fubuki.playersync.config.JdbcConfig; +import vip.fubuki.playersync.util.JDBCsetUp; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.*; + +public class MekanismSupport { + + private static final String MEK_DATA = "mekData"; + private static final String PERSONAL_STORAGE_ID = "personalStorageId"; + + /** + * Call PersonalStorageManager.getInventoryIfPresent(stack) directly. + */ + static Optional getInventoryIfPresent(ItemStack stack) { + try { + return PersonalStorageManager.getInventoryIfPresent(stack); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error calling PersonalStorageManager.getInventoryIfPresent", e); + return Optional.empty(); + } + } + + static Optional getInventoryFor(ItemStack stack) { + try { + return PersonalStorageManager.getInventoryFor(stack); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error calling PersonalStorageManager.getInventoryFor", e); + return Optional.empty(); + } + } + + /** + * Get inventory slots directly from PersonalStorageInventory. + */ + static List getInventorySlots(AbstractPersonalStorageItemInventory inventory) { + try { + return inventory.getInventorySlots(null); + } catch (Exception e) { + PlayerSync.LOGGER.error("Error calling getInventorySlots", e); + return Collections.emptyList(); + } + } + + /** + * Collect all personal chest ItemStacks from a player's inventory + ender chest. + * Returns map: personalStorageId UUID string → ItemStack + */ + public static Map collectPersonalChestItems(Player player) { + Map result = new LinkedHashMap<>(); + List allItems = new ArrayList<>(); + + // Main inventory + for (int i = 0; i < player.getInventory().items.size(); i++) { + allItems.add(player.getInventory().items.get(i)); + } + // Armor + for (int i = 0; i < player.getInventory().armor.size(); i++) { + allItems.add(player.getInventory().armor.get(i)); + } + // Off-hand + allItems.add(player.getInventory().offhand.get(0)); + // Ender chest + for (int i = 0; i < player.getEnderChestInventory().getContainerSize(); i++) { + allItems.add(player.getEnderChestInventory().getItem(i)); + } + + for (ItemStack stack : allItems) { + String storageId = getPersonalStorageId(stack); + if (storageId != null) { + result.put(storageId, stack); + } + } + return result; + } + + /** + * Extract the personalStorageId UUID from an ItemStack's mekData NBT. + */ + public static String getPersonalStorageId(ItemStack stack) { + if (stack.isEmpty()) + return null; + CompoundTag tag = stack.getTag(); + if (tag == null || !tag.contains(MEK_DATA, Tag.TAG_COMPOUND)) + return null; + CompoundTag mekData = tag.getCompound(MEK_DATA); + if (mekData.contains(PERSONAL_STORAGE_ID)) { + return mekData.getString(PERSONAL_STORAGE_ID); + } + return null; + } + + /** + * Save all personal chest inventory data to the database. + */ + public static void savePersonalChestData(Player player) throws SQLException { + if (!ModList.get().isLoaded("mekanism") || !JdbcConfig.SYNC_MEKANISM_PERSONAL_CHEST.get()) + return; + + Map chestItems = collectPersonalChestItems(player); + if (chestItems.isEmpty()) + return; + + for (Map.Entry entry : chestItems.entrySet()) { + String storageId = entry.getKey(); + ItemStack stack = entry.getValue(); + + Optional invOpt = getInventoryIfPresent(stack); + if (invOpt.isEmpty()) + continue; + + AbstractPersonalStorageItemInventory inventory = invOpt.get(); + List slots = getInventorySlots(inventory); + + // Serialize all slots into a CompoundTag + CompoundTag rootTag = new CompoundTag(); + ListTag slotList = new ListTag(); + for (int i = 0; i < slots.size(); i++) { + IInventorySlot slot = slots.get(i); + CompoundTag slotTag = slot.serializeNBT(); + slotTag.putInt("SlotIndex", i); + slotList.add(slotTag); + } + rootTag.put("Slots", slotList); + rootTag.putInt("SlotCount", slots.size()); + + String serialized = vip.fubuki.playersync.sync.VanillaSync.serialize(rootTag.toString()); + + JDBCsetUp.executeUpdate( + "REPLACE INTO mekanism_personal_chest (player_uuid, storage_id, inventory_data) VALUES ('%s', '%s', '%s')", + player.getUUID().toString(), + storageId, + serialized); + } + PlayerSync.LOGGER.debug("Saved {} personal chest(s) for player {}", + chestItems.size(), player.getUUID()); + } + + /** + * Restore personal chest inventory data from the database. + * Should be called AFTER inventory/enderchest items are restored. + */ + public static void restorePersonalChestData(Player player) throws SQLException { + if (!ModList.get().isLoaded("mekanism") || !JdbcConfig.SYNC_MEKANISM_PERSONAL_CHEST.get()) + return; + + Map chestItems = collectPersonalChestItems(player); + if (chestItems.isEmpty()) + return; + + int restored = 0; + for (Map.Entry entry : chestItems.entrySet()) { + String storageId = entry.getKey(); + ItemStack stack = entry.getValue(); + + // Query saved data from DB + JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery( + "SELECT inventory_data FROM mekanism_personal_chest WHERE storage_id = '%s'", + storageId); + ResultSet rs = qr.resultSet(); + if (!rs.next()) { + rs.close(); + qr.connection().close(); + continue; + } + + String serialized = rs.getString("inventory_data"); + rs.close(); + qr.connection().close(); + + if (serialized == null || serialized.isEmpty()) + continue; + + // Deserialize + String nbtString = vip.fubuki.playersync.sync.VanillaSync.deserializeString(serialized); + CompoundTag rootTag; + try { + rootTag = net.minecraft.nbt.TagParser.parseTag(nbtString); + } catch (Exception e) { + PlayerSync.LOGGER.error("Failed to parse personal chest NBT for storage {}", storageId, e); + continue; + } + + // Ensure inventory exists in target world's SavedData + Optional invOpt = getInventoryFor(stack); + if (invOpt.isEmpty()) + continue; + + AbstractPersonalStorageItemInventory inventory = invOpt.get(); + List slots = getInventorySlots(inventory); + ListTag slotList = rootTag.getList("Slots", Tag.TAG_COMPOUND); + + for (int i = 0; i < slotList.size(); i++) { + CompoundTag slotTag = slotList.getCompound(i); + int slotIndex = slotTag.getInt("SlotIndex"); + slotTag.remove("SlotIndex"); + if (slotIndex >= 0 && slotIndex < slots.size()) { + slots.get(slotIndex).deserializeNBT(slotTag); + } + } + restored++; + } + if (restored > 0) { + PlayerSync.LOGGER.info("Restored {} personal chest(s) for player {}", + restored, player.getUUID()); + } + } +} \ No newline at end of file