Merge pull request #172 from mlus-asuka/fix/169-bounded-thread-pool Fix/169 bounded thread pool
This commit is contained in:
parent
e15c9b335e
commit
ac20ad327c
|
|
@ -29,6 +29,7 @@ repositories {
|
||||||
includeGroup "curse.maven"
|
includeGroup "curse.maven"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maven { url 'https://modmaven.dev/' }
|
||||||
}
|
}
|
||||||
|
|
||||||
base {
|
base {
|
||||||
|
|
@ -138,6 +139,14 @@ dependencies {
|
||||||
compileOnly "curse.maven:curios-309927:5266541"
|
compileOnly "curse.maven:curios-309927:5266541"
|
||||||
compileOnly "curse.maven:sophisticated-backpacks-422301:7169843"
|
compileOnly "curse.maven:sophisticated-backpacks-422301:7169843"
|
||||||
compileOnly "curse.maven:sophisticated-core-618298:7169400"
|
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
|
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||||
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||||
|
|
|
||||||
406
docs/code-analysis.md
Normal file
406
docs/code-analysis.md
Normal file
|
|
@ -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:<base64-nbt-string>`)
|
||||||
|
- **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<String, String>` or `Map<Integer, String>`. 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 (`<world>/data/mekanism_personal_storage/<uuid>`), 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`).
|
||||||
|
|
@ -47,3 +47,7 @@ mod_description=make multiserver players' data sync
|
||||||
# JDBC driver version
|
# JDBC driver version
|
||||||
# see https://dev.mysql.com/doc/relnotes/connector-j/en/ for latest version
|
# see https://dev.mysql.com/doc/relnotes/connector-j/en/ for latest version
|
||||||
jdbc_version=9.3.0
|
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
|
||||||
|
|
|
||||||
|
|
@ -173,6 +173,19 @@ public class PlayerSync {
|
||||||
addColumnIfNotExists("backpack_data", "uuid", "CHAR(36) NOT NULL", true);
|
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
|
// Check and alter the 'advancements' column in player_data if necessary
|
||||||
JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executeQuery(
|
JDBCsetUp.QueryResult advColCheck = JDBCsetUp.executeQuery(
|
||||||
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
"SELECT DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ public class JdbcConfig {
|
||||||
public static final ForgeConfigSpec.ConfigValue<String> CHAT_SERVER_IP;
|
public static final ForgeConfigSpec.ConfigValue<String> CHAT_SERVER_IP;
|
||||||
public static final ForgeConfigSpec.IntValue CHAT_SERVER_PORT;
|
public static final ForgeConfigSpec.IntValue CHAT_SERVER_PORT;
|
||||||
public static final ForgeConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
public static final ForgeConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||||
|
public static final ForgeConfigSpec.BooleanValue SYNC_MEKANISM_PERSONAL_CHEST;
|
||||||
|
|
||||||
public static final ForgeConfigSpec.ConfigValue<Integer> SERVER_ID;
|
public static final ForgeConfigSpec.ConfigValue<Integer> SERVER_ID;
|
||||||
|
|
||||||
|
|
@ -61,6 +62,9 @@ public class JdbcConfig {
|
||||||
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER
|
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER
|
||||||
.comment("Override the description of placeholder items which are unavailable on the current server.")
|
.comment("Override the description of placeholder items which are unavailable on the current server.")
|
||||||
.define("item_placeholder_description_override", "");
|
.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_BUILDER.pop();
|
||||||
COMMON_CONFIG = COMMON_BUILDER.build();
|
COMMON_CONFIG = COMMON_BUILDER.build();
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ import net.minecraftforge.server.ServerLifecycleHooks;
|
||||||
import vip.fubuki.playersync.PlayerSync;
|
import vip.fubuki.playersync.PlayerSync;
|
||||||
import vip.fubuki.playersync.config.JdbcConfig;
|
import vip.fubuki.playersync.config.JdbcConfig;
|
||||||
import vip.fubuki.playersync.sync.addons.CuriosCache;
|
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.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;
|
||||||
|
|
@ -339,6 +340,8 @@ public class VanillaSync {
|
||||||
|
|
||||||
modsSupport.doBackPackRestore(serverPlayer);
|
modsSupport.doBackPackRestore(serverPlayer);
|
||||||
|
|
||||||
|
MekanismSupport.restorePersonalChestData(serverPlayer);
|
||||||
|
|
||||||
serverPlayer.addTag("player_synced");
|
serverPlayer.addTag("player_synced");
|
||||||
|
|
||||||
rs2.close();
|
rs2.close();
|
||||||
|
|
@ -655,6 +658,8 @@ public class VanillaSync {
|
||||||
ModsSupport.storeSophisticatedBackpacks(player);
|
ModsSupport.storeSophisticatedBackpacks(player);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
MekanismSupport.savePersonalChestData(player);
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
Map<MobEffect, MobEffectInstance> effects = player.getActiveEffectsMap();
|
Map<MobEffect, MobEffectInstance> effects = player.getActiveEffectsMap();
|
||||||
Map<Integer, String> effectMap = new HashMap<>();
|
Map<Integer, String> effectMap = new HashMap<>();
|
||||||
|
|
@ -783,6 +788,13 @@ public class VanillaSync {
|
||||||
PlayerSync.LOGGER.error("Error auto-saving Curios data for player " + player.getUUID(), e);
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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<AbstractPersonalStorageItemInventory> getInventoryIfPresent(ItemStack stack) {
|
||||||
|
try {
|
||||||
|
return PersonalStorageManager.getInventoryIfPresent(stack);
|
||||||
|
} catch (Exception e) {
|
||||||
|
PlayerSync.LOGGER.error("Error calling PersonalStorageManager.getInventoryIfPresent", e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Optional<AbstractPersonalStorageItemInventory> 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<IInventorySlot> 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<String, ItemStack> collectPersonalChestItems(Player player) {
|
||||||
|
Map<String, ItemStack> result = new LinkedHashMap<>();
|
||||||
|
List<ItemStack> 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<String, ItemStack> chestItems = collectPersonalChestItems(player);
|
||||||
|
if (chestItems.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (Map.Entry<String, ItemStack> entry : chestItems.entrySet()) {
|
||||||
|
String storageId = entry.getKey();
|
||||||
|
ItemStack stack = entry.getValue();
|
||||||
|
|
||||||
|
Optional<AbstractPersonalStorageItemInventory> invOpt = getInventoryIfPresent(stack);
|
||||||
|
if (invOpt.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
AbstractPersonalStorageItemInventory inventory = invOpt.get();
|
||||||
|
List<IInventorySlot> 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<String, ItemStack> chestItems = collectPersonalChestItems(player);
|
||||||
|
if (chestItems.isEmpty())
|
||||||
|
return;
|
||||||
|
|
||||||
|
int restored = 0;
|
||||||
|
for (Map.Entry<String, ItemStack> 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<AbstractPersonalStorageItemInventory> invOpt = getInventoryFor(stack);
|
||||||
|
if (invOpt.isEmpty())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
AbstractPersonalStorageItemInventory inventory = invOpt.get();
|
||||||
|
List<IInventorySlot> 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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user