Compare commits
104 Commits
1.20.4-2.1
...
1.20.1
| 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 |
4
.github/workflows/backport-prs.yml
vendored
4
.github/workflows/backport-prs.yml
vendored
|
|
@ -27,9 +27,9 @@ jobs:
|
|||
startsWith(github.event.comment.body, '/backport')
|
||||
)
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
- name: Create backport pull requests
|
||||
uses: korthout/backport-action@v3
|
||||
uses: korthout/backport-action@v4
|
||||
with:
|
||||
github_token: ${{ secrets.TOKEN }}
|
||||
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: |
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -31,7 +31,6 @@ build
|
|||
# other
|
||||
eclipse
|
||||
run
|
||||
runs
|
||||
run-data
|
||||
|
||||
repo
|
||||
# Files from Forge MDK
|
||||
forge*changelog.txt
|
||||
|
|
|
|||
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
|
||||
}
|
||||
115
build.gradle
115
build.gradle
|
|
@ -1,8 +1,8 @@
|
|||
plugins {
|
||||
id 'idea'
|
||||
id 'java-library'
|
||||
id 'maven-publish'
|
||||
id 'net.neoforged.moddev' version '2.0.86'
|
||||
id 'idea'
|
||||
id 'net.neoforged.moddev.legacyforge' version '2.0.84'
|
||||
}
|
||||
|
||||
tasks.named('wrapper', Wrapper).configure {
|
||||
|
|
@ -19,17 +19,6 @@ group = mod_group_id
|
|||
|
||||
repositories {
|
||||
mavenLocal()
|
||||
exclusiveContent {
|
||||
forRepository {
|
||||
maven {
|
||||
name = "Modrinth"
|
||||
url = "https://api.modrinth.com/maven"
|
||||
}
|
||||
}
|
||||
filter {
|
||||
includeGroup "maven.modrinth"
|
||||
}
|
||||
}
|
||||
exclusiveContent {
|
||||
forRepository {
|
||||
maven {
|
||||
|
|
@ -40,18 +29,19 @@ repositories {
|
|||
includeGroup "curse.maven"
|
||||
}
|
||||
}
|
||||
maven { url 'https://modmaven.dev/' }
|
||||
}
|
||||
|
||||
base {
|
||||
archivesName = mod_id
|
||||
}
|
||||
|
||||
// Mojang ships Java 17 to end users from 1.18 to 1.20.4, so your mod should target Java 17.
|
||||
// Mojang ships Java 17 to end users in 1.20.1, so mods should target Java 17.
|
||||
java.toolchain.languageVersion = JavaLanguageVersion.of(17)
|
||||
|
||||
neoForge {
|
||||
// Specify the version of NeoForge to use.
|
||||
version = project.neo_version
|
||||
legacyForge {
|
||||
// Specify the version of MinecraftForge to use.
|
||||
version = project.minecraft_version + '-' + project.forge_version
|
||||
|
||||
parchment {
|
||||
mappingsVersion = project.parchment_mappings_version
|
||||
|
|
@ -68,13 +58,13 @@ neoForge {
|
|||
client()
|
||||
|
||||
// Comma-separated list of namespaces to load gametests from. Empty = all namespaces.
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
systemProperty 'forge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
|
||||
server {
|
||||
server()
|
||||
programArgument '--nogui'
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
systemProperty 'forge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
|
||||
// This run config launches GameTestServer and runs all registered gametests, then exits.
|
||||
|
|
@ -82,7 +72,7 @@ neoForge {
|
|||
// The gametest system is also enabled by default for other run configs under the /test command.
|
||||
gameTestServer {
|
||||
type = "gameTestServer"
|
||||
systemProperty 'neoforge.enabledGameTestNamespaces', project.mod_id
|
||||
systemProperty 'forge.enabledGameTestNamespaces', project.mod_id
|
||||
}
|
||||
|
||||
data {
|
||||
|
|
@ -114,7 +104,8 @@ neoForge {
|
|||
mods {
|
||||
// define mod <-> source bindings
|
||||
// these are used to tell the game which sources are for which mod
|
||||
// multi mod projects should define one per mod
|
||||
// mostly optional in a single mod project
|
||||
// but multi mod projects should define one per mod
|
||||
"${mod_id}" {
|
||||
sourceSet(sourceSets.main)
|
||||
}
|
||||
|
|
@ -124,38 +115,54 @@ neoForge {
|
|||
// Include resources generated by data generators.
|
||||
sourceSets.main.resources { srcDir 'src/generated/resources' }
|
||||
|
||||
// Sets up a dependency configuration called 'localRuntime'.
|
||||
// This configuration should be used instead of 'runtimeOnly' to declare
|
||||
// Sets up a dependency configuration called 'localRuntime' and a deobfuscating one called 'modLocalRuntime'
|
||||
// These configurations should be used instead of 'runtimeOnly' to declare
|
||||
// a dependency that will be present for runtime testing but that is
|
||||
// "optional", meaning it will not be pulled by dependents of this mod.
|
||||
configurations {
|
||||
runtimeClasspath.extendsFrom localRuntime
|
||||
}
|
||||
obfuscation {
|
||||
createRemappingConfiguration(configurations.localRuntime)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// If you wish to declare dependencies against mods, make sure to use the 'mod*' configurations so that they're remapped.
|
||||
// See https://github.com/neoforged/ModDevGradle/blob/main/LEGACY.md#remapping-mod-dependencies for more information.
|
||||
|
||||
// Example optional mod dependency with JEI
|
||||
// The JEI API is declared for compile time use, while the full JEI artifact is used at runtime
|
||||
// compileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
|
||||
// compileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}"
|
||||
// modCompileOnly "mezz.jei:jei-${mc_version}-common-api:${jei_version}"
|
||||
// modCompileOnly "mezz.jei:jei-${mc_version}-neoforge-api:${jei_version}"
|
||||
// We add the full version to localRuntime, not runtimeOnly, so that we do not publish a dependency on it
|
||||
// localRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
|
||||
compileOnly "maven.modrinth:curios:7.4.3+1.20.4"
|
||||
compileOnly "curse.maven:sophisticated-backpacks-422301:5297718"
|
||||
compileOnly "curse.maven:sophisticated-core-618298:5296142"
|
||||
// modLocalRuntime "mezz.jei:jei-${mc_version}-neoforge:${jei_version}"
|
||||
compileOnly "curse.maven:curios-309927:5266541"
|
||||
compileOnly "curse.maven:sophisticated-backpacks-422301:7169843"
|
||||
compileOnly "curse.maven:sophisticated-core-618298:7169400"
|
||||
// compileOnly "mekanism:Mekanism:${mekanism_version}:api"
|
||||
|
||||
// If you want to test/use Mekanism & its modules during `runClient` invocation, use the following
|
||||
modCompileOnly ("mekanism:Mekanism:${mekanism_version}")// core
|
||||
// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:additions")// Mekanism: Additions
|
||||
// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:generators")// Mekanism: Generators
|
||||
// modcompileOnly ("mekanism:Mekanism:${mekanism_version}:tools")// Mekanism: Tools
|
||||
|
||||
|
||||
// Example mod dependency using a mod jar from ./libs with a flat dir repository
|
||||
// This maps to ./libs/coolmod-${mc_version}-${coolmod_version}.jar
|
||||
// The group id is ignored when searching -- in this case, it is "blank"
|
||||
// implementation "blank:coolmod-${mc_version}:${coolmod_version}"
|
||||
// modImplementation "blank:coolmod-${mc_version}:${coolmod_version}"
|
||||
|
||||
// Example mod dependency using a file as dependency
|
||||
// implementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar")
|
||||
// modImplementation files("libs/coolmod-${mc_version}-${coolmod_version}.jar")
|
||||
|
||||
// Example project dependency using a sister or child project:
|
||||
// implementation project(":myproject")
|
||||
// modImplementation project(":myproject")
|
||||
|
||||
// embedd the JDBC driver in the mod using jarJar
|
||||
runtimeOnly "com.mysql:mysql-connector-j:${jdbc_version}"
|
||||
// JDBC driver auto-detection is broken in Forge as of v47.4.0
|
||||
// i.e. we need to need it both at compile and runtime
|
||||
implementation "com.mysql:mysql-connector-j:${jdbc_version}"
|
||||
jarJar "com.mysql:mysql-connector-j:${jdbc_version}"
|
||||
additionalRuntimeClasspath "com.mysql:mysql-connector-j:${jdbc_version}"
|
||||
|
||||
|
|
@ -164,21 +171,39 @@ dependencies {
|
|||
// http://www.gradle.org/docs/current/userguide/dependency_management.html
|
||||
}
|
||||
|
||||
// Uncomment the lines below if you wish to configure mixin. The mixin file should be named modid.mixins.json.
|
||||
/*
|
||||
mixin {
|
||||
add sourceSets.main, "${mod_id}.refmap.json"
|
||||
config "${mod_id}.mixins.json"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
annotationProcessor 'org.spongepowered:mixin:0.8.5:processor'
|
||||
}
|
||||
|
||||
jar {
|
||||
manifest.attributes([
|
||||
"MixinConfigs": "${mod_id}.mixins.json"
|
||||
])
|
||||
}
|
||||
*/
|
||||
|
||||
// This block of code expands all declared replace properties in the specified resource targets.
|
||||
// A missing property will result in an error. Properties are expanded using ${} Groovy notation.
|
||||
var generateModMetadata = tasks.register("generateModMetadata", ProcessResources) {
|
||||
var replaceProperties = [
|
||||
minecraft_version : minecraft_version,
|
||||
minecraft_version_range: minecraft_version_range,
|
||||
neo_version : neo_version,
|
||||
neo_version_range : neo_version_range,
|
||||
loader_version_range : loader_version_range,
|
||||
mod_id : mod_id,
|
||||
mod_name : mod_name,
|
||||
mod_license : mod_license,
|
||||
mod_version : mod_version,
|
||||
mod_authors : mod_authors,
|
||||
mod_description : mod_description
|
||||
minecraft_version : minecraft_version,
|
||||
minecraft_version_range : minecraft_version_range,
|
||||
forge_version : forge_version,
|
||||
forge_version_range : forge_version_range,
|
||||
loader_version_range : loader_version_range,
|
||||
mod_id : mod_id,
|
||||
mod_name : mod_name,
|
||||
mod_license : mod_license,
|
||||
mod_version : mod_version,
|
||||
mod_authors : mod_authors,
|
||||
mod_description : mod_description
|
||||
]
|
||||
inputs.properties replaceProperties
|
||||
expand replaceProperties
|
||||
|
|
@ -189,7 +214,7 @@ var generateModMetadata = tasks.register("generateModMetadata", ProcessResources
|
|||
// this works with both building through Gradle and the IDE.
|
||||
sourceSets.main.resources.srcDir generateModMetadata
|
||||
// To avoid having to run "generateModMetadata" manually, make it run on every project reload
|
||||
neoForge.ideSyncTask generateModMetadata
|
||||
legacyForge.ideSyncTask generateModMetadata
|
||||
|
||||
// Example configuration to allow publishing using the maven-publish plugin
|
||||
publishing {
|
||||
|
|
|
|||
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`).
|
||||
|
|
@ -7,22 +7,22 @@ org.gradle.configuration-cache=true
|
|||
|
||||
#read more on this at https://github.com/neoforged/ModDevGradle?tab=readme-ov-file#better-minecraft-parameter-names--javadoc-parchment
|
||||
# you can also find the latest versions at: https://parchmentmc.org/docs/getting-started
|
||||
parchment_minecraft_version=1.20.4
|
||||
parchment_mappings_version=2024.04.14
|
||||
parchment_minecraft_version=1.20.1
|
||||
parchment_mappings_version=2023.09.03
|
||||
# Environment Properties
|
||||
# You can find the latest versions here: https://projects.neoforged.net/neoforged/neoforge
|
||||
# The Minecraft version must agree with the Neo version to get a valid artifact
|
||||
minecraft_version=1.20.4
|
||||
# You can find the latest versions here: https://files.minecraftforge.net/net/minecraftforge/forge/index_1.20.1.html
|
||||
# The Minecraft version must agree with the Forge version to get a valid artifact
|
||||
minecraft_version=1.20.1
|
||||
# The Minecraft version range can use any release version of Minecraft as bounds.
|
||||
# Snapshots, pre-releases, and release candidates are not guaranteed to sort properly
|
||||
# as they do not follow standard versioning conventions.
|
||||
minecraft_version_range=[1.20.4]
|
||||
# The Neo version must agree with the Minecraft version to get a valid artifact
|
||||
neo_version=20.4.248
|
||||
# The Neo version range can use any version of Neo as bounds
|
||||
neo_version_range=[20.4.248,)
|
||||
minecraft_version_range=[1.20.1, 1.21)
|
||||
# The Forge version must agree with the Minecraft version to get a valid artifact
|
||||
forge_version=47.4.0
|
||||
# The Forge version range can use any version of Forge as bounds
|
||||
forge_version_range=[47,)
|
||||
# The loader version range can only use the major version of FML as bounds
|
||||
loader_version_range=[1,)
|
||||
loader_version_range=[47,)
|
||||
|
||||
## Mod Properties
|
||||
|
||||
|
|
@ -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.1
|
||||
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
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
pluginManagement {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
gradlePluginPortal()
|
||||
maven { url = 'https://maven.neoforged.net/releases' }
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id 'org.gradle.toolchains.foojay-resolver-convention' version '0.9.0'
|
||||
}
|
||||
|
|
|
|||
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,40 +1,47 @@
|
|||
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;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.config.ModConfig;
|
||||
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
|
||||
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
|
||||
import org.slf4j.Logger;
|
||||
import vip.fubuki.playersync.config.JdbcConfig;
|
||||
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.ResultSet;
|
||||
import java.sql.SQLException;
|
||||
import java.sql.Statement;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
import net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.fml.ModLoadingContext;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.fml.config.ModConfig;
|
||||
import net.neoforged.fml.event.lifecycle.FMLCommonSetupEvent;
|
||||
import net.neoforged.fml.javafmlmod.FMLJavaModLoadingContext;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.server.ServerStartingEvent;
|
||||
import net.neoforged.bus.api.IEventBus;
|
||||
import java.sql.*;
|
||||
|
||||
@Mod(PlayerSync.MODID)
|
||||
public class PlayerSync {
|
||||
public static final String MODID = "playersync";
|
||||
public static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
public PlayerSync(IEventBus modEventBus) {
|
||||
public PlayerSync(FMLJavaModLoadingContext context) {
|
||||
IEventBus modEventBus = context.getModEventBus();
|
||||
context.registerConfig(ModConfig.Type.COMMON, JdbcConfig.COMMON_CONFIG);
|
||||
modEventBus.addListener(this::commonSetup);
|
||||
NeoForge.EVENT_BUS.register(this);
|
||||
ModLoadingContext.get().registerConfig(ModConfig.Type.COMMON, JdbcConfig.COMMON_CONFIG);
|
||||
MinecraftForge.EVENT_BUS.register(this);
|
||||
}
|
||||
|
||||
private void commonSetup(final FMLCommonSetupEvent event) {
|
||||
// JDBC driver auto-detection is broken in Forge as of v47.4.0
|
||||
// We need to register the driver manually
|
||||
try {
|
||||
DriverManager.registerDriver(new Driver());
|
||||
} catch (SQLException e) {
|
||||
LOGGER.error("Unable to register JDBC MySQL driver", e);
|
||||
}
|
||||
|
||||
VanillaSync.register();
|
||||
event.enqueueWork(() -> {
|
||||
// read SYNC_CHAT only within the enqueueWork to reliably get the real
|
||||
|
|
@ -51,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;
|
||||
|
|
@ -65,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," +
|
||||
|
|
@ -98,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;"
|
||||
);
|
||||
|
|
@ -106,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)" +
|
||||
")"
|
||||
);
|
||||
|
|
@ -136,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
|
||||
|
|
@ -171,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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,35 +1,38 @@
|
|||
package vip.fubuki.playersync.config;
|
||||
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Random;
|
||||
import net.neoforged.neoforge.common.ModConfigSpec;
|
||||
|
||||
|
||||
public class JdbcConfig {
|
||||
public static ModConfigSpec COMMON_CONFIG;
|
||||
public static ModConfigSpec.ConfigValue<String> HOST;
|
||||
public static ModConfigSpec.IntValue PORT;
|
||||
public static ModConfigSpec.ConfigValue<String> USERNAME;
|
||||
public static ModConfigSpec.ConfigValue<String> PASSWORD;
|
||||
public static ModConfigSpec.ConfigValue<String> DATABASE_NAME;
|
||||
public static ModConfigSpec.ConfigValue<List<String>> SYNC_WORLD;
|
||||
public static ModConfigSpec.BooleanValue SYNC_ADVANCEMENTS;
|
||||
public static ModConfigSpec.BooleanValue USE_SSL;
|
||||
public static ModConfigSpec.BooleanValue SYNC_CHAT;
|
||||
public static ModConfigSpec.BooleanValue IS_CHAT_SERVER;
|
||||
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_TITLE_OVERRIDE;
|
||||
public static final ModConfigSpec.ConfigValue<String> ITEM_PLACEHOLDER_DESCRIPTION_OVERRIDE;
|
||||
public static ModConfigSpec.ConfigValue<String> CHAT_SERVER_IP;
|
||||
public static ModConfigSpec.IntValue CHAT_SERVER_PORT;
|
||||
public static ModConfigSpec.BooleanValue USE_LEGACY_SERIALIZATION;
|
||||
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 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 ModConfigSpec.ConfigValue<Integer> SERVER_ID;
|
||||
public static final ForgeConfigSpec.ConfigValue<Integer> SERVER_ID;
|
||||
|
||||
|
||||
static {
|
||||
ModConfigSpec.Builder COMMON_BUILDER = new ModConfigSpec.Builder();
|
||||
ForgeConfigSpec.Builder COMMON_BUILDER = new ForgeConfigSpec.Builder();
|
||||
COMMON_BUILDER.comment("General settings").push("general");
|
||||
HOST=COMMON_BUILDER.comment("The host of the database").define("host", "localhost");
|
||||
PORT = COMMON_BUILDER.comment("database port").defineInRange("db_port", 3306, 0, 65535);
|
||||
|
|
@ -43,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(
|
||||
|
|
@ -57,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 net.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.neoforge.common.NeoForge;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import com.mojang.logging.LogUtils;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
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();
|
||||
NeoForge.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.neoforged.neoforge.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,18 +23,24 @@ 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.neoforged.bus.api.SubscribeEvent;
|
||||
import net.neoforged.fml.ModList;
|
||||
import net.neoforged.fml.common.Mod;
|
||||
import net.neoforged.neoforge.event.OnDatapackSyncEvent;
|
||||
import net.neoforged.neoforge.event.TickEvent;
|
||||
import net.neoforged.neoforge.event.entity.player.PlayerEvent;
|
||||
import net.neoforged.neoforge.event.server.ServerStoppedEvent;
|
||||
import net.minecraft.core.registries.BuiltInRegistries;
|
||||
import net.neoforged.neoforge.server.ServerLifecycleHooks;
|
||||
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;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
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,104 +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"));
|
||||
|
||||
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()));
|
||||
// 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
|
||||
|
|
@ -254,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 '{}')
|
||||
|
|
@ -262,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
|
||||
|
|
@ -275,7 +400,7 @@ public class VanillaSync {
|
|||
return ItemStack.EMPTY; // Cannot determine item type
|
||||
}
|
||||
|
||||
if (BuiltInRegistries.ITEM.containsKey(registryName)) {
|
||||
if (ForgeRegistries.ITEMS.containsKey(registryName)) {
|
||||
// Item exists (could be vanilla or a loaded mod item), restore normally
|
||||
try {
|
||||
ItemStack restoredItem = ItemStack.of(compoundTag);
|
||||
|
|
@ -284,7 +409,7 @@ public class VanillaSync {
|
|||
// Either the item is not empty, or it is empty and the original tag was also
|
||||
// empty or it was an empty inventory slot
|
||||
if (!restoredItem.isEmpty() || compoundTag.isEmpty()
|
||||
|| registryName.equals(ResourceLocation.tryParse("air"))) {
|
||||
|| registryName.equals(ResourceLocation.parse("air"))) {
|
||||
return restoredItem;
|
||||
}
|
||||
// ItemStack.of unexpectedly returned empty for a known, non-air item.
|
||||
|
|
@ -341,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();
|
||||
|
||||
|
|
@ -355,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.
|
||||
*/
|
||||
|
|
@ -383,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.
|
||||
*/
|
||||
|
|
@ -391,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
|
||||
|
|
@ -431,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
|
||||
|
|
@ -462,6 +616,8 @@ public class VanillaSync {
|
|||
// 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;
|
||||
}
|
||||
|
||||
|
|
@ -498,16 +654,18 @@ 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<>();
|
||||
for (Map.Entry<MobEffect, MobEffectInstance> entry : effects.entrySet()) {
|
||||
CompoundTag effectTag = entry.getValue().save(new CompoundTag());
|
||||
effectMap.put(BuiltInRegistries.MOB_EFFECT.getId(entry.getKey()), serialize(effectTag.toString()));
|
||||
effectMap.put(MobEffect.getId(entry.getKey()), serialize(effectTag.toString()));
|
||||
}
|
||||
|
||||
// Advancements
|
||||
|
|
@ -516,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 {
|
||||
|
|
@ -547,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 + "'");
|
||||
}
|
||||
|
|
@ -598,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
|
||||
|
|
@ -605,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
|
||||
|
|
@ -627,10 +788,26 @@ 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());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -681,4 +858,12 @@ public class VanillaSync {
|
|||
|
||||
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,12 +1,18 @@
|
|||
package vip.fubuki.playersync.sync;
|
||||
package vip.fubuki.playersync.sync.addons;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import net.minecraft.nbt.CompoundTag;
|
||||
import net.minecraft.nbt.NbtUtils;
|
||||
import net.minecraft.world.entity.player.Player;
|
||||
import net.minecraft.world.item.ItemStack;
|
||||
import net.neoforged.fml.ModList;
|
||||
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;
|
||||
|
||||
|
|
@ -14,39 +20,64 @@ 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 {
|
||||
public void doBackPackRestore(Player player) {
|
||||
if(ModList.get().isLoaded("sophisticatedbackpacks")){
|
||||
// --- Begin Backpack Data Restore ---
|
||||
PlayerSync.LOGGER.info("Restoring backpack data for player " + player.getUUID());
|
||||
net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, (ItemStack backpackItem, String handler, String identifier, int slot) -> {
|
||||
backpackItem.getCapability(net.p3pp3rf1y.sophisticatedbackpacks.api.CapabilityBackpackWrapper.getCapabilityInstance())
|
||||
.ifPresent(wrapper -> {
|
||||
// Retrieve the contents UUID from the backpack's NBT using NBTHelper
|
||||
Optional<UUID> uuidOpt = net.p3pp3rf1y.sophisticatedcore.util.NBTHelper.getUniqueId(wrapper.getBackpack(), "contentsUuid");
|
||||
if (uuidOpt.isPresent()) {
|
||||
UUID contentsUuid = uuidOpt.get();
|
||||
try {
|
||||
JDBCsetUp.QueryResult qrBackpack = JDBCsetUp.executeQuery("SELECT backpack_nbt FROM backpack_data WHERE uuid='" + contentsUuid + "'");
|
||||
ResultSet rsBackpack = qrBackpack.resultSet();
|
||||
if (rsBackpack.next()) {
|
||||
String serialized = rsBackpack.getString("backpack_nbt");
|
||||
String nbtString = VanillaSync.deserializeString(serialized);
|
||||
CompoundTag backpackNbt = NbtUtils.snbtToStructure(nbtString);
|
||||
// Update BackpackStorage with the retrieved NBT
|
||||
net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, backpackNbt);
|
||||
PlayerSync.LOGGER.info("Restored backpack data for UUID " + contentsUuid);
|
||||
}
|
||||
rsBackpack.close();
|
||||
qrBackpack.connection().close();
|
||||
} catch (SQLException e) {
|
||||
PlayerSync.LOGGER.error("Error restoring backpack data for UUID " + contentsUuid, e);
|
||||
} catch (CommandSyntaxException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid during restore");
|
||||
}
|
||||
});
|
||||
return false;
|
||||
});
|
||||
// --- End Backpack Data Restore ---
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the Curios inventory for a player.
|
||||
* The saved data is stored as a flat map with composite keys ("slotType:index").
|
||||
*/
|
||||
public void onPlayerJoin(net.minecraft.world.entity.player.Player player) throws SQLException {
|
||||
public void doCuriosRestore(Player player) throws SQLException {
|
||||
if (ModList.get().isLoaded("curios")) {
|
||||
// Obtain the handler from the API.
|
||||
Optional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||
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.
|
||||
|
|
@ -56,6 +87,12 @@ public class ModsSupport {
|
|||
}
|
||||
}));
|
||||
|
||||
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()) {
|
||||
|
|
@ -73,9 +110,7 @@ public class ModsSupport {
|
|||
}
|
||||
String serialized = entry.getValue();
|
||||
try {
|
||||
String nbtString = VanillaSync.deserializeString(serialized);
|
||||
CompoundTag tag = NbtUtils.snbtToStructure(nbtString);
|
||||
ItemStack stack = ItemStack.of(tag);
|
||||
ItemStack stack = VanillaSync.deserializeAndCreatePlaceholderIfNeeded(serialized);
|
||||
if (handler.getCurios().containsKey(slotType)) {
|
||||
ICurioStacksHandler stacksHandler = handler.getCurios().get(slotType);
|
||||
IDynamicStackHandler dynStacks = stacksHandler.getStacks();
|
||||
|
|
@ -95,43 +130,6 @@ public class ModsSupport {
|
|||
StoreCurios(player, true);
|
||||
}
|
||||
}
|
||||
if(ModList.get().isLoaded("sophisticatedbackpacks")){
|
||||
// --- Begin Backpack Data Restore ---
|
||||
PlayerSync.LOGGER.info("Restoring backpack data for player " + player.getUUID());
|
||||
net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, (ItemStack backpackItem, String handler, String identifier, int slot) -> {
|
||||
net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.IBackpackWrapper backpackWrapper = net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.BackpackWrapper
|
||||
.fromData(backpackItem);
|
||||
|
||||
// Retrieve the contents UUID from the backpack's NBT using NBTHelper
|
||||
Optional<UUID> uuidOpt = net.p3pp3rf1y.sophisticatedcore.util.NBTHelper
|
||||
.getUniqueId(backpackWrapper.getBackpack(), "contentsUuid");
|
||||
if (uuidOpt.isPresent()) {
|
||||
UUID contentsUuid = uuidOpt.get();
|
||||
try {
|
||||
JDBCsetUp.QueryResult qrBackpack = JDBCsetUp.executeQuery("SELECT backpack_nbt FROM backpack_data WHERE uuid='" + contentsUuid + "'");
|
||||
ResultSet rsBackpack = qrBackpack.resultSet();
|
||||
if (rsBackpack.next()) {
|
||||
String serialized = rsBackpack.getString("backpack_nbt");
|
||||
String nbtString = deserializeString(serialized);
|
||||
CompoundTag backpackNbt = NbtUtils.snbtToStructure(nbtString);
|
||||
// Update BackpackStorage with the retrieved NBT
|
||||
net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().setBackpackContents(contentsUuid, backpackNbt);
|
||||
PlayerSync.LOGGER.info("Restored backpack data for UUID " + contentsUuid);
|
||||
}
|
||||
rsBackpack.close();
|
||||
qrBackpack.connection().close();
|
||||
} catch (SQLException e) {
|
||||
PlayerSync.LOGGER.error("Error restoring backpack data for UUID " + contentsUuid, e);
|
||||
} catch (CommandSyntaxException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
} else {
|
||||
PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid during restore");
|
||||
}
|
||||
return false;
|
||||
});
|
||||
// --- End Backpack Data Restore ---
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -140,12 +138,35 @@ public class ModsSupport {
|
|||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public void StoreCurios(net.minecraft.world.entity.player.Player player, boolean init) throws SQLException {
|
||||
Optional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||
LazyOptional<ICuriosItemHandler> handlerOpt = CuriosApi.getCuriosInventory(player);
|
||||
Map<String, String> flatMap = new HashMap<>();
|
||||
|
||||
handlerOpt.ifPresent(handler -> {
|
||||
|
|
@ -173,27 +194,26 @@ public class ModsSupport {
|
|||
public static void storeSophisticatedBackpacks(Player player) {
|
||||
PlayerSync.LOGGER.info("Storing backpack data for player " + player.getUUID());
|
||||
net.p3pp3rf1y.sophisticatedbackpacks.util.PlayerInventoryProvider.get().runOnBackpacks(player, (ItemStack backpackItem, String handler, String identifier, int slot) -> {
|
||||
net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.IBackpackWrapper backpackWrapper = net.p3pp3rf1y.sophisticatedbackpacks.backpack.wrapper.BackpackWrapper
|
||||
.fromData(backpackItem);
|
||||
|
||||
// Retrieve the contents UUID from the backpack's NBT using NBTHelper
|
||||
Optional<UUID> uuidOpt = net.p3pp3rf1y.sophisticatedcore.util.NBTHelper
|
||||
.getUniqueId(backpackWrapper.getBackpack(), "contentsUuid");
|
||||
if (uuidOpt.isPresent()) {
|
||||
UUID contentsUuid = uuidOpt.get();
|
||||
// Get internal backpack data from BackpackStorage (creates it if missing)
|
||||
CompoundTag backpackNbt = net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().getOrCreateBackpackContents(contentsUuid);
|
||||
String serialized = VanillaSync.serialize(backpackNbt.toString());
|
||||
try {
|
||||
// Use REPLACE INTO so existing records are updated
|
||||
JDBCsetUp.executeUpdate("REPLACE INTO backpack_data (uuid, backpack_nbt) VALUES ('" + contentsUuid + "', '" + serialized + "')");
|
||||
PlayerSync.LOGGER.info("Saved backpack data for UUID " + contentsUuid);
|
||||
} catch (SQLException e) {
|
||||
PlayerSync.LOGGER.error("Error saving backpack data for UUID " + contentsUuid, e);
|
||||
}
|
||||
} else {
|
||||
PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid");
|
||||
}
|
||||
backpackItem.getCapability(net.p3pp3rf1y.sophisticatedbackpacks.api.CapabilityBackpackWrapper.getCapabilityInstance())
|
||||
.ifPresent(wrapper -> {
|
||||
// Retrieve the contents UUID from the backpack's NBT using NBTHelper
|
||||
Optional<UUID> uuidOpt = net.p3pp3rf1y.sophisticatedcore.util.NBTHelper.getUniqueId(wrapper.getBackpack(), "contentsUuid");
|
||||
if (uuidOpt.isPresent()) {
|
||||
UUID contentsUuid = uuidOpt.get();
|
||||
// Get internal backpack data from BackpackStorage (creates it if missing)
|
||||
CompoundTag backpackNbt = net.p3pp3rf1y.sophisticatedbackpacks.backpack.BackpackStorage.get().getOrCreateBackpackContents(contentsUuid);
|
||||
String serialized = VanillaSync.serialize(backpackNbt.toString());
|
||||
try {
|
||||
// Use REPLACE INTO so existing records are updated
|
||||
JDBCsetUp.executeUpdate("REPLACE INTO backpack_data (uuid, backpack_nbt) VALUES ('" + contentsUuid + "', '" + serialized + "')");
|
||||
PlayerSync.LOGGER.info("Saved backpack data for UUID " + contentsUuid);
|
||||
} catch (SQLException e) {
|
||||
PlayerSync.LOGGER.error("Error saving backpack data for UUID " + contentsUuid, e);
|
||||
}
|
||||
} else {
|
||||
PlayerSync.LOGGER.warn("Backpack item in slot " + slot + " has no contentsUuid");
|
||||
}
|
||||
});
|
||||
return false; // Continue processing all backpack items.
|
||||
});
|
||||
}
|
||||
|
|
@ -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_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,5 +1,8 @@
|
|||
{
|
||||
"playersync.item_placeholder_description": "物品在此服务器未知。这可能是一个模组物品,或是不同版本的原版物品。\n这张券将会在加入可识别此物品的服务器后自动替换为对应物品。",
|
||||
"playersync.item_placeholder_title": "物品券",
|
||||
"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": "在世界中尝试创建玩家实体时发生了错误,请尝试重新进入"
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@
|
|||
# The name of the mod loader type to load - for regular FML @Mod mods it should be javafml
|
||||
modLoader="javafml" #mandatory
|
||||
|
||||
# A version range to match for said mod loader - for regular FML @Mod it will be the FML version. This is currently 2.
|
||||
loaderVersion="${loader_version_range}" #mandatory
|
||||
# A version range to match for said mod loader - for regular FML @Mod it will be the forge version
|
||||
loaderVersion="${loader_version_range}" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions.
|
||||
|
||||
# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties.
|
||||
# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here.
|
||||
|
|
@ -28,7 +28,7 @@ version="${mod_version}" #mandatory
|
|||
# A display name for the mod
|
||||
displayName="${mod_name}" #mandatory
|
||||
|
||||
# A URL to query for updates for this mod. See the JSON update specification https://docs.neoforged.net/docs/misc/updatechecker/
|
||||
# A URL to query for updates for this mod. See the JSON update specification https://docs.minecraftforge.net/en/latest/misc/updatechecker/
|
||||
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
|
||||
|
||||
# A URL for the "homepage" for this mod, displayed in the mod UI
|
||||
|
|
@ -36,37 +36,34 @@ displayURL="https://github.com/mlus-asuka/PlayerSync"
|
|||
|
||||
# A file name (in the root of the mod JAR) containing a logo for display
|
||||
logoFile="logo.png" #optional
|
||||
|
||||
# A text field displayed in the mod UI
|
||||
credits="Based on Mysql" #optional
|
||||
|
||||
# A text field displayed in the mod UI
|
||||
authors="${mod_authors}" #optional
|
||||
|
||||
# Display Test controls the display for your mod in the server connection screen
|
||||
# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod.
|
||||
# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod.
|
||||
# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component.
|
||||
# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value.
|
||||
# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself.
|
||||
displayTest="IGNORE_SERVER_VERSION"
|
||||
|
||||
# The description text for the mod (multi line!) (#mandatory)
|
||||
description='''${mod_description}'''
|
||||
|
||||
# The [[mixins]] block allows you to declare your mixin config to FML so that it gets loaded.
|
||||
#[[mixins]]
|
||||
#config="${mod_id}.mixins.json"
|
||||
|
||||
# The [[accessTransformers]] block allows you to declare where your AT file is.
|
||||
# If this block is omitted, a fallback attempt will be made to load an AT from META-INF/accesstransformer.cfg
|
||||
#[[accessTransformers]]
|
||||
#file="META-INF/accesstransformer.cfg"
|
||||
|
||||
# The coremods config file path is not configurable and is always loaded from META-INF/coremods.json
|
||||
|
||||
# A dependency - use the . to indicate dependency for a specific modid. Dependencies are optional.
|
||||
[[dependencies.${mod_id}]] #optional
|
||||
# the modid of the dependency
|
||||
modId="neoforge" #mandatory
|
||||
# The type of the dependency. Can be one of "required", "optional", "incompatible" or "discouraged" (case insensitive).
|
||||
# 'required' requires the mod to exist, 'optional' does not
|
||||
# 'incompatible' will prevent the game from loading when the mod exists, and 'discouraged' will show a warning
|
||||
type="required" #mandatory
|
||||
modId="forge" #mandatory
|
||||
# Does this dependency have to exist - if not, ordering below must be specified
|
||||
mandatory=true #mandatory
|
||||
# Optional field describing why the dependency is required or why it is incompatible
|
||||
# reason="..."
|
||||
# The version range of the dependency
|
||||
versionRange="${neo_version_range}" #mandatory
|
||||
versionRange="${forge_version_range}" #mandatory
|
||||
# An ordering relationship for the dependency.
|
||||
# BEFORE - This mod is loaded BEFORE the dependency
|
||||
# AFTER - This mod is loaded AFTER the dependency
|
||||
|
|
@ -77,7 +74,7 @@ description='''${mod_description}'''
|
|||
# Here's another dependency
|
||||
[[dependencies.${mod_id}]]
|
||||
modId="minecraft"
|
||||
type="required"
|
||||
mandatory=true
|
||||
# This version range declares a minimum of the current minecraft version up to but not including the next major version
|
||||
versionRange="${minecraft_version_range}"
|
||||
ordering="NONE"
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user