Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 018d0dfc33 | |||
|
|
ac20ad327c | ||
|
|
e15c9b335e | ||
|
|
b6de595c41 | ||
|
|
8df3b97356 | ||
|
|
9ce83763c9 | ||
|
|
c4e18e61a8 | ||
|
|
8ff5d357a0 | ||
|
|
235d95144f | ||
|
|
4e4ad80a95 | ||
|
|
32f2e2d75e | ||
|
|
5764e85647 | ||
| 86d6393c87 | |||
|
|
5632be3d3d | ||
|
|
1c5f3cddd4 | ||
|
|
a367eb0e3e | ||
|
|
a47bc4bf80 | ||
|
|
c3750da764 | ||
|
|
865926bc54 | ||
|
|
66808f2242 | ||
|
|
ce07fed83d | ||
|
|
5583424e22 | ||
|
|
4ac5d77345 | ||
|
|
dfabb42c6b | ||
|
|
f4584d58b5 | ||
|
|
3291fc54b2 | ||
|
|
3e70f4b801 | ||
|
|
5696272781 | ||
|
|
b062331cce | ||
|
|
a05b0b0375 | ||
|
|
d78c84d8ce | ||
|
|
8b112ecc86 | ||
|
|
176d123f4e | ||
|
|
63a21ce5cc | ||
|
|
fe1e7584d4 |
4
.github/workflows/backport-prs.yml
vendored
4
.github/workflows/backport-prs.yml
vendored
|
|
@ -27,9 +27,9 @@ jobs:
|
||||||
startsWith(github.event.comment.body, '/backport')
|
startsWith(github.event.comment.body, '/backport')
|
||||||
)
|
)
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v5
|
- uses: actions/checkout@v6
|
||||||
- name: Create backport pull requests
|
- name: Create backport pull requests
|
||||||
uses: korthout/backport-action@v3
|
uses: korthout/backport-action@v4
|
||||||
with:
|
with:
|
||||||
github_token: ${{ secrets.TOKEN }}
|
github_token: ${{ secrets.TOKEN }}
|
||||||
pull_description: |
|
pull_description: |
|
||||||
|
|
|
||||||
8
.github/workflows/build.yml
vendored
8
.github/workflows/build.yml
vendored
|
|
@ -16,10 +16,10 @@ jobs:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
- name: Validate Gradle Wrapper
|
- name: Validate Gradle Wrapper
|
||||||
uses: gradle/actions/wrapper-validation@v5
|
uses: gradle/actions/wrapper-validation@v6
|
||||||
|
|
||||||
- name: Set up JDK 17
|
- name: Set up JDK 17
|
||||||
uses: actions/setup-java@v5
|
uses: actions/setup-java@v5
|
||||||
|
|
@ -28,13 +28,13 @@ jobs:
|
||||||
distribution: 'temurin'
|
distribution: 'temurin'
|
||||||
|
|
||||||
- name: Setup Gradle
|
- name: Setup Gradle
|
||||||
uses: gradle/actions/setup-gradle@v5
|
uses: gradle/actions/setup-gradle@v6
|
||||||
|
|
||||||
- name: Build with Gradle
|
- name: Build with Gradle
|
||||||
run: ./gradlew build
|
run: ./gradlew build
|
||||||
|
|
||||||
- name: Build Artifact
|
- name: Build Artifact
|
||||||
uses: actions/upload-artifact@v5
|
uses: actions/upload-artifact@v7
|
||||||
with:
|
with:
|
||||||
name: Player_Sync
|
name: Player_Sync
|
||||||
path: |
|
path: |
|
||||||
|
|
|
||||||
13
build.gradle
13
build.gradle
|
|
@ -29,6 +29,7 @@ repositories {
|
||||||
includeGroup "curse.maven"
|
includeGroup "curse.maven"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
maven { url 'https://modmaven.dev/' }
|
||||||
}
|
}
|
||||||
|
|
||||||
base {
|
base {
|
||||||
|
|
@ -136,8 +137,16 @@ dependencies {
|
||||||
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it
|
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it
|
||||||
// modLocalRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
|
// modLocalRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
|
||||||
compileOnly "curse.maven:curios-309927:5266541"
|
compileOnly "curse.maven:curios-309927:5266541"
|
||||||
compileOnly "curse.maven:sophisticated-backpacks-422301:6303388"
|
compileOnly "curse.maven:sophisticated-backpacks-422301:7169843"
|
||||||
compileOnly "curse.maven:sophisticated-core-618298:6317048"
|
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`).
|
||||||
|
|
@ -34,7 +34,7 @@ mod_name=PlayerSync
|
||||||
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
# The license of the mod. Review your options at https://choosealicense.com/. All Rights Reserved is the default.
|
||||||
mod_license=GPL-3.0 license
|
mod_license=GPL-3.0 license
|
||||||
# The mod version. See https://semver.org/
|
# The mod version. See https://semver.org/
|
||||||
mod_version=2.1.4
|
mod_version=2.1.6
|
||||||
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
# The group ID for the mod. It is only important when publishing as an artifact to a Maven repository.
|
||||||
# This should match the base package used for the mod sources.
|
# This should match the base package used for the mod sources.
|
||||||
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
# See https://maven.apache.org/guides/mini/guide-naming-conventions.html
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -58,12 +58,12 @@ public class PlayerSync {
|
||||||
String dbName = JdbcConfig.DATABASE_NAME.get();
|
String dbName = JdbcConfig.DATABASE_NAME.get();
|
||||||
|
|
||||||
// Step 1: Create the database using a connection that does not select a database.
|
// Step 1: Create the database using a connection that does not select a database.
|
||||||
JDBCsetUp.executeUpdateWithoutDatabase("CREATE DATABASE IF NOT EXISTS " + dbName);
|
JDBCsetUp.executeUpdateWithoutDatabase("CREATE DATABASE IF NOT EXISTS `" + dbName + "`");
|
||||||
|
|
||||||
// Step 2: Explicitly select the database on a connection obtained without default database.
|
// Step 2: Explicitly select the database on a connection obtained without default database.
|
||||||
try (Connection conn = JDBCsetUp.getConnection(false);
|
try (Connection conn = JDBCsetUp.getConnection(false);
|
||||||
Statement st = conn.createStatement()) {
|
Statement st = conn.createStatement()) {
|
||||||
st.execute("USE " + dbName);
|
st.execute("USE `" + dbName + "`");
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
LOGGER.error("Error selecting database " + dbName, e);
|
LOGGER.error("Error selecting database " + dbName, e);
|
||||||
throw e;
|
throw e;
|
||||||
|
|
@ -72,7 +72,7 @@ public class PlayerSync {
|
||||||
// Step 3: Create and alter tables using fully qualified names.
|
// Step 3: 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 + "`.`player_data` (" +
|
||||||
"`uuid` char(36) NOT NULL," +
|
"`uuid` char(36) NOT NULL," +
|
||||||
"`inventory` mediumblob," +
|
"`inventory` mediumblob," +
|
||||||
"`armor` blob," +
|
"`armor` blob," +
|
||||||
|
|
@ -105,7 +105,7 @@ public class PlayerSync {
|
||||||
}
|
}
|
||||||
if (columnCount < 14) {
|
if (columnCount < 14) {
|
||||||
JDBCsetUp.executeUpdate(
|
JDBCsetUp.executeUpdate(
|
||||||
"ALTER TABLE " + dbName + ".player_data " +
|
"ALTER TABLE `" + dbName + "`.`player_data` " +
|
||||||
"ADD COLUMN left_hand blob, " +
|
"ADD COLUMN left_hand blob, " +
|
||||||
"ADD COLUMN cursors blob;"
|
"ADD COLUMN cursors blob;"
|
||||||
);
|
);
|
||||||
|
|
@ -113,7 +113,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 + "`.`server_info` (" +
|
||||||
"`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," +
|
||||||
|
|
@ -126,7 +126,7 @@ public class PlayerSync {
|
||||||
long current = System.currentTimeMillis();
|
long current = System.currentTimeMillis();
|
||||||
int data_version = SharedConstants.getCurrentVersion().getDataVersion().getVersion();
|
int data_version = SharedConstants.getCurrentVersion().getDataVersion().getVersion();
|
||||||
JDBCsetUp.executeUpdate("""
|
JDBCsetUp.executeUpdate("""
|
||||||
INSERT INTO %s.server_info
|
INSERT INTO `%s`.`server_info`
|
||||||
(
|
(
|
||||||
id,
|
id,
|
||||||
enable,
|
enable,
|
||||||
|
|
@ -156,7 +156,7 @@ public class PlayerSync {
|
||||||
// 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 + "`.`curios` (" +
|
||||||
"uuid CHAR(36) NOT NULL, curios_item BLOB, PRIMARY KEY (uuid)" +
|
"uuid CHAR(36) NOT NULL, curios_item BLOB, PRIMARY KEY (uuid)" +
|
||||||
")"
|
")"
|
||||||
);
|
);
|
||||||
|
|
@ -165,7 +165,7 @@ public class PlayerSync {
|
||||||
// Create backpack_data table
|
// Create backpack_data table
|
||||||
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
||||||
JDBCsetUp.executeUpdateWithoutDatabase(
|
JDBCsetUp.executeUpdateWithoutDatabase(
|
||||||
"CREATE TABLE IF NOT EXISTS " + dbName + ".backpack_data (" +
|
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`backpack_data` (" +
|
||||||
"uuid CHAR(36) NOT NULL, backpack_nbt MEDIUMBLOB, PRIMARY KEY (uuid)" +
|
"uuid CHAR(36) NOT NULL, backpack_nbt MEDIUMBLOB, PRIMARY KEY (uuid)" +
|
||||||
");"
|
");"
|
||||||
);
|
);
|
||||||
|
|
@ -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 " +
|
||||||
|
|
@ -185,12 +198,17 @@ public class PlayerSync {
|
||||||
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 from {} to MEDIUMBLOB.", dataType);
|
LOGGER.info("Altering player_data table to modify 'advancements' column from {} to MEDIUMBLOB.", dataType);
|
||||||
JDBCsetUp.executeUpdateWithoutDatabase("ALTER TABLE " + dbName + ".player_data MODIFY COLUMN advancements MEDIUMBLOB");
|
JDBCsetUp.executeUpdateWithoutDatabase("ALTER TABLE `" + dbName + "`.`player_data` MODIFY COLUMN advancements MEDIUMBLOB");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
rsAdvCol.close();
|
rsAdvCol.close();
|
||||||
// ----- END NEW BLOCK -----
|
// ----- END NEW BLOCK -----
|
||||||
|
|
||||||
|
try {
|
||||||
|
JDBCsetUp.executeUpdate("UPDATE player_data SET online=0 WHERE last_server=" + JdbcConfig.SERVER_ID.get() +" AND online=1 LIMIT 1000");
|
||||||
|
} catch (Exception e) {
|
||||||
|
LOGGER.error("An exception occurred while trying change wrong player-status\n" + e.getMessage());
|
||||||
|
}
|
||||||
LOGGER.info("PlayerSync is ready!");
|
LOGGER.info("PlayerSync is ready!");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,11 @@ public class JdbcConfig {
|
||||||
public static final ForgeConfigSpec.BooleanValue IS_CHAT_SERVER;
|
public static final ForgeConfigSpec.BooleanValue IS_CHAT_SERVER;
|
||||||
public static final ForgeConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
|
public static final ForgeConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
|
||||||
public static final ForgeConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
|
public static final ForgeConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
|
||||||
|
public static final ForgeConfigSpec.BooleanValue KICK_WHEN_ALREADY_ONLINE;
|
||||||
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;
|
||||||
|
|
||||||
|
|
@ -44,6 +46,8 @@ public class JdbcConfig {
|
||||||
.define("sync_advancements", true);
|
.define("sync_advancements", true);
|
||||||
SYNC_CHAT = COMMON_BUILDER.comment("Whether synchronize chat").define("sync_chat", false);
|
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);
|
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")
|
||||||
|
.define("kick_when_already_online", true);
|
||||||
CHAT_SERVER_IP = COMMON_BUILDER.define("ChatServerIP","127.0.0.1");
|
CHAT_SERVER_IP = COMMON_BUILDER.define("ChatServerIP","127.0.0.1");
|
||||||
CHAT_SERVER_PORT = COMMON_BUILDER.defineInRange("ChatServerPort",7900,0,65535);
|
CHAT_SERVER_PORT = COMMON_BUILDER.defineInRange("ChatServerPort",7900,0,65535);
|
||||||
USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment(
|
USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment(
|
||||||
|
|
@ -58,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();
|
||||||
|
|
|
||||||
|
|
@ -4,12 +4,15 @@ import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
import com.mojang.serialization.Dynamic;
|
import com.mojang.serialization.Dynamic;
|
||||||
import net.minecraft.ChatFormatting;
|
import net.minecraft.ChatFormatting;
|
||||||
import net.minecraft.SharedConstants;
|
import net.minecraft.SharedConstants;
|
||||||
|
import net.minecraft.core.BlockPos;
|
||||||
import net.minecraft.nbt.*;
|
import net.minecraft.nbt.*;
|
||||||
import net.minecraft.network.chat.Component;
|
import net.minecraft.network.chat.Component;
|
||||||
import net.minecraft.network.chat.Style;
|
import net.minecraft.network.chat.Style;
|
||||||
|
import net.minecraft.resources.ResourceKey;
|
||||||
import net.minecraft.resources.ResourceLocation;
|
import net.minecraft.resources.ResourceLocation;
|
||||||
import net.minecraft.server.MinecraftServer;
|
import net.minecraft.server.MinecraftServer;
|
||||||
import net.minecraft.server.PlayerAdvancements;
|
import net.minecraft.server.PlayerAdvancements;
|
||||||
|
import net.minecraft.server.level.ServerLevel;
|
||||||
import net.minecraft.server.level.ServerPlayer;
|
import net.minecraft.server.level.ServerPlayer;
|
||||||
import net.minecraft.util.datafix.DataFixers;
|
import net.minecraft.util.datafix.DataFixers;
|
||||||
import net.minecraft.util.datafix.fixes.References;
|
import net.minecraft.util.datafix.fixes.References;
|
||||||
|
|
@ -20,9 +23,11 @@ import net.minecraft.world.entity.player.Inventory;
|
||||||
import net.minecraft.world.entity.player.Player;
|
import net.minecraft.world.entity.player.Player;
|
||||||
import net.minecraft.world.item.ItemStack;
|
import net.minecraft.world.item.ItemStack;
|
||||||
import net.minecraft.world.item.Items;
|
import net.minecraft.world.item.Items;
|
||||||
|
import net.minecraft.world.level.Level;
|
||||||
import net.minecraft.world.level.storage.WorldData;
|
import net.minecraft.world.level.storage.WorldData;
|
||||||
import net.minecraftforge.event.OnDatapackSyncEvent;
|
import net.minecraftforge.event.OnDatapackSyncEvent;
|
||||||
import net.minecraftforge.event.TickEvent;
|
import net.minecraftforge.event.TickEvent;
|
||||||
|
import net.minecraftforge.event.entity.living.LivingDeathEvent;
|
||||||
import net.minecraftforge.event.entity.player.PlayerEvent;
|
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||||
import net.minecraftforge.event.entity.player.PlayerNegotiationEvent;
|
import net.minecraftforge.event.entity.player.PlayerNegotiationEvent;
|
||||||
import net.minecraftforge.event.server.ServerStoppedEvent;
|
import net.minecraftforge.event.server.ServerStoppedEvent;
|
||||||
|
|
@ -33,6 +38,9 @@ import net.minecraftforge.registries.ForgeRegistries;
|
||||||
import net.minecraftforge.server.ServerLifecycleHooks;
|
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.MekanismSupport;
|
||||||
|
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;
|
||||||
|
|
@ -44,15 +52,28 @@ import java.nio.file.Files;
|
||||||
import java.sql.ResultSet;
|
import java.sql.ResultSet;
|
||||||
import java.sql.SQLException;
|
import java.sql.SQLException;
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
import java.util.concurrent.ExecutorService;
|
import java.util.concurrent.*;
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
|
|
||||||
@Mod.EventBusSubscriber
|
@Mod.EventBusSubscriber
|
||||||
public class VanillaSync {
|
public class VanillaSync {
|
||||||
|
|
||||||
public static void register() {}
|
public static void register() {
|
||||||
|
}
|
||||||
|
|
||||||
static ExecutorService executorService = Executors.newCachedThreadPool(new PSThreadPoolFactory("PlayerSync"));
|
// FIX: Replace unbounded CachedThreadPool with a bounded ThreadPoolExecutor.
|
||||||
|
// CachedThreadPool creates unlimited threads — with many players and slow DB queries,
|
||||||
|
// thread count can explode to 25000+ causing memory leaks and server crashes.
|
||||||
|
// Bounded pool: 2 core threads, max 8 threads, 30s keepalive, 256-task queue.
|
||||||
|
// If the queue is full, tasks run on the calling thread (CallerRunsPolicy) which
|
||||||
|
// provides natural backpressure instead of creating more threads.
|
||||||
|
static ExecutorService executorService = new ThreadPoolExecutor(
|
||||||
|
2, // core pool size
|
||||||
|
8, // maximum pool size
|
||||||
|
30L, TimeUnit.SECONDS, // idle thread keepalive
|
||||||
|
new LinkedBlockingQueue<>(256), // bounded work queue
|
||||||
|
new PSThreadPoolFactory("PlayerSync"),
|
||||||
|
new ThreadPoolExecutor.CallerRunsPolicy() // backpressure: run on caller thread if queue full
|
||||||
|
);
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public static void onDataPackSyncEvent(OnDatapackSyncEvent event) throws SQLException, IOException {
|
public static void onDataPackSyncEvent(OnDatapackSyncEvent event) throws SQLException, IOException {
|
||||||
|
|
@ -160,14 +181,14 @@ public class VanillaSync {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Second query: Check if player is already online on another server
|
// Second query: Check if player is already online on another server
|
||||||
if (online && lastServer != JdbcConfig.SERVER_ID.get()) {
|
if (JdbcConfig.KICK_WHEN_ALREADY_ONLINE.get() && online && lastServer != JdbcConfig.SERVER_ID.get()) {
|
||||||
JDBCsetUp.QueryResult qr2 = JDBCsetUp.executeQuery("SELECT last_update,enable FROM server_info WHERE id='" + lastServer + "'");
|
JDBCsetUp.QueryResult qr2 = JDBCsetUp.executeQuery("SELECT last_update,enable FROM server_info WHERE id='" + lastServer + "'");
|
||||||
try (ResultSet rs2 = qr2.resultSet()) {
|
try (ResultSet rs2 = qr2.resultSet()) {
|
||||||
if (rs2.next()) {
|
if (rs2.next()) {
|
||||||
long last_update = rs2.getLong("last_update");
|
long last_update = rs2.getLong("last_update");
|
||||||
boolean enable = rs2.getBoolean("enable");
|
boolean enable = rs2.getBoolean("enable");
|
||||||
if (enable && System.currentTimeMillis() < last_update + 300000.0) {
|
if (enable && System.currentTimeMillis() < last_update + 300000.0) {
|
||||||
event.getConnection().disconnect(Component.translatable("playersync.already_online"));
|
event.getConnection().disconnect(Component.translatableWithFallback("playersync.already_online","You can't join more than one synchronization server at the same time."));
|
||||||
qr2.connection().close();
|
qr2.connection().close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -178,29 +199,76 @@ public class VanillaSync {
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("SqlException detected!", e);
|
PlayerSync.LOGGER.error("SqlException detected!", e);
|
||||||
event.getConnection().disconnect(Component.translatable("playersync.sqlexception"));
|
event.getConnection().disconnect(Component.translatableWithFallback("playersync.sqlexception","SqlException detected!Connection lost,please contact with your admin."));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use string uuid as key
|
||||||
|
public static Set<String> deadPlayerWhileLogging = ConcurrentHashMap.newKeySet();
|
||||||
|
public static Set<String> syncNotCompletedPlayer = ConcurrentHashMap.newKeySet();
|
||||||
|
|
||||||
public static void doPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) {
|
public static void doPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) {
|
||||||
|
ServerPlayer joinedPlayer = (ServerPlayer) event.getEntity();
|
||||||
|
String player_uuid = joinedPlayer.getUUID().toString();
|
||||||
|
if (joinedPlayer.isDeadOrDying()) {
|
||||||
|
deadPlayerWhileLogging.add(player_uuid);
|
||||||
|
joinedPlayer.removeTag("player_synced");
|
||||||
|
|
||||||
|
// Simulate normal death behavior
|
||||||
|
MinecraftServer server = joinedPlayer.getServer();
|
||||||
|
if (server != null) {
|
||||||
|
ResourceKey<Level> respawnLevel = joinedPlayer.getRespawnDimension();
|
||||||
|
BlockPos respawnPos = joinedPlayer.getRespawnPosition();
|
||||||
|
double respawnX;
|
||||||
|
double respawnY;
|
||||||
|
double respawnZ;
|
||||||
|
if (respawnPos != null && respawnLevel != null) {
|
||||||
|
ServerLevel level = server.getLevel(respawnLevel);
|
||||||
|
respawnX = respawnPos.getX();
|
||||||
|
respawnY = respawnPos.getY();
|
||||||
|
respawnZ = respawnPos.getZ();
|
||||||
|
if (level != null) {
|
||||||
|
joinedPlayer.teleportTo(level, respawnX, respawnY + 1, respawnZ, 0, 0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayerSync.LOGGER.debug("Player " + player_uuid + " has no respawn point");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayerSync.LOGGER.warn("Trying to get server,but got a null");
|
||||||
|
}
|
||||||
|
|
||||||
|
joinedPlayer.setHealth(1);
|
||||||
|
try {
|
||||||
|
JDBCsetUp.executeUpdate("UPDATE server_info SET last_update=" + System.currentTimeMillis() + " WHERE id=" + JdbcConfig.SERVER_ID.get());
|
||||||
|
JDBCsetUp.executeUpdate("UPDATE player_data SET online= '1',last_server=" + JdbcConfig.SERVER_ID.get() + " WHERE uuid='" + player_uuid + "'");
|
||||||
|
} catch (SQLException e) {
|
||||||
|
PlayerSync.LOGGER.error("An error occurred while trying to execute a dead or dying player" + e.getMessage());
|
||||||
|
}
|
||||||
|
joinedPlayer.connection.disconnect(Component.translatableWithFallback("playersync.wrong_entity_status","An error occurred while creating playerEntity in the world,please login again."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
String player_uuid = event.getEntity().getUUID().toString();
|
|
||||||
PlayerSync.LOGGER.info("Starting synchronization for player " + player_uuid);
|
PlayerSync.LOGGER.info("Starting synchronization for player " + player_uuid);
|
||||||
|
|
||||||
// First query: check basic player data
|
// First query: check basic player data
|
||||||
|
syncNotCompletedPlayer.add(player_uuid);
|
||||||
JDBCsetUp.QueryResult qr1 = JDBCsetUp.executeQuery("SELECT online, last_server FROM player_data WHERE uuid='" + player_uuid + "'");
|
JDBCsetUp.QueryResult qr1 = JDBCsetUp.executeQuery("SELECT online, last_server FROM player_data WHERE uuid='" + player_uuid + "'");
|
||||||
ResultSet rs1 = qr1.resultSet();
|
ResultSet rs1 = qr1.resultSet();
|
||||||
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
|
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
|
||||||
|
|
||||||
// Mod support
|
// Mod support
|
||||||
ModsSupport modsSupport = new ModsSupport();
|
ModsSupport modsSupport = new ModsSupport();
|
||||||
modsSupport.onPlayerJoin(serverPlayer);
|
modsSupport.doCuriosRestore(serverPlayer);
|
||||||
|
|
||||||
if (!rs1.next()){
|
if (!rs1.next()) {
|
||||||
store(event.getEntity(), true);
|
store(event.getEntity(), true);
|
||||||
JDBCsetUp.executeUpdate("UPDATE server_info SET last_update=" + System.currentTimeMillis() + " WHERE id=" + JdbcConfig.SERVER_ID.get());
|
JDBCsetUp.executeUpdate("UPDATE server_info SET last_update=" + System.currentTimeMillis() + " WHERE id=" + JdbcConfig.SERVER_ID.get());
|
||||||
JDBCsetUp.executeUpdate("UPDATE player_data SET online= '1',last_server=" + JdbcConfig.SERVER_ID.get() + " WHERE uuid='" + player_uuid + "'");
|
JDBCsetUp.executeUpdate("UPDATE player_data SET online= '1',last_server=" + JdbcConfig.SERVER_ID.get() + " WHERE uuid='" + player_uuid + "'");
|
||||||
rs1.close();
|
rs1.close();
|
||||||
|
qr1.close();
|
||||||
|
PlayerSync.LOGGER.info("New player detected,init completed.");
|
||||||
|
syncNotCompletedPlayer.remove(player_uuid);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -213,7 +281,12 @@ public class VanillaSync {
|
||||||
|
|
||||||
if (rs2.next()) {
|
if (rs2.next()) {
|
||||||
// Restore basic attributes
|
// Restore basic attributes
|
||||||
serverPlayer.setHealth(rs2.getInt("health"));
|
int health = rs2.getInt("health");
|
||||||
|
if (health <= 0) {
|
||||||
|
serverPlayer.setHealth(1);
|
||||||
|
} else {
|
||||||
|
serverPlayer.setHealth(health);
|
||||||
|
}
|
||||||
serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level"));
|
serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level"));
|
||||||
|
|
||||||
setXpForPlayer(serverPlayer, rs2.getInt("xp"));
|
setXpForPlayer(serverPlayer, rs2.getInt("xp"));
|
||||||
|
|
@ -265,11 +338,21 @@ public class VanillaSync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
modsSupport.doBackPackRestore(serverPlayer);
|
||||||
|
|
||||||
|
MekanismSupport.restorePersonalChestData(serverPlayer);
|
||||||
|
|
||||||
serverPlayer.addTag("player_synced");
|
serverPlayer.addTag("player_synced");
|
||||||
|
|
||||||
rs2.close();
|
rs2.close();
|
||||||
|
qr2.close();
|
||||||
|
rs1.close();
|
||||||
|
qr1.close();
|
||||||
|
PlayerSync.LOGGER.info("Sync data for player {} completed.", player_uuid);
|
||||||
|
syncNotCompletedPlayer.remove(player_uuid);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
PlayerSync.LOGGER.error("Internal Exception detected!", e);
|
PlayerSync.LOGGER.error("Internal Exception detected!", e);
|
||||||
|
syncNotCompletedPlayer.remove(player_uuid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -383,7 +466,7 @@ public class VanillaSync {
|
||||||
loreList.add(StringTag.valueOf(Component.Serializer.toJson(Component.literal(""))));
|
loreList.add(StringTag.valueOf(Component.Serializer.toJson(Component.literal(""))));
|
||||||
|
|
||||||
String placeholderItemDescriptionOverride = JdbcConfig.ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE.get();
|
String placeholderItemDescriptionOverride = JdbcConfig.ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE.get();
|
||||||
String placeholderItemDescriptionLines = placeholderItemDescriptionOverride != null && ! placeholderItemDescriptionOverride.isBlank()
|
String placeholderItemDescriptionLines = placeholderItemDescriptionOverride != null && !placeholderItemDescriptionOverride.isBlank()
|
||||||
? placeholderItemDescriptionOverride
|
? placeholderItemDescriptionOverride
|
||||||
: Component.translatable("playersync.item_placeholder_description").getString();
|
: Component.translatable("playersync.item_placeholder_description").getString();
|
||||||
|
|
||||||
|
|
@ -416,6 +499,7 @@ public class VanillaSync {
|
||||||
/**
|
/**
|
||||||
* Deserializes a string from the database back into an NBT string.
|
* Deserializes a string from the database back into an NBT string.
|
||||||
* Handles both the new Base64 format (prefixed with "B64:") and the old custom format.
|
* Handles both the new Base64 format (prefixed with "B64:") and the old custom format.
|
||||||
|
*
|
||||||
* @param encoded The string retrieved from the database.
|
* @param encoded The string retrieved from the database.
|
||||||
* @return The deserialized NBT string.
|
* @return The deserialized NBT string.
|
||||||
*/
|
*/
|
||||||
|
|
@ -441,6 +525,7 @@ public class VanillaSync {
|
||||||
* Serializes an NBT string for database storage.
|
* Serializes an NBT string for database storage.
|
||||||
* Uses Base64 encoding by default (prefixed with "B64:").
|
* Uses Base64 encoding by default (prefixed with "B64:").
|
||||||
* If USE_LEGACY_SERIALIZATION config is true, uses the old custom replacement format.
|
* If USE_LEGACY_SERIALIZATION config is true, uses the old custom replacement format.
|
||||||
|
*
|
||||||
* @param object The NBT string to serialize.
|
* @param object The NBT string to serialize.
|
||||||
* @return The serialized string.
|
* @return The serialized string.
|
||||||
*/
|
*/
|
||||||
|
|
@ -449,10 +534,10 @@ public class VanillaSync {
|
||||||
if (JdbcConfig.USE_LEGACY_SERIALIZATION.get()) {
|
if (JdbcConfig.USE_LEGACY_SERIALIZATION.get()) {
|
||||||
// Use old custom replacement logic
|
// Use old custom replacement logic
|
||||||
return object.replace(",", "|")
|
return object.replace(",", "|")
|
||||||
.replace("\"", "^")
|
.replace("\"", "^")
|
||||||
.replace("{", "<")
|
.replace("{", "<")
|
||||||
.replace("}", ">")
|
.replace("}", ">")
|
||||||
.replace("'", "~");
|
.replace("'", "~");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base64 encode with a "B64:" marker for new data
|
// Base64 encode with a "B64:" marker for new data
|
||||||
|
|
@ -489,16 +574,27 @@ public class VanillaSync {
|
||||||
|
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) throws SQLException {
|
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) throws SQLException {
|
||||||
// Mod support
|
String player_uuid = event.getEntity().getUUID().toString();
|
||||||
ModsSupport modsSupport = new ModsSupport();
|
if (deadPlayerWhileLogging.contains(player_uuid)) {
|
||||||
modsSupport.onPlayerLeave(event.getEntity());
|
PlayerSync.LOGGER.warn("A dead or dying player was kicked,which uuid is:{}", player_uuid);
|
||||||
executorService.submit(() -> {
|
JDBCsetUp.executeUpdate("UPDATE player_data SET online= '0' WHERE uuid='" + player_uuid + "'");
|
||||||
try {
|
deadPlayerWhileLogging.remove(player_uuid);
|
||||||
doPlayerLogout(event);
|
} else if (syncNotCompletedPlayer.contains(player_uuid)) {
|
||||||
} catch (Exception e) {
|
PlayerSync.LOGGER.warn("A player logged out with uncompleted sync data,which uuid is:{}.For the safety,the new data won't be saved", player_uuid);
|
||||||
e.printStackTrace();
|
JDBCsetUp.executeUpdate("UPDATE player_data SET online= '0' WHERE uuid='" + player_uuid + "'");
|
||||||
}
|
syncNotCompletedPlayer.remove(player_uuid);
|
||||||
});
|
} else {
|
||||||
|
// Mod support
|
||||||
|
ModsSupport modsSupport = new ModsSupport();
|
||||||
|
modsSupport.onPlayerLeave(event.getEntity());
|
||||||
|
executorService.submit(() -> {
|
||||||
|
try {
|
||||||
|
doPlayerLogout(event);
|
||||||
|
} catch (Exception e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to get the NBT string to be saved
|
// Helper function to get the NBT string to be saved
|
||||||
|
|
@ -558,10 +654,12 @@ public class VanillaSync {
|
||||||
ender_chest.put(i, getNbtForStorage(player.getEnderChestInventory().getItem(i)));
|
ender_chest.put(i, getNbtForStorage(player.getEnderChestInventory().getItem(i)));
|
||||||
}
|
}
|
||||||
|
|
||||||
if(ModList.get().isLoaded("sophisticatedbackpacks")){
|
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
||||||
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<>();
|
||||||
|
|
@ -576,7 +674,7 @@ public class VanillaSync {
|
||||||
if (JdbcConfig.SYNC_ADVANCEMENTS.get()) {
|
if (JdbcConfig.SYNC_ADVANCEMENTS.get()) {
|
||||||
File gameDir = Objects.requireNonNull(player.getServer()).getServerDirectory();
|
File gameDir = Objects.requireNonNull(player.getServer()).getServerDirectory();
|
||||||
final MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
|
final MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
|
||||||
if (server != null && server.isDedicatedServer() ) {
|
if (server != null && server.isDedicatedServer()) {
|
||||||
PlayerSync.LOGGER.trace("Reading dedicated server advancements");
|
PlayerSync.LOGGER.trace("Reading dedicated server advancements");
|
||||||
advancements = new File(gameDir, getSyncWorldForServer() + "/advancements" + "/" + player_uuid + ".json");
|
advancements = new File(gameDir, getSyncWorldForServer() + "/advancements" + "/" + player_uuid + ".json");
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -607,7 +705,7 @@ public class VanillaSync {
|
||||||
|
|
||||||
// SQL Operation for player data
|
// SQL Operation for player data
|
||||||
if (init) {
|
if (init) {
|
||||||
JDBCsetUp.executeUpdate("INSERT INTO player_data (uuid,armor,inventory,enderchest,advancements,effects,xp,food_level,health,score,left_hand,cursors,online) VALUES ('" + player_uuid + "','" + equipment + "','" + inventoryMap + "','" + ender_chest + "','" + advancements + "','" + effectMap + "','" + XP + "','" + food_level + "','" + health + "','" + score + "','" + left_hand + "','" + cursors + "',online=true)");
|
JDBCsetUp.executeUpdate("INSERT INTO player_data (uuid,armor,inventory,enderchest,advancements,effects,xp,food_level,health,score,left_hand,cursors,online) VALUES ('" + player_uuid + "','" + equipment + "','" + inventoryMap + "','" + ender_chest + "','" + json + "','" + effectMap + "','" + XP + "','" + food_level + "','" + health + "','" + score + "','" + left_hand + "','" + cursors + "',online=true)");
|
||||||
} else {
|
} else {
|
||||||
JDBCsetUp.executeUpdate("UPDATE player_data SET inventory = '" + inventoryMap + "',armor='" + equipment + "' ,xp='" + XP + "',effects='" + effectMap + "',enderchest='" + ender_chest + "',score='" + score + "',food_level='" + food_level + "',health='" + health + "',advancements='" + json + "',left_hand='" + left_hand + "',cursors='" + cursors + "' WHERE uuid = '" + player_uuid + "'");
|
JDBCsetUp.executeUpdate("UPDATE player_data SET inventory = '" + inventoryMap + "',armor='" + equipment + "' ,xp='" + XP + "',effects='" + effectMap + "',enderchest='" + ender_chest + "',score='" + score + "',food_level='" + food_level + "',health='" + health + "',advancements='" + json + "',left_hand='" + left_hand + "',cursors='" + cursors + "' WHERE uuid = '" + player_uuid + "'");
|
||||||
}
|
}
|
||||||
|
|
@ -658,6 +756,8 @@ public class VanillaSync {
|
||||||
// New fields for auto-save
|
// New fields for auto-save
|
||||||
private static int autoSaveTickCounter = 0;
|
private static int autoSaveTickCounter = 0;
|
||||||
private static final int AUTO_SAVE_INTERVAL_TICKS = 1200; // Every Minute
|
private static final int AUTO_SAVE_INTERVAL_TICKS = 1200; // Every Minute
|
||||||
|
private static int autoCleanCuriosCacheTickCounter = 0;
|
||||||
|
private static final int AUTO_CLEAN_CURIOS_CACHE_INTERVAL_TICKS = 36000; // Every 30 min
|
||||||
|
|
||||||
//AutoSave
|
//AutoSave
|
||||||
@SubscribeEvent
|
@SubscribeEvent
|
||||||
|
|
@ -665,6 +765,7 @@ public class VanillaSync {
|
||||||
// Run at the end phase to avoid interfering with game logic
|
// Run at the end phase to avoid interfering with game logic
|
||||||
if (event.phase == TickEvent.Phase.END) {
|
if (event.phase == TickEvent.Phase.END) {
|
||||||
autoSaveTickCounter++;
|
autoSaveTickCounter++;
|
||||||
|
autoCleanCuriosCacheTickCounter++;
|
||||||
if (autoSaveTickCounter >= AUTO_SAVE_INTERVAL_TICKS) {
|
if (autoSaveTickCounter >= AUTO_SAVE_INTERVAL_TICKS) {
|
||||||
autoSaveTickCounter = 0;
|
autoSaveTickCounter = 0;
|
||||||
// Retrieve the current server instance
|
// Retrieve the current server instance
|
||||||
|
|
@ -687,10 +788,26 @@ 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (autoCleanCuriosCacheTickCounter >= AUTO_CLEAN_CURIOS_CACHE_INTERVAL_TICKS) {
|
||||||
|
autoCleanCuriosCacheTickCounter = 0;
|
||||||
|
executorService.submit(() -> {
|
||||||
|
try {
|
||||||
|
CuriosCache.RemoveExpiredCuriosCache();
|
||||||
|
} catch (Exception e) {
|
||||||
|
PlayerSync.LOGGER.error("An error occurred while cleaning curios cache:" + e.getMessage());
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -741,4 +858,12 @@ public class VanillaSync {
|
||||||
|
|
||||||
return totalXp;
|
return totalXp;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@SubscribeEvent
|
||||||
|
//Don't know what will happen if a fake player is killed,need more test.
|
||||||
|
public static void onPlayerDeath(LivingDeathEvent event) {
|
||||||
|
if (event.getEntity() instanceof ServerPlayer player && !deadPlayerWhileLogging.contains(event.getEntity().getUUID().toString())) {
|
||||||
|
CuriosCache.tryStoreCuriosToCache(player);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
122
src/main/java/vip/fubuki/playersync/sync/addons/CuriosCache.java
Normal file
122
src/main/java/vip/fubuki/playersync/sync/addons/CuriosCache.java
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
package vip.fubuki.playersync.sync.addons;
|
||||||
|
|
||||||
|
import net.minecraft.server.MinecraftServer;
|
||||||
|
import net.minecraft.world.entity.player.Player;
|
||||||
|
import net.minecraft.world.item.ItemStack;
|
||||||
|
import net.minecraft.world.level.GameRules;
|
||||||
|
import net.minecraftforge.common.util.LazyOptional;
|
||||||
|
import net.minecraftforge.fml.ModList;
|
||||||
|
import top.theillusivec4.curios.api.CuriosApi;
|
||||||
|
import top.theillusivec4.curios.api.type.capability.ICuriosItemHandler;
|
||||||
|
import top.theillusivec4.curios.api.type.inventory.IDynamicStackHandler;
|
||||||
|
import vip.fubuki.playersync.PlayerSync;
|
||||||
|
import vip.fubuki.playersync.sync.VanillaSync;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Iterator;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
public class CuriosCache {
|
||||||
|
private static final long CACHE_EXPIRY_MS = 3600000;
|
||||||
|
public static final ConcurrentHashMap<UUID, CuriosCacheEntry> curiosCache = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public static class CuriosCacheEntry {
|
||||||
|
final long timeStamp;
|
||||||
|
final String serializedData;
|
||||||
|
|
||||||
|
CuriosCacheEntry(String data) {
|
||||||
|
this.timeStamp = System.currentTimeMillis();
|
||||||
|
this.serializedData = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean isExpired() {
|
||||||
|
return System.currentTimeMillis() - timeStamp > CACHE_EXPIRY_MS;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
//If player logged out by "Title Screen" button,you will not be able to get the handlerOpt,and it will make the curios inventory sync failed.
|
||||||
|
//Create a method to store temporary curios data when player is dead.
|
||||||
|
//Then check player status in the logged out event,and take a normal sync if player is alive.
|
||||||
|
//If player is dead or dying,the cache will be used to prevent the empty data from the failure of getting handlerOpt.
|
||||||
|
public static void tryStoreCuriosToCache(net.minecraft.world.entity.player.Player player) {
|
||||||
|
if (!ModList.get().isLoaded("curios") || !CuriosCache.isKeepInventoryActive(player)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
LazyOptional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||||
|
if (!handlerOpt.isPresent() || handlerOpt.resolve().isEmpty()) {
|
||||||
|
PlayerSync.LOGGER.error("Obtain the curios api failed,cannot create the cache.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ICuriosItemHandler handler = handlerOpt.resolve().get();
|
||||||
|
String serializedData = serializeCuriosInventory(handler);
|
||||||
|
|
||||||
|
if (serializedData.startsWith("{}")) {
|
||||||
|
PlayerSync.LOGGER.debug("No curios data found,skipping the step of creating cache");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
UUID playerUuid = player.getUUID();
|
||||||
|
curiosCache.put(playerUuid, new CuriosCacheEntry(serializedData));
|
||||||
|
} catch (Exception e) {
|
||||||
|
PlayerSync.LOGGER.error("An error occurred while creating curios cache:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String serializeCuriosInventory(ICuriosItemHandler handler) {
|
||||||
|
Map<String, String> flatMap = new HashMap<>();
|
||||||
|
try {
|
||||||
|
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()) {
|
||||||
|
String serialized = VanillaSync.serialize(VanillaSync.serializeNBT(stack).toString());
|
||||||
|
flatMap.put(slotType + ":" + i, serialized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (Exception e) {
|
||||||
|
PlayerSync.LOGGER.error("Failed to serialize curios data:" + e.getMessage());
|
||||||
|
}
|
||||||
|
return flatMap.isEmpty() ? "{}" : flatMap.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static boolean isKeepInventoryActive(Player player) {
|
||||||
|
MinecraftServer server = player.getServer();
|
||||||
|
if (server == null) {
|
||||||
|
PlayerSync.LOGGER.error("Trying to get the gamerule(KeepInventory),but server is null");
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return server.getGameRules().getBoolean(GameRules.RULE_KEEPINVENTORY);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void RemoveExpiredCuriosCache() {
|
||||||
|
long startMs = System.currentTimeMillis();
|
||||||
|
int cacheSize = curiosCache.size();
|
||||||
|
|
||||||
|
if (cacheSize == 0) {
|
||||||
|
PlayerSync.LOGGER.debug("No curios caches,skipping cleaning");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
int removed = 0;
|
||||||
|
Iterator<Map.Entry<UUID, CuriosCacheEntry>> iterator = curiosCache.entrySet().iterator();
|
||||||
|
|
||||||
|
while (iterator.hasNext()) {
|
||||||
|
if (iterator.next().getValue().isExpired()) {
|
||||||
|
iterator.remove();;
|
||||||
|
removed ++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (removed > 0) {
|
||||||
|
PlayerSync.LOGGER.info("Cleaned {} curios cache(s),{} left,took {} Ms",
|
||||||
|
removed, curiosCache.size(), System.currentTimeMillis() - startMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package vip.fubuki.playersync.sync;
|
package vip.fubuki.playersync.sync.addons;
|
||||||
|
|
||||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||||
import net.minecraft.nbt.CompoundTag;
|
import net.minecraft.nbt.CompoundTag;
|
||||||
|
|
@ -12,6 +12,7 @@ import top.theillusivec4.curios.api.type.capability.ICuriosItemHandler;
|
||||||
import top.theillusivec4.curios.api.type.inventory.ICurioStacksHandler;
|
import top.theillusivec4.curios.api.type.inventory.ICurioStacksHandler;
|
||||||
import top.theillusivec4.curios.api.type.inventory.IDynamicStackHandler;
|
import top.theillusivec4.curios.api.type.inventory.IDynamicStackHandler;
|
||||||
import vip.fubuki.playersync.PlayerSync;
|
import vip.fubuki.playersync.PlayerSync;
|
||||||
|
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;
|
||||||
|
|
||||||
|
|
@ -24,12 +25,50 @@ import java.util.UUID;
|
||||||
|
|
||||||
|
|
||||||
public class ModsSupport {
|
public class ModsSupport {
|
||||||
|
public void doBackPackRestore(Player player) {
|
||||||
|
if(ModList.get().isLoaded("sophisticatedbackpacks")){
|
||||||
|
// --- Begin Backpack Data Restore ---
|
||||||
|
PlayerSync.LOGGER.info("Restoring backpack data for player " + player.getUUID());
|
||||||
|
net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, (ItemStack backpackItem, String handler, String identifier, int slot) -> {
|
||||||
|
backpackItem.getCapability(net.p3pp3rf1y.sophisticatedbackpacks.api.CapabilityBackpackWrapper.getCapabilityInstance())
|
||||||
|
.ifPresent(wrapper -> {
|
||||||
|
// Retrieve the contents UUID from the backpack's NBT using NBTHelper
|
||||||
|
Optional<UUID> uuidOpt = net.p3pp3rf1y.sophisticatedcore.util.NBTHelper.getUniqueId(wrapper.getBackpack(), "contentsUuid");
|
||||||
|
if (uuidOpt.isPresent()) {
|
||||||
|
UUID contentsUuid = uuidOpt.get();
|
||||||
|
try {
|
||||||
|
JDBCsetUp.QueryResult qrBackpack = JDBCsetUp.executeQuery("SELECT backpack_nbt FROM backpack_data WHERE uuid='" + contentsUuid + "'");
|
||||||
|
ResultSet rsBackpack = qrBackpack.resultSet();
|
||||||
|
if (rsBackpack.next()) {
|
||||||
|
String serialized = rsBackpack.getString("backpack_nbt");
|
||||||
|
String nbtString = VanillaSync.deserializeString(serialized);
|
||||||
|
CompoundTag backpackNbt = NbtUtils.snbtToStructure(nbtString);
|
||||||
|
// Update BackpackStorage with the retrieved NBT
|
||||||
|
net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, backpackNbt);
|
||||||
|
PlayerSync.LOGGER.info("Restored backpack data for UUID " + contentsUuid);
|
||||||
|
}
|
||||||
|
rsBackpack.close();
|
||||||
|
qrBackpack.connection().close();
|
||||||
|
} catch (SQLException e) {
|
||||||
|
PlayerSync.LOGGER.error("Error restoring backpack data for UUID " + contentsUuid, e);
|
||||||
|
} catch (CommandSyntaxException e) {
|
||||||
|
throw new RuntimeException(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid during restore");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
// --- End Backpack Data Restore ---
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Restores the Curios inventory for a player.
|
* Restores the Curios inventory for a player.
|
||||||
* The saved data is stored as a flat map with composite keys ("slotType:index").
|
* The saved data is stored as a flat map with composite keys ("slotType:index").
|
||||||
*/
|
*/
|
||||||
public void onPlayerJoin(net.minecraft.world.entity.player.Player player) throws SQLException {
|
public void doCuriosRestore(Player player) throws SQLException {
|
||||||
if (ModList.get().isLoaded("curios")) {
|
if (ModList.get().isLoaded("curios")) {
|
||||||
// Obtain the handler from the API.
|
// Obtain the handler from the API.
|
||||||
LazyOptional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
LazyOptional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||||
|
|
@ -91,42 +130,6 @@ public class ModsSupport {
|
||||||
StoreCurios(player, true);
|
StoreCurios(player, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if(ModList.get().isLoaded("sophisticatedbackpacks")){
|
|
||||||
// --- Begin Backpack Data Restore ---
|
|
||||||
PlayerSync.LOGGER.info("Restoring backpack data for player " + player.getUUID());
|
|
||||||
net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, (ItemStack backpackItem, String handler, String identifier, int slot) -> {
|
|
||||||
backpackItem.getCapability(net.p3pp3rf1y.sophisticatedbackpacks.api.CapabilityBackpackWrapper.getCapabilityInstance())
|
|
||||||
.ifPresent(wrapper -> {
|
|
||||||
// Retrieve the contents UUID from the backpack's NBT using NBTHelper
|
|
||||||
Optional<UUID> uuidOpt = net.p3pp3rf1y.sophisticatedcore.util.NBTHelper.getUniqueId(wrapper.getBackpack(), "contentsUuid");
|
|
||||||
if (uuidOpt.isPresent()) {
|
|
||||||
UUID contentsUuid = uuidOpt.get();
|
|
||||||
try {
|
|
||||||
JDBCsetUp.QueryResult qrBackpack = JDBCsetUp.executeQuery("SELECT backpack_nbt FROM backpack_data WHERE uuid='" + contentsUuid + "'");
|
|
||||||
ResultSet rsBackpack = qrBackpack.resultSet();
|
|
||||||
if (rsBackpack.next()) {
|
|
||||||
String serialized = rsBackpack.getString("backpack_nbt");
|
|
||||||
String nbtString = VanillaSync.deserializeString(serialized);
|
|
||||||
CompoundTag backpackNbt = NbtUtils.snbtToStructure(nbtString);
|
|
||||||
// Update BackpackStorage with the retrieved NBT
|
|
||||||
net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, backpackNbt);
|
|
||||||
PlayerSync.LOGGER.info("Restored backpack data for UUID " + contentsUuid);
|
|
||||||
}
|
|
||||||
rsBackpack.close();
|
|
||||||
qrBackpack.connection().close();
|
|
||||||
} catch (SQLException e) {
|
|
||||||
PlayerSync.LOGGER.error("Error restoring backpack data for UUID " + contentsUuid, e);
|
|
||||||
} catch (CommandSyntaxException e) {
|
|
||||||
throw new RuntimeException(e);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid during restore");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
// --- End Backpack Data Restore ---
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -135,7 +138,30 @@ public class ModsSupport {
|
||||||
*/
|
*/
|
||||||
public void onPlayerLeave(net.minecraft.world.entity.player.Player player) throws SQLException {
|
public void onPlayerLeave(net.minecraft.world.entity.player.Player player) throws SQLException {
|
||||||
if (ModList.get().isLoaded("curios")) {
|
if (ModList.get().isLoaded("curios")) {
|
||||||
StoreCurios(player, false);
|
if (player.isDeadOrDying()) {
|
||||||
|
if (!CuriosCache.curiosCache.isEmpty()) {
|
||||||
|
UUID playerUuid = player.getUUID();
|
||||||
|
if (CuriosCache.curiosCache.get(playerUuid) != null) {
|
||||||
|
CuriosCache.CuriosCacheEntry cacheEntry = CuriosCache.curiosCache.get(playerUuid);
|
||||||
|
String serializedData = cacheEntry.serializedData;
|
||||||
|
JDBCsetUp.executeUpdate("UPDATE curios SET curios_item = '" + serializedData + "' WHERE uuid = '" + player.getUUID() + "'");
|
||||||
|
CuriosCache.curiosCache.remove(playerUuid);
|
||||||
|
PlayerSync.LOGGER.info("Saving curios data for a dead-or-dying player {} Successfully", player.getStringUUID());
|
||||||
|
} else {
|
||||||
|
PlayerSync.LOGGER.error("Failed to find the cache of the logged out dead-or-dying player");
|
||||||
|
PlayerSync.LOGGER.error("The dead-or-dying player uuid is" + player.getStringUUID());
|
||||||
|
PlayerSync.LOGGER.error("Using default data...");
|
||||||
|
StoreCurios(player, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
PlayerSync.LOGGER.warn("No curios cache found while executing a dead-or-dying player logout event.you can ignore this warning if keep-inventory is false");
|
||||||
|
PlayerSync.LOGGER.warn("The dead-or-dying player uuid is" + player.getStringUUID());
|
||||||
|
PlayerSync.LOGGER.warn("Using default data...");
|
||||||
|
StoreCurios(player, false);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
StoreCurios(player, false);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -150,7 +176,7 @@ 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.serialize(stack.serializeNBT().toString());
|
String serialized = VanillaSync.serialize(VanillaSync.serializeNBT(stack).toString());
|
||||||
flatMap.put(slotType + ":" + i, serialized);
|
flatMap.put(slotType + ":" + i, serialized);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -29,7 +29,7 @@ public class JDBCsetUp {
|
||||||
// Ensure that the connection uses the desired database by explicitly issuing "USE dbName"
|
// Ensure that the connection uses the desired database by explicitly issuing "USE dbName"
|
||||||
if (selectDatabase && dbName != null && !dbName.isEmpty()) {
|
if (selectDatabase && dbName != null && !dbName.isEmpty()) {
|
||||||
try (Statement st = conn.createStatement()) {
|
try (Statement st = conn.createStatement()) {
|
||||||
st.execute("USE " + dbName);
|
st.execute("USE `" + dbName + "`");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return conn;
|
return conn;
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@
|
||||||
"playersync.placeholder_titel_override": "Item Voucher",
|
"playersync.placeholder_titel_override": "Item Voucher",
|
||||||
"playersync.item_placeholder_title": "Item Voucher",
|
"playersync.item_placeholder_title": "Item Voucher",
|
||||||
"playersync.already_online": "You can't join more than one synchronization server at the same time.",
|
"playersync.already_online": "You can't join more than one synchronization server at the same time.",
|
||||||
"playersync.sqlexception": "SqlException detected!Connection lost,please contact with your admin."
|
"playersync.sqlexception": "SqlException detected!Connection lost,please contact with your admin.",
|
||||||
|
"playersync.wrong_entity_status": "An error occurred while creating playerEntity in the world,please login again."
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,5 +3,6 @@
|
||||||
"playersync.placeholder_titel_override": "未知物品凭证",
|
"playersync.placeholder_titel_override": "未知物品凭证",
|
||||||
"playersync.item_placeholder_title": "未知物品凭证",
|
"playersync.item_placeholder_title": "未知物品凭证",
|
||||||
"playersync.already_online": "你不能同时加入多个在线的数据互通的服务器",
|
"playersync.already_online": "你不能同时加入多个在线的数据互通的服务器",
|
||||||
"playersync.sqlexception": "检测到Sql异常!连接已中断,请联系管理员"
|
"playersync.sqlexception": "检测到Sql异常!连接已中断,请联系管理员",
|
||||||
|
"playersync.wrong_entity_status": "在世界中尝试创建玩家实体时发生了错误,请尝试重新进入"
|
||||||
}
|
}
|
||||||
Loading…
Reference in New Issue
Block a user