Compare commits
104 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 | ||
|
|
f39a64bf14 | ||
|
|
8b112ecc86 | ||
|
|
176d123f4e | ||
|
|
63a21ce5cc | ||
|
|
fe1e7584d4 | ||
|
|
c2d5d37d30 | ||
|
|
1b4cfe4e39 | ||
|
|
5164900bad | ||
|
|
50b467c780 | ||
|
|
df4dbf6884 | ||
|
|
6781b7ad71 | ||
|
|
3cfa05368f | ||
|
|
610f652141 | ||
|
|
9b889d2458 | ||
|
|
08e32a675e | ||
|
|
cfa9387598 | ||
|
|
a5041917a4 | ||
|
|
05643bd0b4 | ||
|
|
933cd48c03 | ||
|
|
9055e9d342 | ||
|
|
befa36a303 | ||
|
|
7754186d12 | ||
|
|
c28a312f3c | ||
|
|
4d27f3a2d6 | ||
|
|
2cea1068dd | ||
|
|
86aae9534c | ||
|
|
77704d6431 | ||
|
|
db318df85e | ||
|
|
acfef0ff7e | ||
|
|
ca739b0b68 | ||
|
|
36847cc025 | ||
|
|
23d96e128e | ||
|
|
4fe13bd24d | ||
|
|
7f06aa7511 | ||
|
|
e1ac7adb11 | ||
|
|
8f77a96544 | ||
|
|
7a3363592e | ||
|
|
cd13b282e2 | ||
|
|
d934ceff5a | ||
|
|
ad76e0e311 | ||
|
|
0e96107416 | ||
|
|
54cbb9c9a8 | ||
|
|
2ec7fec89e | ||
|
|
0d1a26e348 | ||
|
|
9ce7f3c38f | ||
|
|
a70605a8b6 | ||
|
|
53bdfe2309 | ||
|
|
228b835c2a | ||
|
|
de324a23be | ||
|
|
4ae1954b29 | ||
|
|
a382b0105a | ||
|
|
a1e1616eac | ||
|
|
d29234109b | ||
|
|
7a47c58316 | ||
|
|
7787c79aec | ||
|
|
1193a17010 | ||
|
|
51200c28b3 | ||
|
|
2d891db071 | ||
|
|
d766febb11 | ||
|
|
a5f0cf1978 | ||
|
|
8f2b6d84b1 | ||
|
|
a774688d45 | ||
|
|
b1a11f2ba9 | ||
|
|
e2b90ccf98 | ||
|
|
aca7900890 | ||
|
|
e0f0b51851 | ||
|
|
d873711a40 | ||
|
|
b591d994c1 | ||
|
|
98e0cc3f60 | ||
|
|
d83bad5a33 | ||
|
|
319fe678f3 | ||
|
|
5ba7cc2972 | ||
|
|
d465a724f5 |
39
.github/workflows/backport-prs.yml
vendored
Normal file
39
.github/workflows/backport-prs.yml
vendored
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
name: Backport merged pull request
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [closed]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
permissions:
|
||||
contents: write # so it can comment
|
||||
pull-requests: write # so it can create pull requests
|
||||
jobs:
|
||||
backport:
|
||||
name: Backport pull request
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Only run when pull request is merged
|
||||
# or when a comment starting with `/backport` is created by someone other than the
|
||||
# https://github.com/backport-action bot user (user id: 97796249). Note that if you use your
|
||||
# own PAT as `github_token`, that you should replace this id with yours.
|
||||
if: >
|
||||
(
|
||||
github.event_name == 'pull_request_target' &&
|
||||
github.event.pull_request.merged
|
||||
) || (
|
||||
github.event_name == 'issue_comment' &&
|
||||
github.event.issue.pull_request &&
|
||||
github.event.comment.user.id != 97796249 &&
|
||||
startsWith(github.event.comment.body, '/backport')
|
||||
)
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- name: Create backport pull requests
|
||||
uses: korthout/backport-action@v4
|
||||
with:
|
||||
github_token: ${{ secrets.TOKEN }}
|
||||
pull_description: |
|
||||
Backport of #${pull_number} to `${target_branch}`.
|
||||
|
||||
### Description
|
||||
${pull_description}
|
||||
10
.github/workflows/build.yml
vendored
10
.github/workflows/build.yml
vendored
|
|
@ -16,25 +16,25 @@ jobs:
|
|||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Validate Gradle Wrapper
|
||||
uses: gradle/actions/wrapper-validation@v4
|
||||
uses: gradle/actions/wrapper-validation@v6
|
||||
|
||||
- name: Set up JDK 17
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
distribution: 'temurin'
|
||||
|
||||
- name: Setup Gradle
|
||||
uses: gradle/actions/setup-gradle@v4
|
||||
uses: gradle/actions/setup-gradle@v6
|
||||
|
||||
- name: Build with Gradle
|
||||
run: ./gradlew build
|
||||
|
||||
- name: Build Artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Player_Sync
|
||||
path: |
|
||||
|
|
|
|||
10
.vscode/settings.json
vendored
Normal file
10
.vscode/settings.json
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
{
|
||||
"java.compile.nullAnalysis.mode": "automatic",
|
||||
"java.completion.importOrder": [
|
||||
"",
|
||||
"javax",
|
||||
"java",
|
||||
"#"
|
||||
],
|
||||
"java.sources.organizeImports.starThreshold": 5
|
||||
}
|
||||
13
build.gradle
13
build.gradle
|
|
@ -29,6 +29,7 @@ repositories {
|
|||
includeGroup "curse.maven"
|
||||
}
|
||||
}
|
||||
maven { url 'https://modmaven.dev/' }
|
||||
}
|
||||
|
||||
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
|
||||
// modLocalRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
|
||||
compileOnly "curse.maven:curios-309927:5266541"
|
||||
compileOnly "curse.maven:sophisticated-backpacks-422301:6303388"
|
||||
compileOnly "curse.maven:sophisticated-core-618298:6317048"
|
||||
compileOnly "curse.maven:sophisticated-backpacks-422301:7169843"
|
||||
compileOnly "curse.maven:sophisticated-core-618298:7169400"
|
||||
// compileOnly "mekanism:Mekanism:${mekanism_version}:api"
|
||||
|
||||
// If you want to test/use Mekanism & its modules during `runClient` invocation, use the following
|
||||
modCompileOnly ("mekanism:Mekanism:${mekanism_version}")// core
|
||||
// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:additions")// Mekanism: Additions
|
||||
// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:generators")// Mekanism: Generators
|
||||
// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:tools")// Mekanism: Tools
|
||||
|
||||
|
||||
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||
|
|
|
|||
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.
|
||||
mod_license=GPL-3.0 license
|
||||
# The mod version. See https://semver.org/
|
||||
mod_version=2.1.0
|
||||
mod_version=2.1.6
|
||||
# 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.
|
||||
# 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
|
||||
# see https://dev.mysql.com/doc/relnotes/connector-j/en/ for latest version
|
||||
jdbc_version=9.3.0
|
||||
|
||||
# Mek Version
|
||||
# see https://modmaven.dev/mekanism/Mekanism/ for latest version
|
||||
mekanism_version=1.20.1-10.4.9.61
|
||||
|
|
|
|||
26
src/main/java/vip/fubuki/playersync/CommandInit.java
Normal file
26
src/main/java/vip/fubuki/playersync/CommandInit.java
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
package vip.fubuki.playersync;
|
||||
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
import net.minecraftforge.event.RegisterCommandsEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
@Mod.EventBusSubscriber()
|
||||
public class CommandInit {
|
||||
|
||||
@SubscribeEvent
|
||||
public static void registerCommand(RegisterCommandsEvent event){
|
||||
CommandDispatcher<CommandSourceStack> dispatcher=event.getDispatcher();
|
||||
dispatcher.register(Commands.literal("playersync")
|
||||
.requires(cs->cs.hasPermission(2))
|
||||
.then(Commands.literal("reconnect")
|
||||
.executes(context -> {
|
||||
// context.getSource().sendSuccess(()->MutableComponent.create(new TranslatableContents("playersync.command.reconnect")),true);
|
||||
return 0;
|
||||
}
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,11 @@
|
|||
package vip.fubuki.playersync;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.mysql.cj.jdbc.Driver;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.event.server.ServerStartingEvent;
|
||||
import net.minecraftforge.event.server.ServerStoppingEvent;
|
||||
import net.minecraftforge.eventbus.api.IEventBus;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.ModList;
|
||||
|
|
@ -16,12 +19,7 @@ import vip.fubuki.playersync.sync.ChatSync;
|
|||
import vip.fubuki.playersync.sync.VanillaSync;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
|
||||
import java.sql.Connection;
|
||||
import java.sql.DriverManager;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import com.mysql.cj.jdbc.Driver;
|
||||
import java.sql.*;
|
||||
|
||||
@Mod(PlayerSync.MODID)
|
||||
public class PlayerSync {
|
||||
|
|
@ -60,12 +58,12 @@ public class PlayerSync {
|
|||
String dbName = JdbcConfig.DATABASE_NAME.get();
|
||||
|
||||
// Step 1: Create the database using a connection that does not select a database.
|
||||
JDBCsetUp.executeUpdate("CREATE DATABASE IF NOT EXISTS " + dbName, 1);
|
||||
JDBCsetUp.executeUpdateWithoutDatabase("CREATE DATABASE IF NOT EXISTS `" + dbName + "`");
|
||||
|
||||
// Step 2: Explicitly select the database on a connection obtained without default database.
|
||||
try (Connection conn = JDBCsetUp.getConnection(false);
|
||||
Statement st = conn.createStatement()) {
|
||||
st.execute("USE " + dbName);
|
||||
st.execute("USE `" + dbName + "`");
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error selecting database " + dbName, e);
|
||||
throw e;
|
||||
|
|
@ -74,7 +72,7 @@ public class PlayerSync {
|
|||
// Step 3: Create and alter tables using fully qualified names.
|
||||
// Create player_data table
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS " + dbName + ".`player_data` (" +
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`player_data` (" +
|
||||
"`uuid` char(36) NOT NULL," +
|
||||
"`inventory` mediumblob," +
|
||||
"`armor` blob," +
|
||||
|
|
@ -107,7 +105,7 @@ public class PlayerSync {
|
|||
}
|
||||
if (columnCount < 14) {
|
||||
JDBCsetUp.executeUpdate(
|
||||
"ALTER TABLE " + dbName + ".player_data " +
|
||||
"ALTER TABLE `" + dbName + "`.`player_data` " +
|
||||
"ADD COLUMN left_hand blob, " +
|
||||
"ADD COLUMN cursors blob;"
|
||||
);
|
||||
|
|
@ -115,29 +113,50 @@ public class PlayerSync {
|
|||
|
||||
// Create server_info table
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS " + dbName + ".server_info (" +
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`server_info` (" +
|
||||
"`id` INT NOT NULL," +
|
||||
"`enable` boolean NOT NULL," +
|
||||
"`last_update` BIGINT NOT NULL," +
|
||||
"PRIMARY KEY (`id`)" +
|
||||
");"
|
||||
);
|
||||
// do not modify the create table statement to make sure this code is compatible with older database versions
|
||||
addColumnIfNotExists("server_info", "data_version", "INT NOT NULL DEFAULT 0");
|
||||
|
||||
long current = System.currentTimeMillis();
|
||||
JDBCsetUp.executeUpdate(
|
||||
"INSERT INTO " + dbName + ".server_info(id,enable,last_update) " +
|
||||
"VALUES(" + JdbcConfig.SERVER_ID.get() + ",true," + current + ") " +
|
||||
"ON DUPLICATE KEY UPDATE id= " + JdbcConfig.SERVER_ID.get() + ",enable = 1," +
|
||||
"last_update=" + current + ";"
|
||||
);
|
||||
JDBCsetUp.executeUpdate(
|
||||
"UPDATE " + dbName + ".server_info SET last_update=" + System.currentTimeMillis() +
|
||||
" WHERE id='" + JdbcConfig.SERVER_ID.get() + "'"
|
||||
);
|
||||
int data_version = SharedConstants.getCurrentVersion().getDataVersion().getVersion();
|
||||
JDBCsetUp.executeUpdate("""
|
||||
INSERT INTO `%s`.`server_info`
|
||||
(
|
||||
id,
|
||||
enable,
|
||||
data_version,
|
||||
last_update
|
||||
)
|
||||
VALUES (
|
||||
%d,
|
||||
true,
|
||||
%d,
|
||||
%d
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
id = %d,
|
||||
enable = true,
|
||||
data_version = %d,
|
||||
last_update = %d;
|
||||
""",
|
||||
dbName,
|
||||
JdbcConfig.SERVER_ID.get(),
|
||||
data_version,
|
||||
current,
|
||||
JdbcConfig.SERVER_ID.get(),
|
||||
data_version,
|
||||
current);
|
||||
|
||||
// Create curios table if the Curios mod is loaded
|
||||
if (ModList.get().isLoaded("curios")) {
|
||||
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)" +
|
||||
")"
|
||||
);
|
||||
|
|
@ -145,28 +164,26 @@ public class PlayerSync {
|
|||
|
||||
// Create backpack_data table
|
||||
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
||||
JDBCsetUp.executeUpdate(
|
||||
"CREATE TABLE IF NOT EXISTS " + dbName + ".backpack_data (" +
|
||||
JDBCsetUp.executeUpdateWithoutDatabase(
|
||||
"CREATE TABLE IF NOT EXISTS `" + dbName + "`.`backpack_data` (" +
|
||||
"uuid CHAR(36) NOT NULL, backpack_nbt MEDIUMBLOB, PRIMARY KEY (uuid)" +
|
||||
");", 1
|
||||
");"
|
||||
);
|
||||
|
||||
// Check if backpack_data table has the 'uuid' column
|
||||
JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executeQuery(
|
||||
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = '" + dbName + "' " +
|
||||
"AND TABLE_NAME = 'backpack_data' " +
|
||||
"AND COLUMN_NAME = 'uuid';"
|
||||
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)" +
|
||||
");"
|
||||
);
|
||||
ResultSet rsBackpackCol = backpackColCheck.resultSet();
|
||||
if (rsBackpackCol.next() && rsBackpackCol.getInt("colCount") == 0) {
|
||||
LOGGER.info("Altering backpack_data table to add missing 'uuid' column.");
|
||||
// Add the missing column and set it as primary key.
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE " + dbName + ".backpack_data ADD COLUMN uuid CHAR(36) NOT NULL", 1);
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE " + dbName + ".backpack_data ADD PRIMARY KEY (uuid)", 1);
|
||||
}
|
||||
rsBackpackCol.close();
|
||||
backpackColCheck.connection().close();
|
||||
}
|
||||
|
||||
// Check and alter the 'advancements' column in player_data if necessary
|
||||
|
|
@ -180,14 +197,64 @@ public class PlayerSync {
|
|||
if (rsAdvCol.next()) {
|
||||
String dataType = rsAdvCol.getString("DATA_TYPE");
|
||||
if (!"mediumblob".equalsIgnoreCase(dataType)) {
|
||||
LOGGER.info("Altering player_data table to modify 'advancements' column to MEDIUMBLOB.");
|
||||
JDBCsetUp.executeUpdate("ALTER TABLE " + dbName + ".player_data MODIFY COLUMN advancements MEDIUMBLOB", 1);
|
||||
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");
|
||||
}
|
||||
}
|
||||
rsAdvCol.close();
|
||||
// ----- 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!");
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onServerStopping(ServerStoppingEvent event){
|
||||
ChatSync.shutdown();
|
||||
}
|
||||
|
||||
private static void addColumnIfNotExists(String tableName, String columnName, String dataTypeDefaultNullness,
|
||||
boolean makePrimaryKey) throws SQLException {
|
||||
|
||||
// Making use of the AutoCloseable QueryResult here
|
||||
try (JDBCsetUp.QueryResult backpackColCheck = JDBCsetUp.executeQuery(
|
||||
"SELECT COUNT(*) AS colCount FROM INFORMATION_SCHEMA.COLUMNS " +
|
||||
"WHERE TABLE_SCHEMA = DATABASE()" +
|
||||
"AND TABLE_NAME = '" + tableName + "' " +
|
||||
"AND COLUMN_NAME = '" + columnName + "';")) {
|
||||
ResultSet rsBackpackCol = backpackColCheck.resultSet();
|
||||
|
||||
if (!rsBackpackCol.next()) {
|
||||
LOGGER.warn("Warning: Unable to check existence of colum {} in table {}.", columnName, tableName);
|
||||
return;
|
||||
}
|
||||
|
||||
if (rsBackpackCol.getInt("colCount") > 0) {
|
||||
LOGGER.debug("Column {} already exists. Skipping creation.", columnName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
LOGGER.info("ALTER {} table to add missing {} column.", tableName, columnName);
|
||||
// Add the missing column and set it as primary key.
|
||||
JDBCsetUp.executeUpdate(
|
||||
"ALTER TABLE %s ADD COLUMN %s %s",
|
||||
tableName, columnName, dataTypeDefaultNullness);
|
||||
|
||||
if (makePrimaryKey) {
|
||||
LOGGER.info("Altering {} table to add primary key on {}.", tableName, columnName);
|
||||
JDBCsetUp.executeUpdate(
|
||||
"ALTER TABLE %s ADD PRIMARY KEY (%s)",
|
||||
tableName, columnName);
|
||||
}
|
||||
}
|
||||
|
||||
private static void addColumnIfNotExists(String tableName, String columnName,
|
||||
String dataTypeDefaultNullness) throws SQLException {
|
||||
addColumnIfNotExists(tableName, columnName, dataTypeDefaultNullness, false);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,24 +9,26 @@ import java.util.Random;
|
|||
|
||||
|
||||
public class JdbcConfig {
|
||||
public static ForgeConfigSpec COMMON_CONFIG;
|
||||
public static ForgeConfigSpec.ConfigValue<String> HOST;
|
||||
public static ForgeConfigSpec.IntValue PORT;
|
||||
public static ForgeConfigSpec.ConfigValue<String> USERNAME;
|
||||
public static ForgeConfigSpec.ConfigValue<String> PASSWORD;
|
||||
public static ForgeConfigSpec.ConfigValue<String> DATABASE_NAME;
|
||||
public static ForgeConfigSpec.ConfigValue<List<String>> SYNC_WORLD;
|
||||
public static ForgeConfigSpec.BooleanValue SYNC_ADVANCEMENTS;
|
||||
public static ForgeConfigSpec.BooleanValue USE_SSL;
|
||||
public static ForgeConfigSpec.BooleanValue SYNC_CHAT;
|
||||
public static ForgeConfigSpec.BooleanValue IS_CHAT_SERVER;
|
||||
public static final ForgeConfigSpec COMMON_CONFIG;
|
||||
public static final ForgeConfigSpec.ConfigValue<String> HOST;
|
||||
public static final ForgeConfigSpec.IntValue PORT;
|
||||
public static final ForgeConfigSpec.ConfigValue<String> USERNAME;
|
||||
public static final ForgeConfigSpec.ConfigValue<String> PASSWORD;
|
||||
public static final ForgeConfigSpec.ConfigValue<String> DATABASE_NAME;
|
||||
public static final ForgeConfigSpec.ConfigValue<List<String>> SYNC_WORLD;
|
||||
public static final ForgeConfigSpec.BooleanValue SYNC_ADVANCEMENTS;
|
||||
public static final ForgeConfigSpec.BooleanValue USE_SSL;
|
||||
public static final ForgeConfigSpec.BooleanValue SYNC_CHAT;
|
||||
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_DESCRIPTION_OVERRIDE;
|
||||
public static ForgeConfigSpec.ConfigValue<String> CHAT_SERVER_IP;
|
||||
public static ForgeConfigSpec.IntValue CHAT_SERVER_PORT;
|
||||
public static ForgeConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||
public static final ForgeConfigSpec.BooleanValue KICK_WHEN_ALREADY_ONLINE;
|
||||
public static final ForgeConfigSpec.ConfigValue<String> CHAT_SERVER_IP;
|
||||
public static final ForgeConfigSpec.IntValue CHAT_SERVER_PORT;
|
||||
public static final ForgeConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||
public static final ForgeConfigSpec.BooleanValue SYNC_MEKANISM_PERSONAL_CHEST;
|
||||
|
||||
public static ForgeConfigSpec.ConfigValue<Integer> SERVER_ID;
|
||||
public static final ForgeConfigSpec.ConfigValue<Integer> SERVER_ID;
|
||||
|
||||
|
||||
static {
|
||||
|
|
@ -44,6 +46,8 @@ public class JdbcConfig {
|
|||
.define("sync_advancements", true);
|
||||
SYNC_CHAT = COMMON_BUILDER.comment("Whether synchronize chat").define("sync_chat", false);
|
||||
IS_CHAT_SERVER = COMMON_BUILDER.comment("Whether recieve messages from other servers as host").define("IsChatServer",false);
|
||||
KICK_WHEN_ALREADY_ONLINE = COMMON_BUILDER.comment("Whether to kick player when already online on another server")
|
||||
.define("kick_when_already_online", true);
|
||||
CHAT_SERVER_IP = COMMON_BUILDER.define("ChatServerIP","127.0.0.1");
|
||||
CHAT_SERVER_PORT = COMMON_BUILDER.defineInRange("ChatServerPort",7900,0,65535);
|
||||
USE_LEGACY_SERIALIZATION = COMMON_BUILDER.comment(
|
||||
|
|
@ -58,6 +62,9 @@ public class JdbcConfig {
|
|||
ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE = COMMON_BUILDER
|
||||
.comment("Override the description of placeholder items which are unavailable on the current server.")
|
||||
.define("item_placeholder_description_override", "");
|
||||
SYNC_MEKANISM_PERSONAL_CHEST = COMMON_BUILDER
|
||||
.comment("Whether to sync Mekanism personal chest inventories across servers.")
|
||||
.define("sync_mekanism_personal_chest", false);
|
||||
|
||||
COMMON_BUILDER.pop();
|
||||
COMMON_CONFIG = COMMON_BUILDER.build();
|
||||
|
|
|
|||
|
|
@ -1,140 +1,55 @@
|
|||
package vip.fubuki.playersync.sync;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.players.PlayerList;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import org.slf4j.Logger;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
import vip.fubuki.playersync.sync.chat.ChatSyncClient;
|
||||
import vip.fubuki.playersync.sync.chat.ChatSyncServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.util.Objects;
|
||||
import java.util.Scanner;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
|
||||
public class ChatSync {
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
static PlayerList playerList;
|
||||
|
||||
static ServerSocket serverSocket;
|
||||
static Socket clientSocket;
|
||||
static Set<Socket> SocketList = ConcurrentHashMap.newKeySet();
|
||||
static ExecutorService executorService = Executors.newCachedThreadPool();
|
||||
public static final Logger LOGGER = LogUtils.getLogger();
|
||||
private static ChatSyncServer chatSyncServer;
|
||||
private static ChatSyncClient chatSyncClient;
|
||||
|
||||
public static void register(){
|
||||
if(JdbcConfig.IS_CHAT_SERVER.get()) {
|
||||
LOGGER.info("Launching chat server thread.");
|
||||
new Thread(ChatSync::ServerSocket).start();
|
||||
}
|
||||
ClientSocket();
|
||||
MinecraftForge.EVENT_BUS.register(ChatSync.class);
|
||||
}
|
||||
|
||||
|
||||
private static void ServerSocket() {
|
||||
try {
|
||||
LOGGER.info("Trying to setup chat server at port " + JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
serverSocket = new ServerSocket(JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
while (true) {
|
||||
Socket newSocket = serverSocket.accept();
|
||||
SocketList.add(newSocket);
|
||||
executorService.submit(() -> handleClient(newSocket));
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.error("Unable to start chat server");
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
try {
|
||||
serverSocket.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void handleClient(Socket socket) {
|
||||
try (InputStream inputStream = socket.getInputStream()) {
|
||||
byte[] buffer = new byte[1024];
|
||||
int bytesRead;
|
||||
while ((bytesRead = inputStream.read(buffer)) != -1) {
|
||||
String message = new String(buffer, 0, bytesRead);
|
||||
broadcastMessage(socket, message);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
} finally {
|
||||
SocketList.remove(socket);
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void broadcastMessage(Socket sender, String message) {
|
||||
for (Socket socket : SocketList) {
|
||||
if (!socket.equals(sender)) {
|
||||
new Thread(()->{
|
||||
chatSyncServer = new ChatSyncServer();
|
||||
try {
|
||||
OutputStream outputStream = socket.getOutputStream();
|
||||
outputStream.write(message.getBytes());
|
||||
chatSyncServer.run();
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
LOGGER.error("Unable to start chat server", e);
|
||||
}
|
||||
}
|
||||
}, "ChatSync-Server").start();
|
||||
}
|
||||
}
|
||||
|
||||
private static void ClientSocket() {
|
||||
try {
|
||||
new Thread(()->{
|
||||
try {
|
||||
Thread.sleep(2000);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
|
||||
LOGGER.info("Trying to connect to chat server "
|
||||
+ JdbcConfig.CHAT_SERVER_IP.get()
|
||||
+ ":"
|
||||
+ JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
clientSocket = new Socket(JdbcConfig.CHAT_SERVER_IP.get(), JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
Scanner scanner = new Scanner(clientSocket.getInputStream());
|
||||
while (scanner.hasNextLine()) {
|
||||
String line = scanner.nextLine();
|
||||
Component textComponents = Component.nullToEmpty(line);
|
||||
playerList.broadcastSystemMessage(textComponents,true);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
e.printStackTrace();
|
||||
reconnectClient();
|
||||
chatSyncClient = new ChatSyncClient();
|
||||
chatSyncClient.run();
|
||||
}, "ChatSync-Client").start();
|
||||
MinecraftForge.EVENT_BUS.register(ChatSyncClient.class);
|
||||
}
|
||||
|
||||
public static void shutdown() {
|
||||
if (chatSyncServer != null) {
|
||||
chatSyncServer.shutdown();
|
||||
}
|
||||
if (chatSyncClient != null) {
|
||||
chatSyncClient.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private static void reconnectClient() {
|
||||
LOGGER.warn("TODO: implement reconnectClient()");
|
||||
//TODO
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerChat(net.minecraftforge.event.ServerChatEvent event) throws IOException {
|
||||
String message= event.getUsername()+":"+event.getMessage();
|
||||
OutputStream outputStream = clientSocket.getOutputStream();
|
||||
outputStream.write(message.getBytes());
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event){
|
||||
playerList= Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerLeave(PlayerEvent.PlayerLoggedOutEvent event){
|
||||
playerList= Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,21 @@
|
|||
package vip.fubuki.playersync.sync;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
|
||||
import com.mojang.serialization.Dynamic;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.ListTag;
|
||||
import net.minecraft.nbt.NbtUtils;
|
||||
import net.minecraft.nbt.StringTag;
|
||||
import net.minecraft.nbt.Tag;
|
||||
import net.minecraft.SharedConstants;
|
||||
import net.minecraft.core.BlockPos;
|
||||
import net.minecraft.nbt.*;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.Style;
|
||||
import net.minecraft.resources.ResourceKey;
|
||||
import net.minecraft.resources.ResourceLocation;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraft.server.PlayerAdvancements;
|
||||
import net.minecraft.server.level.ServerLevel;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraft.util.datafix.DataFixers;
|
||||
import net.minecraft.util.datafix.fixes.References;
|
||||
import net.minecraft.world.InteractionHand;
|
||||
import net.minecraft.world.effect.MobEffect;
|
||||
import net.minecraft.world.effect.MobEffectInstance;
|
||||
|
|
@ -21,10 +23,13 @@ import net.minecraft.world.entity.player.Inventory;
|
|||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.minecraft.world.item.Items;
|
||||
import net.minecraft.world.level.Level;
|
||||
import net.minecraft.world.level.storage.WorldData;
|
||||
import net.minecraftforge.event.OnDatapackSyncEvent;
|
||||
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.PlayerNegotiationEvent;
|
||||
import net.minecraftforge.event.server.ServerStoppedEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.ModList;
|
||||
|
|
@ -33,6 +38,9 @@ import net.minecraftforge.registries.ForgeRegistries;
|
|||
import net.minecraftforge.server.ServerLifecycleHooks;
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
import vip.fubuki.playersync.sync.addons.CuriosCache;
|
||||
import vip.fubuki.playersync.sync.addons.MekanismSupport;
|
||||
import vip.fubuki.playersync.sync.addons.ModsSupport;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||
import vip.fubuki.playersync.util.PSThreadPoolFactory;
|
||||
|
|
@ -43,20 +51,29 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.nio.file.Files;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.util.Base64;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
|
||||
@Mod.EventBusSubscriber
|
||||
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
|
||||
public static void onDataPackSyncEvent(OnDatapackSyncEvent event) throws SQLException, IOException {
|
||||
|
|
@ -142,106 +159,212 @@ public class VanillaSync {
|
|||
}
|
||||
}
|
||||
|
||||
public static void doPlayerJoin(PlayerEvent.PlayerLoggedInEvent event) throws SQLException, CommandSyntaxException, IOException {
|
||||
String player_uuid = event.getEntity().getUUID().toString();
|
||||
PlayerSync.LOGGER.info("Starting synchronization for player " + player_uuid);
|
||||
public static void doPlayerConnect(PlayerNegotiationEvent event) {
|
||||
try {
|
||||
String player_uuid = event.getProfile().getId().toString();
|
||||
PlayerSync.LOGGER.info("Detected connection from player" + player_uuid + ",starting checking");
|
||||
boolean online;
|
||||
int lastServer;
|
||||
|
||||
// First query: check basic player data
|
||||
JDBCsetUp.QueryResult qr1 = JDBCsetUp.executeQuery("SELECT online, last_server FROM player_data WHERE uuid='" + player_uuid + "'");
|
||||
ResultSet rs1 = qr1.resultSet();
|
||||
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
|
||||
if (!rs1.next()){
|
||||
store(event.getEntity(), true);
|
||||
return;
|
||||
}
|
||||
boolean online = rs1.getBoolean("online");
|
||||
int lastServer = rs1.getInt("last_server");
|
||||
// First query: check basic player data and check whether player can join into server.
|
||||
JDBCsetUp.QueryResult qr1 = JDBCsetUp.executeQuery("SELECT online, last_server FROM player_data WHERE uuid='" + player_uuid + "'");
|
||||
|
||||
// Second query: retrieve full player data
|
||||
JDBCsetUp.QueryResult qr2 = JDBCsetUp.executeQuery("SELECT * FROM player_data WHERE uuid='" + player_uuid + "'");
|
||||
ResultSet rs2 = qr2.resultSet();
|
||||
|
||||
// Check if player is already online on another server
|
||||
if (online && lastServer != JdbcConfig.SERVER_ID.get()) {
|
||||
JDBCsetUp.QueryResult qr3 = JDBCsetUp.executeQuery("SELECT last_update,enable FROM server_info WHERE id='" + lastServer + "'");
|
||||
ResultSet rs3 = qr3.resultSet();
|
||||
if (rs3.next()){
|
||||
long last_update = rs3.getLong("last_update");
|
||||
boolean enable = rs3.getBoolean("enable");
|
||||
if (enable && System.currentTimeMillis() < last_update + 300000.0){
|
||||
event.getEntity().removeTag("player_synced");
|
||||
serverPlayer.connection.disconnect(Component.translatable("playersync.already_online"));
|
||||
try (ResultSet rs1 = qr1.resultSet()) {
|
||||
if (!rs1.next()) {
|
||||
PlayerSync.LOGGER.info("A new-player connection detected");
|
||||
qr1.connection().close();
|
||||
return;
|
||||
}
|
||||
JDBCsetUp.executeUpdate("UPDATE server_info SET enable= '0' WHERE id=" + lastServer);
|
||||
online = rs1.getBoolean("online");
|
||||
lastServer = rs1.getInt("last_server");
|
||||
qr1.connection().close();
|
||||
}
|
||||
rs3.close();
|
||||
}
|
||||
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 + "'");
|
||||
|
||||
if (rs2.next()) {
|
||||
// Restore basic attributes
|
||||
serverPlayer.setHealth(rs2.getInt("health"));
|
||||
serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level"));
|
||||
serverPlayer.totalExperience = 0;
|
||||
serverPlayer.experienceLevel = 0;
|
||||
serverPlayer.experienceProgress = 0;
|
||||
serverPlayer.giveExperiencePoints(rs2.getInt("xp"));
|
||||
serverPlayer.setScore(rs2.getInt("score"));
|
||||
|
||||
// Restore left-hand item
|
||||
String leftHandEncoded = rs2.getString("left_hand");
|
||||
serverPlayer.setItemInHand(InteractionHand.OFF_HAND,
|
||||
deserializeAndCreatePlaceholderIfNeeded(leftHandEncoded));
|
||||
|
||||
// Restore cursor item
|
||||
String cursorsEncoded = rs2.getString("cursors");
|
||||
serverPlayer.containerMenu.setCarried(
|
||||
deserializeAndCreatePlaceholderIfNeeded(cursorsEncoded));
|
||||
|
||||
// Restore armor
|
||||
String armor_data = rs2.getString("armor");
|
||||
if (armor_data.length() > 2) {
|
||||
Map<Integer, String> equipment = LocalJsonUtil.StringToEntryMap(armor_data);
|
||||
for (Map.Entry<Integer, String> entry : equipment.entrySet()) {
|
||||
serverPlayer.getInventory().armor.set(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
// Second query: Check if player is already online on another server
|
||||
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 + "'");
|
||||
try (ResultSet rs2 = qr2.resultSet()) {
|
||||
if (rs2.next()) {
|
||||
long last_update = rs2.getLong("last_update");
|
||||
boolean enable = rs2.getBoolean("enable");
|
||||
if (enable && System.currentTimeMillis() < last_update + 300000.0) {
|
||||
event.getConnection().disconnect(Component.translatableWithFallback("playersync.already_online","You can't join more than one synchronization server at the same time."));
|
||||
qr2.connection().close();
|
||||
return;
|
||||
}
|
||||
JDBCsetUp.executeUpdate("UPDATE server_info SET enable= '0' WHERE id=" + lastServer);
|
||||
}
|
||||
qr2.connection().close();
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("SqlException detected!", e);
|
||||
event.getConnection().disconnect(Component.translatableWithFallback("playersync.sqlexception","SqlException detected!Connection lost,please contact with your admin."));
|
||||
}
|
||||
}
|
||||
|
||||
// Restore inventory
|
||||
Map<Integer, String> inventory = LocalJsonUtil.StringToEntryMap(rs2.getString("inventory"));
|
||||
for (Map.Entry<Integer, String> entry : inventory.entrySet()) {
|
||||
serverPlayer.getInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
// 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) {
|
||||
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");
|
||||
}
|
||||
|
||||
// Restore Ender Chest
|
||||
Map<Integer, String> ender_chest = LocalJsonUtil.StringToEntryMap(rs2.getString("enderchest"));
|
||||
for (Map.Entry<Integer, String> entry : ender_chest.entrySet()) {
|
||||
serverPlayer.getEnderChestInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
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 {
|
||||
PlayerSync.LOGGER.info("Starting synchronization for player " + player_uuid);
|
||||
|
||||
// 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 + "'");
|
||||
ResultSet rs1 = qr1.resultSet();
|
||||
ServerPlayer serverPlayer = (ServerPlayer) event.getEntity();
|
||||
|
||||
// Mod support
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
modsSupport.doCuriosRestore(serverPlayer);
|
||||
|
||||
if (!rs1.next()) {
|
||||
store(event.getEntity(), true);
|
||||
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 + "'");
|
||||
rs1.close();
|
||||
qr1.close();
|
||||
PlayerSync.LOGGER.info("New player detected,init completed.");
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore Effects
|
||||
String effectData = rs2.getString("effects");
|
||||
if (effectData.length() > 2) {
|
||||
serverPlayer.removeAllEffects();
|
||||
Map<Integer, String> effects = LocalJsonUtil.StringToEntryMap(effectData);
|
||||
for (Map.Entry<Integer, String> entry : effects.entrySet()) {
|
||||
CompoundTag effectTag = NbtUtils.snbtToStructure(deserializeString(entry.getValue()));
|
||||
MobEffectInstance mobEffectInstance = MobEffectInstance.load(effectTag);
|
||||
if (mobEffectInstance != null) {
|
||||
serverPlayer.addEffect(mobEffectInstance);
|
||||
// Second query: retrieve full player data
|
||||
JDBCsetUp.QueryResult qr2 = JDBCsetUp.executeQuery("SELECT * FROM player_data WHERE uuid='" + player_uuid + "'");
|
||||
ResultSet rs2 = qr2.resultSet();
|
||||
|
||||
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 + "'");
|
||||
|
||||
if (rs2.next()) {
|
||||
// Restore basic attributes
|
||||
int health = rs2.getInt("health");
|
||||
if (health <= 0) {
|
||||
serverPlayer.setHealth(1);
|
||||
} else {
|
||||
serverPlayer.setHealth(health);
|
||||
}
|
||||
serverPlayer.getFoodData().setFoodLevel(rs2.getInt("food_level"));
|
||||
|
||||
setXpForPlayer(serverPlayer, rs2.getInt("xp"));
|
||||
serverPlayer.setScore(rs2.getInt("score"));
|
||||
|
||||
// Restore left-hand item
|
||||
String leftHandEncoded = rs2.getString("left_hand");
|
||||
serverPlayer.setItemInHand(InteractionHand.OFF_HAND,
|
||||
deserializeAndCreatePlaceholderIfNeeded(leftHandEncoded));
|
||||
|
||||
// Restore cursor item
|
||||
String cursorsEncoded = rs2.getString("cursors");
|
||||
serverPlayer.containerMenu.setCarried(
|
||||
deserializeAndCreatePlaceholderIfNeeded(cursorsEncoded));
|
||||
|
||||
// Restore armor
|
||||
String armor_data = rs2.getString("armor");
|
||||
if (armor_data.length() > 2) {
|
||||
Map<Integer, String> equipment = LocalJsonUtil.StringToEntryMap(armor_data);
|
||||
for (Map.Entry<Integer, String> entry : equipment.entrySet()) {
|
||||
serverPlayer.getInventory().armor.set(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
}
|
||||
}
|
||||
|
||||
// Restore inventory
|
||||
Map<Integer, String> inventory = LocalJsonUtil.StringToEntryMap(rs2.getString("inventory"));
|
||||
for (Map.Entry<Integer, String> entry : inventory.entrySet()) {
|
||||
serverPlayer.getInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
}
|
||||
|
||||
// Restore Ender Chest
|
||||
Map<Integer, String> ender_chest = LocalJsonUtil.StringToEntryMap(rs2.getString("enderchest"));
|
||||
for (Map.Entry<Integer, String> entry : ender_chest.entrySet()) {
|
||||
serverPlayer.getEnderChestInventory().setItem(entry.getKey(), deserializeAndCreatePlaceholderIfNeeded(entry.getValue()));
|
||||
}
|
||||
|
||||
// Restore Effects
|
||||
String effectData = rs2.getString("effects");
|
||||
if (effectData.length() > 2) {
|
||||
serverPlayer.removeAllEffects();
|
||||
Map<Integer, String> effects = LocalJsonUtil.StringToEntryMap(effectData);
|
||||
for (Map.Entry<Integer, String> entry : effects.entrySet()) {
|
||||
CompoundTag effectTag = NbtUtils.snbtToStructure(deserializeString(entry.getValue()));
|
||||
MobEffectInstance mobEffectInstance = MobEffectInstance.load(effectTag);
|
||||
if (mobEffectInstance != null) {
|
||||
serverPlayer.addEffect(mobEffectInstance);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
modsSupport.doBackPackRestore(serverPlayer);
|
||||
|
||||
MekanismSupport.restorePersonalChestData(serverPlayer);
|
||||
|
||||
serverPlayer.addTag("player_synced");
|
||||
|
||||
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) {
|
||||
PlayerSync.LOGGER.error("Internal Exception detected!", e);
|
||||
syncNotCompletedPlayer.remove(player_uuid);
|
||||
}
|
||||
}
|
||||
|
||||
// Mod support
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
modsSupport.onPlayerJoin(serverPlayer);
|
||||
serverPlayer.addTag("player_synced");
|
||||
|
||||
rs2.close();
|
||||
@SubscribeEvent
|
||||
public static void onPlayerConnect(PlayerNegotiationEvent event) {
|
||||
executorService.submit(() -> {
|
||||
try {
|
||||
doPlayerConnect(event);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
|
|
@ -256,7 +379,7 @@ public class VanillaSync {
|
|||
}
|
||||
|
||||
// deserialize item and potentially create placeholders
|
||||
private static ItemStack deserializeAndCreatePlaceholderIfNeeded(String serializedNbt)
|
||||
public static ItemStack deserializeAndCreatePlaceholderIfNeeded(String serializedNbt)
|
||||
throws CommandSyntaxException {
|
||||
if (serializedNbt == null || serializedNbt.isEmpty() || serializedNbt.equals("B64:e30=")) {
|
||||
// Check for empty NBT (Base64 encoded '{}')
|
||||
|
|
@ -264,7 +387,7 @@ public class VanillaSync {
|
|||
}
|
||||
|
||||
String nbtString = deserializeString(serializedNbt);
|
||||
CompoundTag compoundTag = NbtUtils.snbtToStructure(nbtString);
|
||||
CompoundTag compoundTag = snbtToFixedCompoundTag(nbtString);
|
||||
|
||||
if (compoundTag == null || compoundTag.isEmpty() || !compoundTag.contains("id", Tag.TAG_STRING)) {
|
||||
return ItemStack.EMPTY; // Invalid or empty tag
|
||||
|
|
@ -343,7 +466,7 @@ public class VanillaSync {
|
|||
loreList.add(StringTag.valueOf(Component.Serializer.toJson(Component.literal(""))));
|
||||
|
||||
String placeholderItemDescriptionOverride = JdbcConfig.ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE.get();
|
||||
String placeholderItemDescriptionLines = placeholderItemDescriptionOverride != null && ! placeholderItemDescriptionOverride.isBlank()
|
||||
String placeholderItemDescriptionLines = placeholderItemDescriptionOverride != null && !placeholderItemDescriptionOverride.isBlank()
|
||||
? placeholderItemDescriptionOverride
|
||||
: Component.translatable("playersync.item_placeholder_description").getString();
|
||||
|
||||
|
|
@ -357,9 +480,26 @@ public class VanillaSync {
|
|||
return placeholder;
|
||||
}
|
||||
|
||||
public static CompoundTag snbtToFixedCompoundTag(String nbtString) throws CommandSyntaxException {
|
||||
CompoundTag parsedTag = TagParser.parseTag(nbtString);
|
||||
|
||||
int currentDataVersion = SharedConstants.getCurrentVersion().getDataVersion().getVersion();
|
||||
int snbtDataVersion = NbtUtils.getDataVersion(parsedTag, 500);
|
||||
|
||||
Dynamic<Tag> dynamicTagInput = new Dynamic<>(NbtOps.INSTANCE, parsedTag);
|
||||
|
||||
Dynamic<Tag> updatedDynamicTag = DataFixers.getDataFixer().update(
|
||||
References.ITEM_STACK,
|
||||
dynamicTagInput,
|
||||
snbtDataVersion,
|
||||
currentDataVersion);
|
||||
return (CompoundTag) updatedDynamicTag.getValue();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param encoded The string retrieved from the database.
|
||||
* @return The deserialized NBT string.
|
||||
*/
|
||||
|
|
@ -385,6 +525,7 @@ public class VanillaSync {
|
|||
* Serializes an NBT string for database storage.
|
||||
* Uses Base64 encoding by default (prefixed with "B64:").
|
||||
* If USE_LEGACY_SERIALIZATION config is true, uses the old custom replacement format.
|
||||
*
|
||||
* @param object The NBT string to serialize.
|
||||
* @return The serialized string.
|
||||
*/
|
||||
|
|
@ -393,10 +534,10 @@ public class VanillaSync {
|
|||
if (JdbcConfig.USE_LEGACY_SERIALIZATION.get()) {
|
||||
// Use old custom replacement logic
|
||||
return object.replace(",", "|")
|
||||
.replace("\"", "^")
|
||||
.replace("{", "<")
|
||||
.replace("}", ">")
|
||||
.replace("'", "~");
|
||||
.replace("\"", "^")
|
||||
.replace("{", "<")
|
||||
.replace("}", ">")
|
||||
.replace("'", "~");
|
||||
}
|
||||
|
||||
// Base64 encode with a "B64:" marker for new data
|
||||
|
|
@ -433,16 +574,27 @@ public class VanillaSync {
|
|||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerLogout(PlayerEvent.PlayerLoggedOutEvent event) throws SQLException {
|
||||
// Mod support
|
||||
ModsSupport modsSupport = new ModsSupport();
|
||||
modsSupport.onPlayerLeave(event.getEntity());
|
||||
executorService.submit(() -> {
|
||||
try {
|
||||
doPlayerLogout(event);
|
||||
} catch (Exception e) {
|
||||
e.printStackTrace();
|
||||
}
|
||||
});
|
||||
String player_uuid = event.getEntity().getUUID().toString();
|
||||
if (deadPlayerWhileLogging.contains(player_uuid)) {
|
||||
PlayerSync.LOGGER.warn("A dead or dying player was kicked,which uuid is:{}", player_uuid);
|
||||
JDBCsetUp.executeUpdate("UPDATE player_data SET online= '0' WHERE uuid='" + player_uuid + "'");
|
||||
deadPlayerWhileLogging.remove(player_uuid);
|
||||
} else if (syncNotCompletedPlayer.contains(player_uuid)) {
|
||||
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);
|
||||
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
|
||||
|
|
@ -453,16 +605,28 @@ public class VanillaSync {
|
|||
return itemStack.getTag().getString("playersync:original_item_nbt");
|
||||
} else {
|
||||
// It's a normal item or empty, serialize its current NBT
|
||||
return serialize(itemStack.serializeNBT().toString());
|
||||
return serialize(serializeNBT(itemStack).toString());
|
||||
}
|
||||
}
|
||||
|
||||
public static CompoundTag serializeNBT(ItemStack itemStack) {
|
||||
if (itemStack == null || itemStack.isEmpty()) {
|
||||
return new CompoundTag();
|
||||
}
|
||||
// Serialize the ItemStack to NBT
|
||||
CompoundTag compoundTag = new CompoundTag();
|
||||
itemStack.save(compoundTag);
|
||||
// Adding data version to allow newer version of Minecraft to properly update the itemstack from the db
|
||||
NbtUtils.addCurrentDataVersion(compoundTag);
|
||||
return compoundTag;
|
||||
}
|
||||
|
||||
public static void store(Player player, boolean init) throws SQLException, IOException {
|
||||
String player_uuid = player.getUUID().toString();
|
||||
PlayerSync.LOGGER.info("Storing data for player " + player_uuid + " (init=" + init + ")");
|
||||
|
||||
// Basic Attributes
|
||||
int XP = player.totalExperience;
|
||||
int XP = getTotalExperience(player);
|
||||
int score = player.getScore();
|
||||
int food_level = player.getFoodData().getFoodLevel();
|
||||
int health = (int) player.getHealth();
|
||||
|
|
@ -490,10 +654,12 @@ public class VanillaSync {
|
|||
ender_chest.put(i, getNbtForStorage(player.getEnderChestInventory().getItem(i)));
|
||||
}
|
||||
|
||||
if(ModList.get().isLoaded("sophisticatedbackpacks")){
|
||||
if (ModList.get().isLoaded("sophisticatedbackpacks")) {
|
||||
ModsSupport.storeSophisticatedBackpacks(player);
|
||||
}
|
||||
|
||||
MekanismSupport.savePersonalChestData(player);
|
||||
|
||||
// Effects
|
||||
Map<MobEffect, MobEffectInstance> effects = player.getActiveEffectsMap();
|
||||
Map<Integer, String> effectMap = new HashMap<>();
|
||||
|
|
@ -508,7 +674,7 @@ public class VanillaSync {
|
|||
if (JdbcConfig.SYNC_ADVANCEMENTS.get()) {
|
||||
File gameDir = Objects.requireNonNull(player.getServer()).getServerDirectory();
|
||||
final MinecraftServer server = ServerLifecycleHooks.getCurrentServer();
|
||||
if (server != null && server.isDedicatedServer() ) {
|
||||
if (server != null && server.isDedicatedServer()) {
|
||||
PlayerSync.LOGGER.trace("Reading dedicated server advancements");
|
||||
advancements = new File(gameDir, getSyncWorldForServer() + "/advancements" + "/" + player_uuid + ".json");
|
||||
} else {
|
||||
|
|
@ -539,7 +705,7 @@ public class VanillaSync {
|
|||
|
||||
// SQL Operation for player data
|
||||
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 {
|
||||
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 + "'");
|
||||
}
|
||||
|
|
@ -590,6 +756,8 @@ public class VanillaSync {
|
|||
// New fields for auto-save
|
||||
private static int autoSaveTickCounter = 0;
|
||||
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
|
||||
@SubscribeEvent
|
||||
|
|
@ -597,6 +765,7 @@ public class VanillaSync {
|
|||
// Run at the end phase to avoid interfering with game logic
|
||||
if (event.phase == TickEvent.Phase.END) {
|
||||
autoSaveTickCounter++;
|
||||
autoCleanCuriosCacheTickCounter++;
|
||||
if (autoSaveTickCounter >= AUTO_SAVE_INTERVAL_TICKS) {
|
||||
autoSaveTickCounter = 0;
|
||||
// Retrieve the current server instance
|
||||
|
|
@ -619,10 +788,82 @@ public class VanillaSync {
|
|||
PlayerSync.LOGGER.error("Error auto-saving Curios data for player " + player.getUUID(), e);
|
||||
}
|
||||
});
|
||||
|
||||
executorService.submit(() -> {
|
||||
try {
|
||||
MekanismSupport.savePersonalChestData(player);
|
||||
} catch (Exception e) {
|
||||
PlayerSync.LOGGER.error("Error auto-saving Mekanism data for player " + player.getUUID(), e);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void setXpForPlayer(ServerPlayer serverPlayer, int databaseXp) {
|
||||
// Don't use giveExperience() as it has several side-effects:
|
||||
// triggers an event, sends network packets, increases the score, ...
|
||||
serverPlayer.totalExperience = databaseXp;
|
||||
serverPlayer.experienceLevel = 0;
|
||||
serverPlayer.experienceProgress = 0;
|
||||
|
||||
int xpForLevel;
|
||||
|
||||
while (databaseXp >= (xpForLevel = serverPlayer.getXpNeededForNextLevel())) {
|
||||
databaseXp -= xpForLevel;
|
||||
serverPlayer.experienceLevel++;
|
||||
}
|
||||
|
||||
serverPlayer.experienceProgress = serverPlayer.experienceLevel > 0
|
||||
? (float) databaseXp / serverPlayer.getXpNeededForNextLevel()
|
||||
: 0f;
|
||||
|
||||
PlayerSync.LOGGER.debug("Giving player "
|
||||
+ serverPlayer.experienceLevel + " levels and "
|
||||
+ serverPlayer.experienceProgress * 100 + "% experience progress, calculated from "
|
||||
+ serverPlayer.totalExperience + " XP.");
|
||||
}
|
||||
|
||||
private static int getTotalExperience(final Player player) {
|
||||
int level = player.experienceLevel;
|
||||
int totalXp = 0;
|
||||
|
||||
// Calculate total XP for completed levels
|
||||
if (level > 30) {
|
||||
totalXp = (int) (4.5 * Math.pow(level, 2) - 162.5 * level + 2220);
|
||||
} else if (level > 15) {
|
||||
totalXp = (int) (2.5 * Math.pow(level, 2) - 40.5 * level + 360);
|
||||
} else {
|
||||
totalXp = level * level + 6 * level;
|
||||
}
|
||||
|
||||
// Add partial level progress
|
||||
totalXp += Math.round(player.getXpNeededForNextLevel() * player.experienceProgress);
|
||||
|
||||
PlayerSync.LOGGER.debug("Experience calcuation for "
|
||||
+ player.experienceLevel + " levels and "
|
||||
+ player.experienceProgress * 100 + "% experience progress yields "
|
||||
+ totalXp + " XP.");
|
||||
|
||||
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 net.minecraft.nbt.CompoundTag;
|
||||
|
|
@ -7,7 +7,12 @@ import net.minecraft.world.entity.player.Player;
|
|||
import net.minecraft.world.item.ItemStack;
|
||||
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.ICurioStacksHandler;
|
||||
import top.theillusivec4.curios.api.type.inventory.IDynamicStackHandler;
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.sync.VanillaSync;
|
||||
import vip.fubuki.playersync.util.JDBCsetUp;
|
||||
import vip.fubuki.playersync.util.LocalJsonUtil;
|
||||
|
||||
|
|
@ -15,87 +20,12 @@ import java.sql.ResultSet;
|
|||
import java.sql.SQLException;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import top.theillusivec4.curios.api.CuriosApi;
|
||||
import top.theillusivec4.curios.api.type.capability.ICuriosItemHandler;
|
||||
import top.theillusivec4.curios.api.type.inventory.ICurioStacksHandler;
|
||||
import top.theillusivec4.curios.api.type.inventory.IDynamicStackHandler;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
import static vip.fubuki.playersync.sync.VanillaSync.deserializeString;
|
||||
|
||||
|
||||
public class ModsSupport {
|
||||
|
||||
/**
|
||||
* Restores the Curios inventory for a player.
|
||||
* 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 {
|
||||
if (ModList.get().isLoaded("curios")) {
|
||||
// Obtain the handler from the API.
|
||||
LazyOptional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery("SELECT curios_item FROM curios WHERE uuid = '" + player.getUUID() + "'");
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) {
|
||||
String curiosData = rs.getString("curios_item");
|
||||
if (curiosData.length() <= 2) {
|
||||
rs.close();
|
||||
qr.connection().close();
|
||||
return;
|
||||
}
|
||||
// Parse the stored data (assumes a simple Map.toString() format: "{key=value, key2=value2, ...}")
|
||||
Map<String, String> storedMap = LocalJsonUtil.StringToMap(curiosData);
|
||||
|
||||
// Clear current Curios slots to avoid conflicts.
|
||||
handlerOpt.ifPresent(handler -> handler.getCurios().forEach((slotType, stacksHandler) -> {
|
||||
// Use the dynamic stack handler to clear slots.
|
||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
||||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||
dynStacks.setStackInSlot(i, ItemStack.EMPTY);
|
||||
}
|
||||
}));
|
||||
|
||||
// Restore each saved item.
|
||||
handlerOpt.ifPresent(handler -> {
|
||||
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
||||
String compositeKey = entry.getKey(); // Expected format: "slotType:index"
|
||||
String[] parts = compositeKey.split(":");
|
||||
if (parts.length != 2) {
|
||||
continue;
|
||||
}
|
||||
String slotType = parts[0];
|
||||
int slotIndex;
|
||||
try {
|
||||
slotIndex = Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException ex) {
|
||||
continue;
|
||||
}
|
||||
String serialized = entry.getValue();
|
||||
try {
|
||||
String nbtString = VanillaSync.deserializeString(serialized);
|
||||
CompoundTag tag = NbtUtils.snbtToStructure(nbtString);
|
||||
ItemStack stack = ItemStack.of(tag);
|
||||
if (handler.getCurios().containsKey(slotType)) {
|
||||
ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType);
|
||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
||||
if (slotIndex < dynStacks.getSlots()) {
|
||||
dynStacks.setStackInSlot(slotIndex, stack);
|
||||
}
|
||||
}
|
||||
} catch (CommandSyntaxException e) {
|
||||
throw new RuntimeException("Error deserializing Curio data for key " + compositeKey, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
rs.close();
|
||||
qr.connection().close();
|
||||
} else {
|
||||
// No stored data; perform an initial save.
|
||||
StoreCurios(player, true);
|
||||
}
|
||||
}
|
||||
public void doBackPackRestore(Player player) {
|
||||
if(ModList.get().isLoaded("sophisticatedbackpacks")){
|
||||
// --- Begin Backpack Data Restore ---
|
||||
PlayerSync.LOGGER.info("Restoring backpack data for player " + player.getUUID());
|
||||
|
|
@ -111,7 +41,7 @@ public class ModsSupport {
|
|||
ResultSet rsBackpack = qrBackpack.resultSet();
|
||||
if (rsBackpack.next()) {
|
||||
String serialized = rsBackpack.getString("backpack_nbt");
|
||||
String nbtString = deserializeString(serialized);
|
||||
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);
|
||||
|
|
@ -134,13 +64,104 @@ public class ModsSupport {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the Curios inventory for a player.
|
||||
* The saved data is stored as a flat map with composite keys ("slotType:index").
|
||||
*/
|
||||
public void doCuriosRestore(Player player) throws SQLException {
|
||||
if (ModList.get().isLoaded("curios")) {
|
||||
// Obtain the handler from the API.
|
||||
LazyOptional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||
JDBCsetUp.QueryResult qr = JDBCsetUp.executeQuery("SELECT curios_item FROM curios WHERE uuid = '" + player.getUUID() + "'");
|
||||
ResultSet rs = qr.resultSet();
|
||||
if (rs.next()) {
|
||||
String curiosData = rs.getString("curios_item");
|
||||
// Parse the stored data (assumes a simple Map.toString() format: "{key=value, key2=value2, ...}")
|
||||
Map<String, String> storedMap = LocalJsonUtil.StringToMap(curiosData);
|
||||
// Clear current Curios slots to avoid conflicts.
|
||||
handlerOpt.ifPresent(handler -> handler.getCurios().forEach((slotType, stacksHandler) -> {
|
||||
// Use the dynamic stack handler to clear slots.
|
||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
||||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||
dynStacks.setStackInSlot(i, ItemStack.EMPTY);
|
||||
}
|
||||
}));
|
||||
|
||||
if (curiosData.length() <= 2) {
|
||||
rs.close();
|
||||
qr.connection().close();
|
||||
return;
|
||||
}
|
||||
|
||||
// Restore each saved item.
|
||||
handlerOpt.ifPresent(handler -> {
|
||||
for (Map.Entry<String, String> entry : storedMap.entrySet()) {
|
||||
String compositeKey = entry.getKey(); // Expected format: "slotType:index"
|
||||
String[] parts = compositeKey.split(":");
|
||||
if (parts.length != 2) {
|
||||
continue;
|
||||
}
|
||||
String slotType = parts[0];
|
||||
int slotIndex;
|
||||
try {
|
||||
slotIndex = Integer.parseInt(parts[1]);
|
||||
} catch (NumberFormatException ex) {
|
||||
continue;
|
||||
}
|
||||
String serialized = entry.getValue();
|
||||
try {
|
||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(serialized);
|
||||
if (handler.getCurios().containsKey(slotType)) {
|
||||
ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType);
|
||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
||||
if (slotIndex < dynStacks.getSlots()) {
|
||||
dynStacks.setStackInSlot(slotIndex, stack);
|
||||
}
|
||||
}
|
||||
} catch (CommandSyntaxException e) {
|
||||
throw new RuntimeException("Error deserializing Curio data for key " + compositeKey, e);
|
||||
}
|
||||
}
|
||||
});
|
||||
rs.close();
|
||||
qr.connection().close();
|
||||
} else {
|
||||
// No stored data; perform an initial save.
|
||||
StoreCurios(player, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves the current Curios inventory for a player.
|
||||
* It builds a flat map keyed by "slotType:index" using the dynamic stack handler.
|
||||
*/
|
||||
public void onPlayerLeave(net.minecraft.world.entity.player.Player player) throws SQLException {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -155,7 +176,7 @@ public class ModsSupport {
|
|||
for (int i = 0; i < dynStacks.getSlots(); i++) {
|
||||
ItemStack stack = dynStacks.getStackInSlot(i);
|
||||
if (!stack.isEmpty()) {
|
||||
String serialized = VanillaSync.serialize(stack.serializeNBT().toString());
|
||||
String serialized = VanillaSync.serialize(VanillaSync.serializeNBT(stack).toString());
|
||||
flatMap.put(slotType + ":" + i, serialized);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,133 @@
|
|||
package vip.fubuki.playersync.sync.chat;
|
||||
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.players.PlayerList;
|
||||
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.ConnectException;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ChatSyncClient {
|
||||
static PlayerList playerList;
|
||||
static Socket clientSocket;
|
||||
static PrintWriter out;
|
||||
|
||||
private static volatile boolean running = true;
|
||||
private static final int RECONNECT_DELAY = 5000;
|
||||
private static final int MAX_RECONNECT_ATTEMPTS = 10;
|
||||
|
||||
public void run() {
|
||||
int reconnectAttempts = 0;
|
||||
|
||||
while (running && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
try {
|
||||
PlayerSync.LOGGER.info("Connecting to chat server {}:{}",
|
||||
JdbcConfig.CHAT_SERVER_IP.get(),
|
||||
JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
|
||||
clientSocket = new Socket();
|
||||
clientSocket.setReuseAddress(true);
|
||||
clientSocket.setKeepAlive(true);
|
||||
clientSocket.setTcpNoDelay(true);
|
||||
|
||||
clientSocket.connect(
|
||||
new InetSocketAddress(
|
||||
JdbcConfig.CHAT_SERVER_IP.get(),
|
||||
JdbcConfig.CHAT_SERVER_PORT.get()
|
||||
),
|
||||
15000
|
||||
);
|
||||
|
||||
clientSocket.setSoTimeout(0);
|
||||
|
||||
out = new PrintWriter(new BufferedWriter(
|
||||
new OutputStreamWriter(clientSocket.getOutputStream())), true);
|
||||
|
||||
PlayerSync.LOGGER.info("Successfully connected to chat server");
|
||||
reconnectAttempts = 0;
|
||||
|
||||
BufferedReader in = new BufferedReader(
|
||||
new InputStreamReader(clientSocket.getInputStream()));
|
||||
|
||||
String serverMessage;
|
||||
while (running && (serverMessage = in.readLine()) != null) {
|
||||
Component textComponents = Component.nullToEmpty(serverMessage);
|
||||
if(playerList != null){
|
||||
playerList.getServer().execute(() ->
|
||||
playerList.broadcastSystemMessage(textComponents, false));
|
||||
}else {
|
||||
PlayerSync.LOGGER.info("Received message from chat server: " + serverMessage);
|
||||
}
|
||||
}
|
||||
|
||||
} catch (SocketTimeoutException e) {
|
||||
PlayerSync.LOGGER.warn("Chat server read timeout, reconnecting...");
|
||||
} catch (ConnectException e) {
|
||||
PlayerSync.LOGGER.warn("Cannot connect to chat server: {}", e.getMessage());
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Chat client connection error: {}", e.getMessage());
|
||||
} finally {
|
||||
closeConnection();
|
||||
}
|
||||
|
||||
if (running && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
|
||||
reconnectAttempts++;
|
||||
PlayerSync.LOGGER.warn("Attempting to reconnect to chat server ({}/{})",
|
||||
reconnectAttempts, MAX_RECONNECT_ATTEMPTS);
|
||||
|
||||
try {
|
||||
long delay = Math.min(RECONNECT_DELAY * (long)Math.pow(2, reconnectAttempts-1), 60000);
|
||||
Thread.sleep(delay);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void closeConnection() {
|
||||
try {
|
||||
if (out != null) {
|
||||
out.close();
|
||||
out = null;
|
||||
}
|
||||
if (clientSocket != null && !clientSocket.isClosed()) {
|
||||
clientSocket.close();
|
||||
clientSocket = null;
|
||||
}
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error closing connection: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
running = false;
|
||||
closeConnection();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerChat(net.minecraftforge.event.ServerChatEvent event) {
|
||||
String message= "<"+event.getUsername()+"> "+event.getMessage().getString();
|
||||
if (out != null) {
|
||||
out.println(message);
|
||||
}
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerJoin(PlayerEvent.PlayerLoggedInEvent event){
|
||||
playerList = Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public static void onPlayerLeave(PlayerEvent.PlayerLoggedOutEvent event){
|
||||
playerList = Objects.requireNonNull(event.getEntity().getServer()).getPlayerList();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
package vip.fubuki.playersync.sync.chat;
|
||||
|
||||
import vip.fubuki.playersync.PlayerSync;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.PrintWriter;
|
||||
import java.net.ServerSocket;
|
||||
import java.net.Socket;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.util.Iterator;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
public class ChatSyncServer {
|
||||
static ServerSocket serverSocket;
|
||||
static final Set<Socket> SocketList = ConcurrentHashMap.newKeySet();
|
||||
static final ExecutorService executorService = Executors.newCachedThreadPool();
|
||||
private volatile boolean running = true;
|
||||
|
||||
public void run() throws IOException {
|
||||
try {
|
||||
serverSocket = new ServerSocket(JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
serverSocket.setReuseAddress(true);
|
||||
PlayerSync.LOGGER.info("Chat server started successfully on port {}", JdbcConfig.CHAT_SERVER_PORT.get());
|
||||
|
||||
while (running && !Thread.currentThread().isInterrupted()) {
|
||||
try {
|
||||
Socket newSocket = serverSocket.accept();
|
||||
newSocket.setSoTimeout(0);
|
||||
SocketList.add(newSocket);
|
||||
executorService.submit(() -> handleClient(newSocket));
|
||||
PlayerSync.LOGGER.info("New client connected, total clients: {}", SocketList.size());
|
||||
} catch (IOException e) {
|
||||
if (running) {
|
||||
PlayerSync.LOGGER.error("Error accepting client connection: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
private void handleClient(Socket socket) {
|
||||
String clientInfo = socket.getInetAddress() + ":" + socket.getPort();
|
||||
|
||||
try (BufferedReader reader = new BufferedReader(
|
||||
new InputStreamReader(socket.getInputStream()))) {
|
||||
|
||||
String message;
|
||||
while (running && (message = reader.readLine()) != null) {
|
||||
broadcastMessage(socket, message);
|
||||
}
|
||||
|
||||
} catch (SocketTimeoutException e) {
|
||||
PlayerSync.LOGGER.warn("Client {} timeout", clientInfo);
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error handling client {}: {}", clientInfo, e.getMessage());
|
||||
} finally {
|
||||
SocketList.remove(socket);
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error closing client socket: {}", e.getMessage());
|
||||
}
|
||||
PlayerSync.LOGGER.info("Client disconnected, remaining clients: {}", SocketList.size());
|
||||
}
|
||||
}
|
||||
|
||||
private void broadcastMessage(Socket sender, String message) {
|
||||
Iterator<Socket> iterator = SocketList.iterator();
|
||||
while (iterator.hasNext()) {
|
||||
Socket socket = iterator.next();
|
||||
if (!socket.equals(sender) && !socket.isClosed()) {
|
||||
try {
|
||||
PrintWriter writer = new PrintWriter(socket.getOutputStream(), true);
|
||||
writer.println(message);
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error broadcasting to client, removing: {}", e.getMessage());
|
||||
iterator.remove();
|
||||
try {
|
||||
socket.close();
|
||||
} catch (IOException ex) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
running = false;
|
||||
try {
|
||||
if (serverSocket != null && !serverSocket.isClosed()) {
|
||||
serverSocket.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
PlayerSync.LOGGER.error("Error closing server socket: {}", e.getMessage());
|
||||
}
|
||||
|
||||
for (Socket socket : SocketList) {
|
||||
try {
|
||||
if (!socket.isClosed()) {
|
||||
socket.close();
|
||||
}
|
||||
} catch (IOException e) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
SocketList.clear();
|
||||
|
||||
executorService.shutdown();
|
||||
try {
|
||||
if (!executorService.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
executorService.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
executorService.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,11 @@
|
|||
package vip.fubuki.playersync.util;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import org.slf4j.Logger;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
|
||||
import java.sql.*;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
|
||||
public class JDBCsetUp {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
|
@ -31,7 +29,7 @@ public class JDBCsetUp {
|
|||
// Ensure that the connection uses the desired database by explicitly issuing "USE dbName"
|
||||
if (selectDatabase && dbName != null && !dbName.isEmpty()) {
|
||||
try (Statement st = conn.createStatement()) {
|
||||
st.execute("USE " + dbName);
|
||||
st.execute("USE `" + dbName + "`");
|
||||
}
|
||||
}
|
||||
return conn;
|
||||
|
|
@ -45,37 +43,41 @@ public class JDBCsetUp {
|
|||
/**
|
||||
* Executes a query using a connection that includes the database.
|
||||
*/
|
||||
public static QueryResult executeQuery(String sql) throws SQLException {
|
||||
public static QueryResult executeQuery(String sqlFormatString, Object... args) throws SQLException {
|
||||
String sql = String.format(sqlFormatString, args);
|
||||
LOGGER.trace(sql);
|
||||
Connection connection = getConnection(); // With database selected (and "USE" already run)
|
||||
PreparedStatement queryStatement = connection.prepareStatement(sql);
|
||||
ResultSet resultSet = queryStatement.executeQuery();
|
||||
return new QueryResult(connection, resultSet);
|
||||
return new QueryResult(connection, queryStatement, resultSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an update using a connection that includes the database.
|
||||
* Executes an update using a connection with or without the database within the JDBC URL
|
||||
*/
|
||||
public static void executeUpdate(String sql) throws SQLException {
|
||||
private static void executeUpdate(boolean selectDatabase, String sqlFormatString, Object... args) throws SQLException {
|
||||
String sql = String.format(sqlFormatString, args);
|
||||
LOGGER.trace(sql);
|
||||
try (Connection connection = getConnection()) { // With database selected
|
||||
try (Connection connection = getConnection(selectDatabase)) {
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(sql)) {
|
||||
updateStatement.executeUpdate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an update using a connection that includes the database in the JDBC URL
|
||||
*/
|
||||
public static void executeUpdate(String sqlFormatString, Object... args) throws SQLException {
|
||||
executeUpdate(true, sqlFormatString, args);
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes an update using a connection that does NOT include a default database.
|
||||
* This method is used for commands like "CREATE DATABASE IF NOT EXISTS ..."
|
||||
*/
|
||||
public static void executeUpdate(String sql, int dummy) throws SQLException {
|
||||
LOGGER.trace(sql);
|
||||
try (Connection connection = getConnection(false)) { // Without default database
|
||||
try (PreparedStatement updateStatement = connection.prepareStatement(sql)) {
|
||||
updateStatement.executeUpdate();
|
||||
}
|
||||
}
|
||||
public static void executeUpdateWithoutDatabase(String sqlFormatString, Object... args) throws SQLException {
|
||||
executeUpdate(false, sqlFormatString, args);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -92,6 +94,32 @@ public class JDBCsetUp {
|
|||
}
|
||||
}
|
||||
|
||||
public record QueryResult(Connection connection, ResultSet resultSet) {
|
||||
public record QueryResult(Connection connection,PreparedStatement preparedStatement, ResultSet resultSet) implements AutoCloseable {
|
||||
@Override
|
||||
public void close() {
|
||||
if (resultSet != null) {
|
||||
try {
|
||||
resultSet.close();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error closing ResultSet", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (preparedStatement != null) {
|
||||
try {
|
||||
preparedStatement.close();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error closing PreparedStatement", e);
|
||||
}
|
||||
}
|
||||
|
||||
if (connection != null) {
|
||||
try {
|
||||
connection.close();
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Error closing Connection", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,31 +2,44 @@ package vip.fubuki.playersync.util;
|
|||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class LocalJsonUtil {
|
||||
public static Map<String,String> StringToMap(String param) {
|
||||
Map<String,String> map = new HashMap<>();
|
||||
String s1 = param.substring(1,param.length()-1);
|
||||
String s2 = s1.trim();
|
||||
String[] split = s2.split(",");
|
||||
for (int i = split.length - 1; i >= 0; i--) {
|
||||
String trim = split[i].trim();
|
||||
String[] split1 = trim.split("=");
|
||||
map.put(split1[0],split1[1]);
|
||||
private static <K> Map<K, String> stringToGenericMap(String param, Function<String, K> keyParser) {
|
||||
Map<K, String> map = new HashMap<>();
|
||||
|
||||
// check if string is at least minimal json
|
||||
if (param == null || param.length() < 2 || param.equals("{}")) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// extract string within outermost json brackets {}
|
||||
String s1 = param.substring(param.indexOf('{')+1, param.lastIndexOf('}')).trim();
|
||||
if (s1.isEmpty()) {
|
||||
return map;
|
||||
}
|
||||
|
||||
// split all json elements
|
||||
for (String split : s1.split(",")) {
|
||||
String trim = split.trim();
|
||||
|
||||
// only check for the first "=" as the values also contain additional "="
|
||||
int equalIndex = trim.indexOf('=');
|
||||
if (equalIndex < 0)
|
||||
continue;
|
||||
|
||||
String key = trim.substring(0, equalIndex);
|
||||
String value = trim.substring(equalIndex + 1);
|
||||
map.put(keyParser.apply(key), value);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
public static Map<Integer,String> StringToEntryMap(String param) {
|
||||
Map<Integer,String> map = new HashMap<>();
|
||||
String s1 = param.substring(1,param.length()-1);
|
||||
String s2 = s1.trim();
|
||||
String[] split = s2.split(",");
|
||||
for (int i = split.length - 1; i >= 0; i--) {
|
||||
String trim = split[i].trim();
|
||||
String[] split1 = trim.split("=");
|
||||
map.put(Integer.parseInt(split1[0]),split1[1]);
|
||||
}
|
||||
return map;
|
||||
public static Map<String, String> StringToMap(String param) {
|
||||
return stringToGenericMap(param, Function.identity());
|
||||
}
|
||||
|
||||
public static Map<Integer, String> StringToEntryMap(String param) {
|
||||
return stringToGenericMap(param, Integer::parseInt);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,8 @@
|
|||
{
|
||||
"playersync.item_placeholder_description": "Item is unknown on this server. This can either\nbe a modded item, an added, or a removed vanilla\nitem.\nThis voucher will automatically be replaced with\nthe corresponding item when joining a server\nwhere the item is known.","playersync.placeholder_titel_override": "Item Voucher",
|
||||
"playersync.item_placeholder_description": "Item is unknown on this server. This can either\nbe a modded item, an added, or a removed vanilla\nitem.\nThis voucher will automatically be replaced with\nthe corresponding item when joining a server\nwhere the item is known.",
|
||||
"playersync.placeholder_titel_override": "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.wrong_entity_status": "An error occurred while creating playerEntity in the world,please login again."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,8 @@
|
|||
{
|
||||
"playersync.already_online": "你不能同时加入多个同步的服务器。"
|
||||
"playersync.item_placeholder_description": "在这个服务器上,该物品是未知的\n它可能曾是一个其他已被移除的mod的物品,或者被移除的原版物品\n这一凭证将在加入拥有该物品的服务器时自动转换为对应物品",
|
||||
"playersync.placeholder_titel_override": "未知物品凭证",
|
||||
"playersync.item_placeholder_title": "未知物品凭证",
|
||||
"playersync.already_online": "你不能同时加入多个在线的数据互通的服务器",
|
||||
"playersync.sqlexception": "检测到Sql异常!连接已中断,请联系管理员",
|
||||
"playersync.wrong_entity_status": "在世界中尝试创建玩家实体时发生了错误,请尝试重新进入"
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user