Compare commits

...

35 Commits

Author SHA1 Message Date
018d0dfc33 Feat: 更新版本
Some checks failed
Build / build (push) Failing after 1m34s
2026-06-09 10:23:25 +08:00
mlus
ac20ad327c Feat: 添加了Mek支持
Some checks failed
Build / build (push) Has been cancelled
Merge pull request #172 from mlus-asuka/fix/169-bounded-thread-pool

Fix/169 bounded thread pool
2026-06-09 10:22:39 +08:00
mlus
e15c9b335e
Merge pull request #172 from mlus-asuka/fix/169-bounded-thread-pool
Fix/169 bounded thread pool
2026-05-10 00:06:51 +08:00
mlus
b6de595c41 Merge branch '1.20.1' of https://github.com/mlus-asuka/PlayerSync into 1.20.1 2026-05-10 00:00:12 +08:00
mlus
8df3b97356 fix #169 - bounded thread pool
Replace unbounded CachedThreadPool with bounded ThreadPoolExecutor

to prevent memory leaks and server crashes under high load.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 23:59:55 +08:00
mlus
9ce83763c9
Merge pull request #170 from mlus-asuka/dependabot/github_actions/gradle/actions-6
Bump gradle/actions from 5 to 6
2026-04-21 17:06:14 +08:00
dependabot[bot]
c4e18e61a8
Bump gradle/actions from 5 to 6
Bumps [gradle/actions](https://github.com/gradle/actions) from 5 to 6.
- [Release notes](https://github.com/gradle/actions/releases)
- [Commits](https://github.com/gradle/actions/compare/v5...v6)

---
updated-dependencies:
- dependency-name: gradle/actions
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-01 21:02:27 +00:00
mlus
8ff5d357a0
Merge pull request #167 from mlus-asuka/dependabot/github_actions/actions/upload-artifact-7
Bump actions/upload-artifact from 6 to 7
2026-03-04 15:50:28 +08:00
dependabot[bot]
235d95144f
Bump actions/upload-artifact from 6 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-01 16:26:39 +00:00
mlus
4e4ad80a95
Merge pull request #166 from mlus-asuka/1.20.1-dev
SQL syntax fix about Database name
2026-02-24 00:24:45 +08:00
mlus
32f2e2d75e fix #165 2026-02-24 00:22:40 +08:00
mlus
5764e85647
Merge pull request #164 from LeisureTimeDock/1.20.1
[Backport 1.20.1]Fix #151 #160
2026-02-06 16:13:07 +08:00
86d6393c87 update version 2026-02-06 11:21:05 +08:00
3944Realms
5632be3d3d
Change KICK_WHEN_ALREADY_ONLINE to final 2026-02-06 11:09:32 +08:00
3944Realms
1c5f3cddd4
Add kick option for players already online 2026-02-06 11:02:04 +08:00
3944Realms
a367eb0e3e
Update JdbcConfig.java 2026-02-06 11:01:07 +08:00
mlus
a47bc4bf80
Merge pull request #159 from mlus-asuka/dependabot/github_actions/actions/upload-artifact-6
Bump actions/upload-artifact from 5 to 6
2026-01-02 01:17:28 +08:00
mlus
c3750da764
Merge pull request #158 from mlus-asuka/dependabot/github_actions/korthout/backport-action-4
Bump korthout/backport-action from 3 to 4
2026-01-02 01:17:14 +08:00
dependabot[bot]
865926bc54
Bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-01 16:18:29 +00:00
dependabot[bot]
66808f2242
Bump korthout/backport-action from 3 to 4
Bumps [korthout/backport-action](https://github.com/korthout/backport-action) from 3 to 4.
- [Release notes](https://github.com/korthout/backport-action/releases)
- [Commits](https://github.com/korthout/backport-action/compare/v3...v4)

---
updated-dependencies:
- dependency-name: korthout/backport-action
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-01 16:18:24 +00:00
mlus
ce07fed83d fix init advancement 2025-12-07 00:45:50 +08:00
mlus
5583424e22
Merge pull request #149 from mlus-asuka/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-12-02 18:30:50 +08:00
dependabot[bot]
4ac5d77345
Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-01 19:25:35 +00:00
mlus
dfabb42c6b
Merge pull request #147 from mlus-asuka/1.20.1-dev
Fix sync order for mod support
2025-11-30 01:04:08 +08:00
mlus
f4584d58b5 :) 2025-11-30 01:01:49 +08:00
mlus
3291fc54b2 backpack dirty mark test 2025-11-26 15:42:29 +08:00
mlus
3e70f4b801
Merge pull request #146 from Fugit-5414/feature/some-improvements
Related to #136
2025-11-24 23:43:59 +08:00
Fugit-5414
5696272781 Related to #136 2025-11-24 22:11:13 +08:00
mlus
b062331cce
Merge pull request #145 from Fugit-5414/fix/sync-failed-when-player-health-is-zero
Fixes #144
2025-11-24 11:59:04 +08:00
Fugit-5414
a05b0b0375 Fixes #144 2025-11-23 22:17:27 +08:00
mlus
d78c84d8ce
Merge pull request #143 from mlus-asuka/1.20.1-dev
2.1.4 update
2025-11-22 23:01:31 +08:00
mlus
8b112ecc86
Merge pull request #141 from Fugit-5414/fix/wrong-player-status-after-restart-from-crashes
Fixes #140
2025-11-22 22:37:09 +08:00
Fugit-5414
176d123f4e Fixes #140 2025-11-22 22:30:46 +08:00
mlus
63a21ce5cc
Merge pull request #139 from Fugit-5414/fix/Curios-nbtException
Fixes #138
2025-11-20 23:24:50 +08:00
Fugit-5414
fe1e7584d4 Fixes #138 2025-11-20 23:04:48 +08:00
14 changed files with 1031 additions and 91 deletions

View File

@ -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: |

View File

@ -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: |

View File

@ -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
View 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`).

View File

@ -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

View File

@ -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!");
} }

View File

@ -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();

View File

@ -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);
}
}
} }

View 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);
}
}
}

View File

@ -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());
}
}
}

View File

@ -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);
} }
} }

View File

@ -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;

View File

@ -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."
} }

View File

@ -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": "在世界中尝试创建玩家实体时发生了错误,请尝试重新进入"
} }