9
0
mirror of https://github.com/WiIIiam278/HuskSync.git synced 2025-12-21 15:49:20 +00:00

Compare commits

..

294 Commits
1.4.1 ... 3.0.2

Author SHA1 Message Date
William
c406f40898 Bump to 3.0.2 2023-09-23 23:34:46 +01:00
William
7561762c25 Fix relocation of com.fatboyindustrial lib 2023-09-23 22:27:12 +01:00
William
d245245083 Fix #get call when appling locked map data, Fix #169 2023-09-23 18:45:06 +01:00
William
2b55e129b3 Slightly improve BukkitData.Items#setContents method 2023-09-23 15:15:10 +01:00
William
0caec74436 Improve stat map resilience for modded block types 2023-09-23 14:08:53 +01:00
William
55e443cd49 Improve error handling on data sync 2023-09-22 22:07:31 +01:00
William
b63e1bd283 Fixup adapting health when scaling 2023-09-22 21:48:39 +01:00
William
575122e6dd Tweak max health syncing calculation, add config option 2023-09-22 21:47:05 +01:00
William
856cbb9caa Allow conversion of v1-v3 data snapshots 2023-09-22 21:27:11 +01:00
William
7034a97d3a Fix wrong timestamp/UUID being used for legacy conversion (#167)
* Maintain legacy snapshot IDs when updating

* Also maintain timestamps during conversion

* Actually implement timestamp fix in LegacyConverter
2023-09-22 16:12:08 +01:00
William
635edb930f Fix wrong syntax message on /userdata restore, Close #166 2023-09-22 13:26:31 +01:00
dependabot[bot]
1d3e4b7a20 Bump de.tr7zw:item-nbt-api from 2.12.0-SNAPSHOT to 2.12.0 (#165)
Bumps de.tr7zw:item-nbt-api from 2.12.0-SNAPSHOT to 2.12.0.

---
updated-dependencies:
- dependency-name: de.tr7zw:item-nbt-api
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-22 09:45:40 +01:00
William278
6f9abb63cc docs: warn against joining futures 2023-09-21 20:27:55 +01:00
dependabot[bot]
563b9e146e Bump de.tr7zw:item-nbt-api from 2.12.0-RC1 to 2.12.0-SNAPSHOT (#164)
Bumps de.tr7zw:item-nbt-api from 2.12.0-RC1 to 2.12.0-SNAPSHOT.

---
updated-dependencies:
- dependency-name: de.tr7zw:item-nbt-api
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-09-21 08:30:08 +01:00
Villag3r_
7c9ac37eb7 Update it-it.yml (#163) 2023-09-20 17:11:56 +01:00
William278
5d3de4115f docs: Update links to Data Snapshot API 2023-09-20 14:14:52 +01:00
William
105f65c93a v3.0: New modular, more compatible data format, new API, better UX (#160)
* Start work on v3

* More work on task scheduling

* Add comment to notification display slot

* Synchronise branches

* Use new HuskHomes-style task system

* Bump to 2.3

* Remove HuskSyncInitializationException.java

* Optimise database for MariaDB

* Update libraries, move some around

* Tweak command registration

* Remove dummyhusksync

* Fixup core synchronisation logic to use new task system

* Implement new event dispatch subsystem

* Remove last remaining future calls

* Remove `Event#fire()`

* Refactor startup process

* New command subsystem, more initialization improvements, locale fixes

* Update docs, tweak command perms

* Reduce task number during data setting

* add todo

* Start work on data format / serialization refactor

* More work on Bukkit impl

* More serialization work

* Fixes to serialization, data preview system

* Start legacy conversion skeleton

* Handle setting empty inventories

* Start on-the-fly legacy conversion work

* Add advancement conversion

* Rewrite advancement get / apply logic

* Start work on locked map persistence

* More map persistence work

* More work on map serialization

* Move around persistence logic

* Add testing suite

* Fix item synchronisation

* Finalize more reliable locked map persistence

* Remove deprecated method call

* remove sync feature enum

* Fix held item slot syncing

* Make data types modular and API-extensible

* Remove some excessive debugging, minor refactor

* Fixup date formatting, improve menu UIs

* Finish up legacy data converting

* Null safety in item stack serializaiton

* Fix relocation of nbtapi, update dumping docs

* Add v1/MPDB Migrators back in

* Fix pinning/unpinning data not working

* Consumer instead of Function for editing data

* Show file size in DataSnapshotOverview

* Fix getIdentifier always returning empty

* Re-add items and inventory GUI commands

* Improve config file, fixup data restoration

* Add min time between backups (more useful backups!)

* More work on backups

* Fixup backup rotation frequency

* Remove stdout debug print in `#getEventPriority`

* Improve sync complete locale logic, fix synchronization spelling

* Remove `static` on exception

* Use dedicated thread for Redis, properly unsubscribe

* Refactor `player` package -> `user`

* `PlayerDataHolder` -> `UserDataHolder`

* Make `StatisticsMap` public, but `@ApiStatus.Internal`

* Suppress unused warnings on `Data`

* Add option to disable Plan hook

* Decompress legacy data before converting

* Decompress bytes in fromBytes

* Check permission node before serving TAB suggestions

* Actually convert legacy item stack data

* Fix syntax errors

* Minor method refactor in items command

* Fixup case-sensitive parsing in HuskSync command

* Start API work

* More work on API, fix potion effects

* Fix cross-server, config formatting for auto-pinned issue

* Fix confusion with UserData command, update docs images

* Update commands docs

* More docs updating

* Fix sync feature enabled/disabled checking logic

* Fix `#isCustom()`

* Enable persistent_data syncing by default

* docs: update Sync-Features config snippet

* docs: correct typo in Sync Features

* More API work

* bukkit: slightly optimized schedulers

* More API work, various refactorings

* docs: Start new API docs

* bump dependencies

* Add some basic unit tests

* docs: Correct typos

* More docs work, annotate DB methods as `@Blocking`

* Encapsulate `RedisMessage`, minor optimisations

* api: Simplify `#getCurrentData`

* api: Simplify `editCurrentData`, using `ThrowingConsumers` for better error handling

* docs: More Data Snapshot API documenting

* docs: add TOC to Data Snapshot API page

* bukkit: Make data types extend BukkitData

* Move where custom data is stored, finish up Custom Data API docs

* Optimise imports

* Fix `data_manager_advancements_preview_remaining` locale

* Fix advancement and playtime previews

* Fix potion effect deserialization

* Make snapshot_backup_frequency default to 4, more error handling/logging

* docs: Add ToC to Custom Data API

* docs: Minor legacy API tweaks

* Remove some unneeded catch logic

* Suppress a few warnings

* Fix Effect constructor being supplied in wrong order
2023-09-20 14:02:26 +01:00
William
9018ad02e1 settings: Fix wrong comment 2023-09-09 09:51:00 +03:00
rktfier
9db9a3e721 docs: Improve troubleshooting information for Redis/MySQL on Pterodactyl (#158) 2023-08-29 13:38:33 +01:00
William
9120c062de docs: correct mclo.gs link 2023-08-12 19:57:34 +01:00
William
bd83c8935d Tweak database connection confirmation messages 2023-07-28 21:38:10 +01:00
William
62095364ce 2.2.8: Explicitly specify MariaDB Driver class name 2023-07-28 21:25:23 +01:00
William
304df9984c [ci skip] Bump to 2.2.7 2023-07-28 19:44:09 +01:00
William
b73de81519 Add missing Maria schema 2023-07-28 19:41:42 +01:00
William
12e882fe22 v2.2.6: Crafting inventory safety, Maria v11 support (#153)
* Clear player inventory crafting slots on sync

* Bundle Maria driver for v11 support
2023-07-28 16:50:52 +01:00
William
4ed8b94d55 docs: explicitly state compatibility with custom item plugins 2023-07-28 13:58:12 +01:00
dependabot[bot]
854bf37186 Bump org.junit.jupiter:junit-jupiter-api from 5.9.3 to 5.10.0 (#149)
Bumps [org.junit.jupiter:junit-jupiter-api](https://github.com/junit-team/junit5) from 5.9.3 to 5.10.0.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.9.3...r5.10.0)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-api
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-24 19:38:08 +01:00
dependabot[bot]
8bab2a8123 Bump org.junit.jupiter:junit-jupiter-engine from 5.9.3 to 5.10.0 (#150)
Bumps [org.junit.jupiter:junit-jupiter-engine](https://github.com/junit-team/junit5) from 5.9.3 to 5.10.0.
- [Release notes](https://github.com/junit-team/junit5/releases)
- [Commits](https://github.com/junit-team/junit5/compare/r5.9.3...r5.10.0)

---
updated-dependencies:
- dependency-name: org.junit.jupiter:junit-jupiter-engine
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-07-24 19:37:29 +01:00
Joo200
97ad608d56 Add mariadb protocol option type (#145)
Co-authored-by: William <will27528@gmail.com>
2023-07-01 13:54:30 +01:00
William
f7419f7277 [ci skip] Update README footer 2023-07-01 13:43:54 +01:00
William
f0497f61f0 [ci skip] Update README headings 2023-07-01 13:43:06 +01:00
William
f6aab54d4d license: Relicense under Apache-2.0 2023-07-01 13:39:48 +01:00
kFor
c306d700ce Option to blacklist all commands (#138)
* Option to blacklist all commands

* blacklist all commands by default

---------

Co-authored-by: William <will27528@gmail.com>
2023-06-22 11:25:17 +01:00
Rafael Romão
bbcb091daf Fix locked map data saving (#140) 2023-06-21 10:45:17 +01:00
William
eb9e2491e5 docs: Fix songoda links 2023-06-11 13:13:23 +01:00
William
0250ad80c8 docs: Use new repo 2023-06-11 13:08:23 +01:00
William
516b163e07 use AndJam 1.0.2 for now. 2023-06-09 10:22:10 +01:00
William
c123b15708 Bump AndJam to 1.0.3 2023-06-09 10:19:42 +01:00
William
29f8d8bf50 Bump dependencies 2023-06-09 10:12:21 +01:00
dependabot[bot]
4f65cf49ef Bump commons-io:commons-io from 2.11.0 to 2.12.0 (#134)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William <will27528@gmail.com>
2023-05-28 10:28:38 +01:00
William
27f9e24dfb [ci skip] Fix missing closing perenthesis 2023-05-17 17:01:35 +01:00
William
2cbe39f158 [ci skip] Update songoda link to craftaro 2023-05-17 17:01:09 +01:00
William
5840f61571 Fix tests 2023-05-07 23:17:23 +01:00
William
6bd12dc000 Update DesertWell to 2.0.2, improve adventure API usage 2023-05-07 23:15:50 +01:00
William
1afbd3753a Merge remote-tracking branch 'origin/master'
# Conflicts:
#	.github/workflows/java_ci.yml
#	build.gradle
2023-05-07 23:02:05 +01:00
William
38a063420b Add license headers 2023-05-07 23:00:12 +01:00
William
0fae3484a1 Add workflow files, test reporting, maven publishing, docs, bump version 2023-05-07 22:59:19 +01:00
William
7bb4bff485 Make case conversion operations use the English locale 2023-05-07 22:50:37 +01:00
William
c5f5cd702e Update gradle-build-action 2023-05-01 14:58:10 +01:00
dependabot[bot]
3ced2cc0af Bump org.junit.jupiter:junit-jupiter-api from 5.9.2 to 5.9.3 (#128)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-01 14:56:48 +01:00
dependabot[bot]
73e1840574 Bump org.junit.jupiter:junit-jupiter-engine from 5.9.2 to 5.9.3 (#129)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William <will27528@gmail.com>
2023-05-01 13:50:40 +01:00
dependabot[bot]
d1ca56af1f Bump dev.triumphteam:triumph-gui from 3.1.4 to 3.1.5 (#130)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-05-01 13:50:25 +01:00
dependabot[bot]
55211e30c5 Bump org.ajoberstar.grgit from 5.0.0 to 5.2.0 (#126)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William <will27528@gmail.com>
2023-04-26 00:25:20 +01:00
evlad
2c1a38f2c9 Disable all projectiles while player is in sync (#127) 2023-04-26 00:21:04 +01:00
小蔡
0c7e052d44 Update zh-tw.yml (#124) 2023-04-20 22:20:24 +01:00
William
54cc11fce0 [ci skip] Update README.md 2023-04-17 11:21:40 +01:00
William
3645fa01ec [ci skip] Wrap README header in header tag 2023-04-17 11:11:38 +01:00
dependabot[bot]
e20a0da845 Bump net.kyori:adventure-api from 4.13.0 to 4.13.1 (#120)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-15 18:02:22 +01:00
William
7ed7d0a29e Add additional cast checking to PDC tag fetching, cast via complex type (#118) 2023-04-05 12:53:05 +01:00
William278
e7e7995e4e Bump dependencies 2023-04-05 12:03:13 +01:00
William278
af57cfcf70 Fix log method not logging throwables 2023-03-28 15:37:04 +01:00
William
f1ac9b5e04 [ci skip] Update README 2023-03-13 13:44:25 +00:00
William
a9b1070725 Encapsulate Settings, tweak keys, add empty dead player save option, close #73 2023-03-10 21:17:15 +00:00
William
5a000add98 Fix deadlock on busy servers with small thread pools, close #100 2023-03-10 21:00:15 +00:00
dependabot[bot]
aec2836d1e Bump com.github.plan-player-analytics:Plan from 5.5.2254 to 5.5.2272 (#108)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William <will27528@gmail.com>
2023-03-10 20:56:14 +00:00
William
84e2ea3904 gradle: Update to Gradle 8.0.2, bump shadow to 8.1.0 2023-03-10 20:54:57 +00:00
dependabot[bot]
4f669170c2 Bump org.jetbrains:annotations from 24.0.0 to 24.0.1 (#107)
Bumps [org.jetbrains:annotations](https://github.com/JetBrains/java-annotations) from 24.0.0 to 24.0.1.
- [Release notes](https://github.com/JetBrains/java-annotations/releases)
- [Changelog](https://github.com/JetBrains/java-annotations/blob/master/CHANGELOG.md)
- [Commits](https://github.com/JetBrains/java-annotations/commits)

---
updated-dependencies:
- dependency-name: org.jetbrains:annotations
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-05 12:45:59 +00:00
dependabot[bot]
8ea8c7b7ba Bump com.github.plan-player-analytics:Plan from 5.5.2208 to 5.5.2254 (#104)
Bumps [com.github.plan-player-analytics:Plan](https://github.com/plan-player-analytics/Plan) from 5.5.2208 to 5.5.2254.
- [Release notes](https://github.com/plan-player-analytics/Plan/releases)
- [Commits](https://github.com/plan-player-analytics/Plan/compare/5.5.2208...5.5.2254)

---
updated-dependencies:
- dependency-name: com.github.plan-player-analytics:Plan
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William <will27528@gmail.com>
2023-02-28 02:50:03 +00:00
dependabot[bot]
acab4ae58a Bump net.william278:DesertWell from 1.1 to 1.1.1 (#102)
Bumps [net.william278:DesertWell](https://github.com/WiIIiam278/DesertWell) from 1.1 to 1.1.1.
- [Release notes](https://github.com/WiIIiam278/DesertWell/releases)
- [Commits](https://github.com/WiIIiam278/DesertWell/compare/1.1...1.1.1)

---
updated-dependencies:
- dependency-name: net.william278:DesertWell
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-28 02:49:35 +00:00
Ceddix
ea822b0f4b fixed some issues I made (#101) 2023-02-23 02:26:52 +00:00
dependabot[bot]
24a9974ff7 Bump com.github.plan-player-analytics:Plan from 5.4.1690 to 5.5.2208 (#99)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William <will27528@gmail.com>
2023-02-22 12:59:51 +00:00
dependabot[bot]
222a9871e0 Bump dev.dejvokep:boosted-yaml from 1.3 to 1.3.1 (#98)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: William <will27528@gmail.com>
2023-02-22 12:59:37 +00:00
dependabot[bot]
0ce9d2ce74 Bump dev.triumphteam:triumph-gui from 3.1.3 to 3.1.4 (#97)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-02-22 12:58:27 +00:00
William278
cde500a123 Bump to 2.2.4 2023-02-18 13:30:36 +00:00
William278
f15790030f Update dependencies 2023-02-18 13:29:12 +00:00
William278
ce3350c6fa Fix shutdown crash with PDC (rollback switch to using task) 2023-02-18 13:24:49 +00:00
William
4288742052 Update dependencies, close #85, close #86, close #87, close #88, close #89 2023-02-13 13:29:52 +00:00
William
8205b9c169 Create dependabot.yml and funding.yml 2023-02-13 13:20:27 +00:00
William
1d7f6a8d8b refactor: Use mappings class for PDC tag type handling 2023-02-12 19:14:46 +00:00
William
3425c97245 Bump to v2.2.3 2023-02-12 18:18:29 +00:00
evlad
2d1d8f1ab6 Sync lock: Cancel item frame interaction, add command blacklist (#84)
Co-authored-by: William <will27528@gmail.com>
2023-02-12 18:08:46 +00:00
William
f322d31b03 [ci skip] Correct typos in README 2023-02-12 15:43:26 +00:00
William278
368e665ac3 [ci skip] Update README 2023-01-27 11:46:31 +00:00
William278
922eb2f19a Merge remote-tracking branch 'origin/master'
# Conflicts:
#	README.md
2023-01-27 11:45:50 +00:00
William278
23e0123004 Update README and banner 2023-01-27 11:45:04 +00:00
William
e98bac844a Hotfix bump to 2.2.2 - fix unchecked cast on trident lock check 2023-01-10 14:42:54 +00:00
William
d6d9a55f72 Fix unchecked cast on trident launch locking, fix #79 2023-01-10 14:42:28 +00:00
William
e3070a65ab Ensure player isn't locked before force-dropping items 2023-01-07 22:17:42 +00:00
William
a8b4696604 Minor style tweaks 2023-01-07 22:14:06 +00:00
William
7f5ca6206b Merge pull request #76 from ItsWagPvP/patch-1 2023-01-07 22:11:18 +00:00
William
ad885a9a15 Bump library versions, fix test dependencies 2023-01-07 22:11:05 +00:00
William
fe89e7b770 Fix tests 2023-01-07 21:31:14 +00:00
William
17ea62ed0b Merge pull request #78 from emmanuelvlad/master 2023-01-07 21:11:01 +00:00
evlad
94717637ba fix dupe with trident when player is locked 2023-01-07 22:06:47 +01:00
William
f6663f0c09 Refactor; consolidate Logger and ResourceReader, simplify certain method arguments 2023-01-05 19:06:04 +00:00
William
33588c2345 Bump to v2.2.1 2023-01-05 18:35:34 +00:00
William
c2c5a424fb Add checks against the user being an NPC 2023-01-05 18:35:10 +00:00
Gabriele Cabrini
ce41053e87 Fixed issue #74
Added the code that @alexdev03 suggested
2023-01-03 21:06:26 +01:00
William
5817de83e5 Fix locked map data not being applied in some cases 2023-01-03 12:38:17 +00:00
William
30dd48ce88 Add additional checks and error handling when setting health 2023-01-03 12:14:18 +00:00
William
cf7912a89e Bump to 2.2 2023-01-03 11:58:06 +00:00
William
9900b44858 Disable locked map syncing by default 2023-01-03 11:56:47 +00:00
William
9019181208 Merge remote-tracking branch 'origin/master' 2023-01-03 11:51:29 +00:00
William
99483387f1 Add additional error handling for player health and statistic updating 2023-01-03 11:51:25 +00:00
William
42177f2582 Merge pull request #75 from emmanuelvlad/expose-locked-players 2023-01-01 16:19:52 +00:00
William
e4e0743205 [ci skip] Update license year (2023) 2023-01-01 16:18:58 +00:00
evlad
105927a57f added dummy method 2022-12-31 04:41:20 +03:00
evlad
71706bf9ae expose locked players + add a method on OnlineUser 2022-12-31 04:30:45 +03:00
William
101e0c11d7 Fix unsynced players having data saved on world save / death 2022-12-27 19:31:14 +00:00
William
70323fb2e2 Merge remote-tracking branch 'origin/master' 2022-12-26 16:42:48 +00:00
William
9dc5577175 Clear the player's cursor when setting inventory contents 2022-12-26 16:06:22 +00:00
William
117d5edea2 [ci skip] Update badge 2022-12-19 16:04:35 +00:00
William
3f0f518037 [ci-skip] Clarify minimum Java version in README 2022-11-20 18:43:04 +00:00
William
2017ecc20f Minor refactoring / code improvements 2022-11-18 14:40:15 +00:00
William
ded89ad343 ACTION_BAR as default notification slot 2022-11-18 11:25:00 +00:00
William
c4b194f8d6 Fix unlocked maps getting wrongly locked 2022-11-17 02:46:40 +00:00
William
d682e6e6c6 Fix notifications through Toast AndJam library 2022-11-17 02:34:52 +00:00
William
6fef9c4eae Remove map itemstack debug logging mess 2022-11-16 22:40:23 +00:00
William278
16eee05065 Add notification slot configuration, support for toasts 2022-11-16 22:31:57 +00:00
William278
b664e2586d Bump gson to 2.10 2022-11-16 22:06:26 +00:00
William278
d594c9c257 Truncate long data save cause names, close #60 2022-11-16 14:44:32 +00:00
William278
532a65eca8 Ensure players remain locked on disconnect and shutdown, close #67 2022-11-16 12:06:01 +00:00
William
5af8ae0da5 Use canvas rendering approach, finish locked map synchronisation 2022-11-15 23:37:41 +00:00
William278
c0709f82bd Add the ability to synchronise/persist locked maps cross-server, close #14 2022-11-15 18:29:37 +00:00
William278
945b65e1bc Minor performance improvement to event cancelling, add checks against inventory clicks 2022-11-15 17:46:55 +00:00
William278
efcb36d345 Fix database username at wrong config path 2022-11-15 11:37:15 +00:00
William
30cd89c578 Remove unnecessary debug 2022-11-15 00:43:52 +00:00
William
bb3753b8e4 Fix typo 2022-11-15 00:42:34 +00:00
William
d5569ad3ed Fix event priorities in config, bump to 2.1.3 2022-11-15 00:36:45 +00:00
William
d8386fd2a2 Fix edit nodes not being respected 2022-11-14 18:25:44 +00:00
William
3bfea58f35 Make event priority configurable for three key events 2022-11-14 11:47:33 +00:00
William
51cf7beeb8 Remove redundant compiler warning suppressors 2022-11-14 11:04:41 +00:00
William
df247b41f4 Bump to v2.1.2 2022-11-06 22:32:46 +00:00
William
bac760165e Tweak logic for determining if a player is dead, fix issues with <1HP players being detected dead 2022-11-06 22:31:33 +00:00
William
dd39482ed1 Tweaked bukkit implementation of #isDead detection 2022-10-28 13:00:08 +01:00
William
c05f165278 Bump to v2.1.1 2022-10-26 15:21:06 +01:00
William
c888759d33 Merge remote-tracking branch 'origin/master' 2022-10-26 15:20:21 +01:00
William
089ea5b63a Fix unsafe joins on inventory and ender chest commands, better exception catching, Close #58 2022-10-26 15:20:08 +01:00
William
9020e9d906 Merge pull request #57 from iVillager/patch-1
Update it-it.yml
2022-10-19 19:41:27 +01:00
Villag3r_
7584ea0070 Update it-it.yml 2022-10-19 19:46:25 +02:00
William
9c243c2893 Merge pull request #56 from Ceddix/master
Updated German locales
2022-10-19 15:27:08 +01:00
Ceddix
8ba90fadc4 Updated german locales 2022-10-19 15:39:09 +02:00
William
480796fbee Remove config.yml 2022-10-13 20:30:19 +01:00
William
9b186ec97a [ci skip] Add data-dumping.png 2022-10-12 21:47:55 +01:00
William
d828631dea Merge branch 'master' into triumph-gui 2022-10-12 21:34:15 +01:00
William
5b8de7967b Fix it not being possible to creative-middle-click-clone item stacks from un-editable inventory snapshot views, close #44 2022-10-12 21:34:09 +01:00
William
4fddbc2b32 Automatically expand menus to accommodate custom inventory/echest sizes, close #45 2022-10-12 21:16:11 +01:00
William
43cd367ca3 TriumphGUI for menus, fix missing inv/echest view message, fix data saving despite no updates, close #42 2022-10-12 21:02:57 +01:00
William
19ca504bab Suggest delete and restore commands rather than run them 2022-10-12 16:37:56 +01:00
William
394b8ff1d1 Disable dump commands for operators by default 2022-10-12 15:43:39 +01:00
William
4577da3336 Restore users with at least one health point 2022-10-12 15:37:26 +01:00
William
c3b339b3dd Minor cleanup, tweak listener method names 2022-10-12 15:13:19 +01:00
William
2b91154ca2 Ensure players have their inventory contents saved on death 2022-10-12 01:45:46 +01:00
William
2351be31e3 Merge branch 'annotaml-config' into save-on-death 2022-10-11 21:15:50 +01:00
William
624543b93d More test fixes 2022-10-11 21:15:43 +01:00
William
c13f4b2a05 Merge branch 'annotaml-config' into save-on-death 2022-10-11 21:01:02 +01:00
William
00b8d335d8 Fix tests 2022-10-11 21:00:54 +01:00
William
89d8b79ae3 Add ability to save user data on player death 2022-10-11 20:58:19 +01:00
William
acd97a1cb0 Migrate config to Annotaml 2022-10-11 20:42:15 +01:00
William
8c0f7a295f [ci skip] tweak comment 2022-10-11 19:05:57 +01:00
William
7536bfaaf5 Add /userdata dump command, for file/web dumping of user data json 2022-10-07 22:52:58 +01:00
William
cbf5d9c24e Implement variable-sized user data; only save needed data 2022-10-07 17:02:26 +01:00
William
b9e474d946 Variable-size data format support: Deprecate getXData() methods in UserData in favour of optional returns 2022-10-07 16:04:56 +01:00
William
3d232f97fb Shade adventure instead of bundling at runtime 2022-10-07 14:31:08 +01:00
William
6d649d0889 Extra logging for DataSerialization exceptions 2022-10-07 14:20:48 +01:00
William
0754837820 Start work on data list pagination via PagineDown, update locales 2022-09-22 23:00:24 +01:00
William
8f44dbb296 Import cleanup 2022-09-22 15:00:38 +01:00
William
049cd8ecca Migrate plugin to MineDown/Kyori-adventure 2022-09-22 15:00:22 +01:00
William
2ed7705903 Merge remote-tracking branch 'origin/master' 2022-09-22 14:49:45 +01:00
William
6bc6749e38 Merge API module into bukkit for simplicity 2022-09-22 14:48:36 +01:00
William278
97a02b7a05 [ci skip] Tweak docs URL 2022-09-10 20:01:41 +03:00
William278
abc41a0aca [ci skip] Add screenshot to README 2022-09-10 20:01:00 +03:00
William278
31a14b2de7 [ci skip] Update README with new badges 2022-09-10 19:58:53 +03:00
William278
59a0002c16 Migrate to DesertWell for about menu, version checking 2022-09-10 19:52:34 +03:00
William278
61020e04d9 Fix typo in default command description 2022-09-10 19:40:53 +03:00
William278
ff1ace8342 Update version format 2022-09-10 19:27:26 +03:00
William278
847790c514 Remove api module, include it in with the bukkit module for simplicity 2022-09-10 19:25:07 +03:00
William
390a77b407 Update README.md (remove mariadb comment) 2022-08-21 13:30:37 +01:00
William
04ab9d14f8 Update rich-syntax-highlighting.png 2022-08-21 13:22:23 +01:00
William
3a32d481c4 Add rich-syntax-highlighting.png 2022-08-21 13:20:04 +01:00
William
8edbc029f8 Remove test commodore completions 2022-08-21 13:15:48 +01:00
William
258356e45d Bundle boosted-yaml, adjust shading and build scripts 2022-08-21 13:09:30 +01:00
William
e1628b6448 Download jedis at runtime 2022-08-21 12:51:58 +01:00
William
fa32e97564 Bump mysql-connector-java to 8.0.30 2022-08-21 12:33:47 +01:00
William
8080d57645 Update MPDB migrator messages 2022-08-21 12:22:33 +01:00
William
0a2f7b6cd4 Fix migration reporting for MPDB migrator too 2022-08-21 12:21:56 +01:00
William
26a2366876 Fix inaccurate migration progress tracking 2022-08-21 12:20:19 +01:00
William
2690ab3144 Make initialization exception more obvious, close #47 2022-08-21 11:54:51 +01:00
William
18b96944e9 Tweak migrator to ensure safe user setting on large data imports 2022-08-21 11:49:38 +01:00
William
3282f5739c Fix nested futures on #ensureUser causing ineffective #join synchronisation calls, close #40 2022-08-21 11:49:15 +01:00
William
50e66be0c0 Relocate DummySettings 2022-08-08 19:49:10 +01:00
William
593c88c8ba Fix tests, create DummySettings 2022-08-08 19:44:12 +01:00
William
2f700b2d93 Use Commodore for rich command completion registering 2022-08-08 19:32:09 +01:00
William
d1c95030f0 Added config option for syncing dead player inventories, cancel damage if locked 2022-08-04 17:48:16 +01:00
William
1ed2414241 Add Bulgarian (bg-bg) courtesy of Pukejoy_1 2022-08-04 13:31:28 +01:00
William
8847483ff8 Correct Persistent Data serialization 2022-07-21 14:23:27 +01:00
William
31552f85e4 Fix malformed events 2022-07-19 14:18:41 +01:00
William
125f142cf5 Fix tests 2022-07-19 11:28:26 +01:00
William
dc3882e47e Merge remote-tracking branch 'origin/master' 2022-07-19 11:27:00 +01:00
William
dafbcad10e Fix PersistentDataContainer synchronisation, bump to v2.0.2 2022-07-19 11:26:56 +01:00
William
d1085ca7bd Update README.md 2022-07-16 17:51:53 +01:00
William
4663842946 Prepare v2.0.1 release 2022-07-14 21:33:57 +01:00
William
e4262abfd7 Prevent synchronisation of newer user data formats, tweak error messages 2022-07-14 10:30:10 +01:00
William
fc6a760848 usersAwaitingSync -> lockedPlayers, also lock while quitting 2022-07-14 10:06:31 +01:00
William
e03a580870 Tweak exception wording 2022-07-14 10:06:04 +01:00
William
112e5fe0bd longblob rather than mediumblob 2022-07-13 16:39:12 +01:00
William
ae4f005a9c Additional exception handling to player quit event, stop edge-case asynchronous execution 2022-07-13 15:16:39 +01:00
William
d1432ebb31 Hotfix: Fix IllegalArgumentException when attempting to set non-primitive PersistentDataContainer key types. 2022-07-13 14:21:15 +01:00
William
460cb54a7d Merge branch 'master' into zh-tw 2022-07-13 12:24:21 +01:00
William
ebf5b77f00 Add credit to HookWoods 2022-07-13 12:22:56 +01:00
William
33904d82d0 Merge branch 'master' into hotfix/death-sync-event 2022-07-13 12:21:42 +01:00
William
10b3eb5a43 Properly synchronise player death states 2022-07-13 12:21:38 +01:00
小蔡
7ae0709895 Update zh-tw.yml 2022-07-13 19:19:44 +08:00
William
9d6da91a5e Bump up to 2.1 2022-07-13 11:48:09 +01:00
Hugo Planque
268c351a95 Clear inventory on death if player still not synced to avoid dupes 2022-07-13 12:42:38 +02:00
William
8760fcea1f Fix tests 2022-07-13 11:15:43 +01:00
William
60a3bba165 Additional error handling 2022-07-13 10:48:34 +01:00
William
082b3e6c42 Tweak debug logging when reading Redis keys 2022-07-13 10:26:07 +01:00
Hugo Planque
221baa7b04 switch variable to final 2022-07-13 09:52:31 +02:00
Hugo Planque
8b7b32906e hotfix(Bukkit): fix death sync event when setting health of 0 2022-07-13 01:11:38 +02:00
William
261b9cc00c MPDBConverter over jitpack 2022-07-12 23:04:26 +01:00
William
654e1f0855 Jitpack: find and move output artifacts 2022-07-12 22:35:19 +01:00
William
fe14b4db35 Add it-it courtesy of xF3d3 and es-es courtesy of Melonzio 2022-07-12 22:22:26 +01:00
William
c94ed4926f Jitpack: mkdir ~/.m2/repository/` 2022-07-12 22:00:50 +01:00
William
b0a37ddb04 Jitpack: Force read the generated jars 2022-07-12 21:57:44 +01:00
William
e78e61b084 Jitpack: Force read the generated jars 2022-07-12 21:54:38 +01:00
William
f849336435 Jitpack: rm ~/.m2` 2022-07-12 21:09:37 +01:00
William
f51e05061b Jitpack: Ignore mpdbdataconverter 2022-07-12 19:29:46 +01:00
William
b0b39e684c Jitpack: Publish API docs and sources 2022-07-12 19:23:33 +01:00
William
66af3065e3 Update jitpack.yml 2022-07-12 18:53:47 +01:00
William
12bac4011c Include javadocs and sources in build 2022-07-12 18:35:51 +01:00
William
02c64a54c2 Correct LICENSE link 2022-07-12 12:42:46 +01:00
William
9534a8ed0c Escape bracket terminator in javadoc comment 2022-07-11 23:54:22 +01:00
William
8552598c6e Jitpack - api:build 2022-07-11 22:51:20 +01:00
William
41399b39b1 CI - test, not build test 2022-07-11 20:47:06 +01:00
William
17086e51a9 Add data-snapshot-viewer.png 2022-07-11 20:25:48 +01:00
William
4e479029a3 Update data-snapshot-list.png 2022-07-11 20:20:30 +01:00
William
51faa6acb2 Add data-snapshot-list.png 2022-07-11 20:18:10 +01:00
William
60a435aa82 Rename in config max_user_data_records --> max_user_data_snapshots 2022-07-11 20:11:25 +01:00
William
e34fa07eb9 Add system diagram 2022-07-11 19:43:42 +01:00
William
0520cc6ad0 Merge branch 'master' into futuriumnetwork_master 2022-07-11 19:28:55 +01:00
William
c931910fc0 Add credit for mateusneresrb (pt-br) 2022-07-11 19:28:51 +01:00
mateusneresrb
2bee9561d7 Implement translation portuguese brazil 2022-07-11 15:12:03 -03:00
William
feb6280fd2 Re-arrange permissions 2022-07-11 18:58:14 +01:00
William
8f396273c7 Respect userdata.manage permission node 2022-07-11 18:57:43 +01:00
William
5d584581f0 Add pin to usage 2022-07-11 18:51:19 +01:00
William
5fe9085483 Re-order commands 2022-07-11 18:50:06 +01:00
William
c2e0c605f8 Merge remote-tracking branch 'origin/master' 2022-07-11 18:47:40 +01:00
William
038150cff7 Update default usages 2022-07-11 18:47:36 +01:00
William
e19a82ef82 Update README.md 2022-07-11 18:21:15 +01:00
William
2aba652793 Plan hook image 2022-07-11 18:09:36 +01:00
William
12d69e96de Plan hook image 2022-07-11 18:08:20 +01:00
William
143bbfc4f8 Adjust graphic height 2022-07-11 16:51:03 +01:00
William
5ce68458aa Remove graphic border 2022-07-11 16:49:55 +01:00
William
4494f4ee27 New banner graphic 2022-07-11 16:48:39 +01:00
William
e550ad9156 Make script executable 2022-07-11 16:09:05 +01:00
William
68ed7248a1 Update java_ci.yml 2022-07-11 16:04:47 +01:00
William
2254c36bb4 Fix gradlew permissions, remove old graphic 2022-07-11 15:57:30 +01:00
William
b5789c04ec Add CI action workflow 2022-07-11 15:53:00 +01:00
William
b35408c429 Re-implement bStats Metrics 2022-07-11 15:48:48 +01:00
William
ac3f179321 Update zh-cn.yml courtesy of DJelly4K 2022-07-11 15:43:17 +01:00
William
3323418b74 Update README.md 2022-07-11 15:40:13 +01:00
William
22b7648b77 General cleanup and optimisations 2022-07-11 15:00:22 +01:00
William
b5f447b20a Visual tweaks to Plan extension 2022-07-11 14:52:36 +01:00
William
086c235323 Comment cleanup 2022-07-11 14:03:20 +01:00
William
fbf9f7f2b1 Listener cleanup 2022-07-11 14:02:55 +01:00
William
28c4cfb55f Cancel inventory edits when editing is disabled, tweak listener logic for better compatibility 2022-07-11 13:58:03 +01:00
William
b0363d10ed Fix MPDB migrator 2022-07-11 13:49:33 +01:00
William
723c79b3a9 Improve initialization logic 2022-07-11 13:18:36 +01:00
William
ff1c8cddb5 Cleanup user logic 2022-07-11 12:48:41 +01:00
William
54553069bf Fix ender chest command too 2022-07-11 12:44:49 +01:00
William
bc9d31abc8 Fix inventory and ender chest commands 2022-07-11 12:32:51 +01:00
William
e5e848126a Update de-de courtesy of Ceddix 2022-07-11 10:54:01 +01:00
William
b0e0b9c435 Minor legacy migrator fix, about menu link and text tweaks, minor version checker fixes, fix TAB completion in console 2022-07-11 01:15:16 +01:00
William
6bfbeec74d Finalize locales 2022-07-10 21:06:31 +01:00
William
745c420fed Flesh out Plan extension 2022-07-10 20:59:36 +01:00
William
e5422d66f0 Remove leftover debug logs from startup 2022-07-10 18:16:07 +01:00
William
2e7ed6d9f5 User data pinning, version validation checks, fixes 2022-07-10 18:12:01 +01:00
William
d1e9f858fe Start work on Plan integration 2022-07-09 18:10:21 +01:00
William
0fce3c44ab Add legacy migrator 2022-07-09 16:52:38 +01:00
William
3d29d45d8a Merge branch 'master' into 2.0-dev
# Conflicts:
#	bukkit/src/main/java/net/william278/husksync/bukkit/util/PlayerSetter.java
#	common/src/main/java/net/william278/husksync/proxy/data/DataManager.java
#	gradle.properties
2022-07-09 00:40:06 +01:00
William
725cc2b24b Send migrator help menu if executed with no sub arguments 2022-07-09 00:34:46 +01:00
William
94e7fe61cc Fix getAvailableMigrators returning null 2022-07-09 00:31:23 +01:00
William
96c6a878c4 Shrink built jar file size, work on MySQLPlayerDataBridge migrator 2022-07-09 00:29:39 +01:00
William
f650db4438 Implement data rollbacks and deletion 2022-07-08 16:05:12 +01:00
William
b7709f2d6c Userdata command, API expansion, editor interfaces 2022-07-08 01:18:01 +01:00
William
1c9d74f925 Events & API work, save DataSaveCauses as part of versioning 2022-07-07 01:52:19 +01:00
William
fd08a3e7d0 Echest, invsee 2022-07-05 15:24:26 +01:00
William
1829526aa7 Implement compression via DataAdapter, add option to disable compression, better exception handling 2022-07-04 23:44:45 +01:00
William
f2d4bec138 Version data via the database implementation 2022-07-04 14:53:26 +01:00
William
948887c90f (MySQL) - fix uuid generation 2022-07-04 13:16:10 +01:00
William
d78dd42b72 Fix data sync when changing servers, consume keys when retrieved 2022-07-04 00:23:31 +01:00
William
38c261871a Basic bukkit implementation 2022-07-03 18:14:44 +01:00
William
9471e0cbff Start 2.0 rewrite
Use redis key caching, remove need for proxy plugin
Make platform independent to allow porting to other platforms
2022-07-02 00:17:51 +01:00
209 changed files with 15727 additions and 7210 deletions

7
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,7 @@
# Dependabot configuration file for GitHub
version: 2
updates:
- package-ecosystem: "gradle" # See documentation for possible values
directory: "/" # Location of package manifests
schedule:
interval: "daily"

3
.github/funding.yml vendored Normal file
View File

@@ -0,0 +1,3 @@
# Funding metadata for GitHub
github: WiIIiam278
custom: https://buymeacoff.ee/william278

37
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,37 @@
# Builds, tests the project with Gradle
name: CI Tests
on:
push:
branches: [ 'master' ]
paths-ignore:
- 'docs/**'
- 'workflows/**'
- 'README.md'
permissions:
contents: read
checks: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle
uses: gradle/gradle-build-action@v2
with:
arguments: build test publish
env:
SNAPSHOTS_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
SNAPSHOTS_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Publish Test Report
uses: mikepenz/action-junit-report@v3
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'

24
.github/workflows/pr_tests.yml vendored Normal file
View File

@@ -0,0 +1,24 @@
# Carry out tests on pull requests
name: PR Tests
on:
pull_request:
branches: [ 'master' ]
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Test Pull Request
uses: gradle/gradle-build-action@v2
with:
arguments: test

33
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,33 @@
# Builds, tests and publishes to maven when a release is published
name: Release Tests
on:
release:
types: [ published ]
permissions:
contents: read
checks: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Set up JDK 17
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Build with Gradle
uses: gradle/gradle-build-action@v2
with:
arguments: build test publish
env:
RELEASES_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
RELEASES_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
- name: Publish Test Report
uses: mikepenz/action-junit-report@v3
if: success() || failure() # always run even if the previous step fails
with:
report_paths: '**/build/test-results/test/TEST-*.xml'

28
.github/workflows/update_docs.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
# Update the GitHub Wiki documentation when a push is made to docs/
name: Update Docs
on:
push:
branches: [ 'master' ]
paths:
- 'docs/**'
- 'workflows/**'
tags-ignore:
- '*'
permissions:
contents: write
jobs:
deploy-wiki:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Code'
uses: actions/checkout@v3
- name: 'Push Changes to Wiki'
uses: Andrew-Chen-Wang/github-wiki-action@v3
env:
WIKI_DIR: 'docs/'
GH_TOKEN: ${{ github.token }}
GH_MAIL: 'actions@github.com'
GH_NAME: 'github-actions[bot]'

5
.gitignore vendored
View File

@@ -118,3 +118,8 @@ run/
!gradle-wrapper.jar
/build-output-final/
/target/
# Don't include generated test suite files
/test/servers/
/test/HuskSync
/test/config.yml

View File

@@ -1,5 +0,0 @@
#!/bin/bash
echo "mvn-installing mpdbdataconverter..."
curl "-L" "-O" "https://github.com/WiIIiam278/MPDBDataConverter/releases/download/1.0/mpdbdataconverter-1.0.jar"
mvn "install:install-file" "-Dfile=mpdbdataconverter-1.0.jar" "-DgroupId=net.william278" "-DartifactId=mpdbdataconverter" "-Dversion=1.0" "-Dpackaging=jar" "-DgeneratePom=true" "-e"

16
HEADER Normal file
View File

@@ -0,0 +1,16 @@
This file is part of HuskSync, licensed under the Apache License 2.0.
Copyright (c) William278 <will27528@gmail.com>
Copyright (c) contributors
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

217
LICENSE
View File

@@ -1,21 +1,202 @@
Copyright © William278 2022. All rights reserved
LICENSE
This source code is provided as reference to licensed individuals that have purchased the HuskSync
plugin once from any of the official sources it is provided. The availability of this code does
not grant you the rights to modify, re-distribute, compile or redistribute this source code or
"plugin" outside this intended purpose. This license does not cover libraries developed by third
parties that are utilised in the plugin.
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
CONTRIBUTOR AGREEMENT
By contributing code to this repository, contributors agree that they forfeit their contributions
to the copyright holder and only the copyright holder.
In exchange for contributing, the copyright holder may give, at their discretion, permission to use
the plugin in commercial contexts
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
DEFINITIONS
"plugin"; the jar file compiled from this source code
"source code"; the java source code and gradle configurations provided in this repository, however
excludes libraries
"copyright holder"; William278
"contributor(s)"; person(s) who submit (contribute) code through a pull request to this repository
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to saveCause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must saveCause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

288
README.md
View File

@@ -1,229 +1,85 @@
[![HuskSync Banner](images/banner-graphic.png)](https://github.com/WiIIiam278/HuskSync)
# HuskSync
[![Discord](https://img.shields.io/discord/818135932103557162?color=7289da&logo=discord)](https://discord.gg/tVYhJfyDWG)
<!--suppress ALL -->
<p align="center">
<img src="images/banner.png" alt="HuskSync" />
<a href="https://github.com/WiIIiam278/HuskSync/actions/workflows/ci.yml">
<img src="https://img.shields.io/github/actions/workflow/status/WiIIiam278/HuskSync/ci.yml?branch=master&logo=github"/>
</a>
<a href="https://jitpack.io/#net.william278/HuskSync">
<img src="https://img.shields.io/jitpack/version/net.william278/HuskSync?color=%2300fb9a&label=api&logo=gradle" />
</a>
<a href="https://discord.gg/tVYhJfyDWG">
<img src="https://img.shields.io/discord/818135932103557162.svg?label=&logo=discord&logoColor=fff&color=7389D8&labelColor=6A7EC2" />
</a>
<br/>
<b>
<a href="https://www.spigotmc.org/resources/husksync.97144/">Spigot</a>
</b>
<b>
<a href="https://william278.net/docs/husksync/setup">Setup</a>
</b>
<b>
<a href="https://william278.net/docs/husksync/">Docs</a>
</b>
<b>
<a href="https://github.com/WiIIiam278/HuskSync/issues">Issues</a>
</b>
</p>
<br/>
**HuskSync** is a modern, cross-server player data synchronisation system that allows player data (inventories, health, hunger & status effects) to be synchronised across servers through the use of **Redis**.
**HuskSync** is a modern, cross-server player data synchronization system that enables the comprehensive synchronization of your user's data across multiple proxied servers. It does this by making use of Redis and MySQL to optimally cache data while players change servers.
## Disclaimer
This source code is provided as reference to licensed individuals that have purchased the HuskSync plugin once from any of the official sources it is provided. The availability of this code does not grant you the rights to re-distribute, compile or share this source code outside this intended purpose.
## Features
**⭐ Seamless synchronization** &mdash; Utilises optimised Redis caching when players change server to sync player data super quickly for a seamless experience.
Are you a developer? [Read below for information about code bounty licensing](#Contributing).
**⭐ Complete player synchronization** &mdash; Sync inventories, Ender Chests, health, hunger, effects, advancements, statistics, locked maps & [more](https://william278.net/docs/husksync/sync-features)—no data left behind!
**⭐ Backup, restore & rotate** &mdash; Something gone wrong? Restore players back to a previous data state. Rotate and manage data snapshots in-game!
**⭐ Import existing data** &mdash; Import your MySQLPlayerDataBridge data—or from your existing world data! No server reset needed!
**⭐ Works great with Plan** &mdash; Stay in touch with your community through HuskSync analytics on your Plan web panel.
**⭐ Extensible API & open-source** &mdash; Need more? Extend the plugin with the Developer API. Or, submit a pull request through our code bounty system!
**Ready?** [It's syncing time!](https://william278.net/docs/husksync/setup)
## Setup
### Requirements
* A BungeeCord or Velocity-based proxy server
* A Spigot-based game server
* A Redis server
Requires a MySQL (v8.0+) database, a Redis (v5.0+) server and any number of Spigot-based 1.16.5+ Minecraft servers, running Java 16+.
### Installation
1. Install HuskSync in the `/plugins/` folder of both your Spigot and Proxy servers.
2. Start your servers, then stop them again to allow the configuration files to generate.
3. Navigate to the generated `config.yml` files on your Spigot server and Proxy (located in `/plugins/HuskSync/`) and fill in the credentials of your redis server.
1. On the Proxy server, you can additionally configure a MySQL database to save player data in, as by default the plugin will create a SQLite database.
3. By default, everything except player locations are synchronised. If you would like to change what gets synchronised, you can do this by editing the `config.yml` files of each Spigot server.
4. Once you have finished setting everything up, make sure to restart all of your servers and proxy server. Then, log in and data should be synchronised!
1. Place the plugin jar file in the /plugins/ directory of each Spigot server. You do not need to install HuskSync as a proxy plugin.
2. Start, then stop every server to let HuskSync generate the config file.
3. Navigate to the HuskSync config file on each server (~/plugins/HuskSync/config.yml) and fill in both the MySQL and Redis database credentials.
4. Start every server again and synchronization will begin.
### Migration from MySQLPlayerDataBridge
HuskSync supports the migration of player data from [MySQLPlayerDataBridge](https://www.spigotmc.org/resources/mysql-player-data-bridge.8117/). Please note that HuskSync is not compatible with MySQLPlayerInventoryBridge, as that has a different system for data handling.
## Development
To build HuskSync, simply run the following in the root of the repository:
To migrate from MySQLPLayerDataBridge, you need a Proxy server with HuskSync installed and one Spigot server with both HuskSync and MySQLPlayerDataBridge installed. To migrate:
1. Make sure HuskSync is set up correctly on the Proxy and Spigot server, making sure that the two are able to communicate with Redis (it will display a handshake confirmation message in both consoles when communications have been established)
2. Make sure your database is configured correctly on your Proxy server. For example, if you would like to change from SQLite to MySQL, you should do this now because the data from MySQLPlayerDataBridge will be moved into it.
3. Make sure no players are online, then in the Proxy server's console run `husksync migrate`
4. Follow the steps in the Migration wizard to ensure the connection credentials and details of the database containing your MySQLPlayerDataBridge are correct, changing settings with `husksync migrate setting <setting> <new value>` as necessary.
5. Run `husksync migrate start` in the Proxy server's console to start the migration. This could take some time, depending on the amount of data that needs migrating and the speed of your database/server. When the migration is complete, it will display a "Migration complete" message.
### Troubleshooting
#### Commands do not function
Please check that the plugin is installed and enabled on both the proxy and bukkit server you are trying to execute the command from and that both plugins connected to Redis. (A connection handshake confirmation message is logged to console when communications are successfully established.)
#### Data not being synced on player join and SQL errors in proxy console
This issue frequently occurs in users running Cracked (illegal) servers. I do not support piracy and so will be limited in my ability to help you.
If you are running an offline server for a legitimate reason, however, make sure that in the `paper.yml` of your Bukkit servers `bungee-online-mode` is set to the correct value - and that both your Proxy (BungeeCord, Waterfall, etc.) server and Bukkit (Spigot, paper, etc.) servers are set up correctly to work with offline mode.
#### Data sometimes not syncing between servers
There are two primary reasons this may happen:
* On your proxy server, you are running _FlameCord_ or a similar fork of Waterfall. Due to the nature of these forks changing security parameters, they can block or interfere with Redis packets being sent to and from your server. FlameCord, XCord and other forks are not compatible with HuskSync. For security-conscious users, I recommend Velocity.
* Your backend servers/proxy and Redis server have noticeably different amounts of latency between each other. This is particularly relevant for users running across multiple machines, where some backend servers / the proxy are installed with Redis and other backend servers are on a different machine. The solution to this is to have your BungeeCord and Redis alone on one machine, and your backend servers across the others - or have a separate machine with equal latency to the others that has Redis on. In the future, I may have a look at automatically correcting and accounting for differences in latency.
## How it works
![Flow chart showing different processes of how the plugin works](images/flow-chart.png)
HuskSync saves a player's data when they log out to a cache on your proxy server, and redistributes that data to players when they join another HuskSync-enabled server. Player data in the cache is then saved to a database (be it SQLite or MySQL) and this is loaded from when a player joins your network.
To facilitate the transfer of data between servers, HuskSync serializes player data and then makes use of Redis to communicate between the Proxy and Spigot servers.
### What is synchronised
Everything except player locations are synchronised by default. You can enable or disable what data is loaded on a server by modifying these values in the `/plugins/HuskSync/config.yml` file on each Spigot server.
* Player inventory
* Player armour and off-hand
* Player currently selected hotbar slot
* Player ender chest
* Player experience points & levels
* Player health
* Player max health
* Player health scale
* Player hunger
* Player saturation
* Player exhaustion
* Player game mode
* Player advancements
* Player statistics (ESC → Statistics menu)
* Player location
* Player flight status
### Commands
Commands are handled by the proxy server, rather than each spigot server. Some will only work on Spigot servers with HuskSync installed. Please remember that you will need a Proxy permission plugin (e.g. LuckPermsBungee) to set permissions for proxy commands.
| Command | Description | Permission |
|---------------------------------------|--------------------------------------|--------------------------------|
| `/husksync about` | View plugin information | _None_ |
| `/husksync update` | Check if an update is available | `husksync.command.admin` |
| `/husksync status` | View system status information | `husksync.command.admin` |
| `/husksync reload` | Reload config & message files | `husksync.command.admin` |
| `/husksync invsee <player> [cluster]` | View an offline player's inventory | `husksync.command.inventory` |
| `/husksync echest <player> [cluster]` | View an offline player's ender chest | `husksync.command.ender_chest` |
| `/husksync migrate [args] ` | Migrate data from MPDB | _Console-only_ |
### Frequently Asked Questions (FAQs)
#### Is Redis required?
Yes. Redis is both free, easy to install and multiplatform, though. Pterodactyl users can also run it in an egg with relatively low overheads.
#### What is Redis?
Redis is server software that acts as an in-memory data store. Minecraft server software typically makes use of its function to send messages efficiently.
#### Is Economy / Vault synchronization supported?
No.
Synchronising economy data like MySQLPlayerDataBridge does causes a number of issues and incompatibilities that mean that MySQLPlayerDataBridge has had to add integrations with a number of plugins just to make them work. This leads to poor compatibility and more bugs as plugins change their APIs and systems. In the case of HuskSync, this would require both plugin authors and myself to manually support each other, which would inevitably increase update times, lead to a bottomless pit of "add support for this plugin" requests and these integrations would then inevitably break when authors decide to update their plugins, requiring me to update manually.
I strongly recommend making use of economy plugins that provide built-in support for cross-server synchronisation instead, which do not have the same issues. I have personally used [XConomy](https://www.spigotmc.org/resources/xconomy.75669/) in the past and reccommend it.
#### Will this work on servers running multiple proxies?
Short answer: Not right now, but improved support for this is planned in the future.
Long answer: This is a difficult question to unpack because of the wide variety of setups that involve multiple proxies, however currently the architecture of how messages are sent between servers assumes that one proxy will serve multiple Bukkit servers, so having multiple proxies will lead to data going out of sync, among other issues.
#### Does it work with Velocity?
Yes! Servers running the Velocity proxy software are supported as of HuskSync 1.2+.
#### Is this faster than MySqlPlayerDataBridge (MPDB)?
It's difficult to say, and will depend on your server.
MPDB stores data in a MySQL database (hence the name) and operates by querying a database for said data when a player joins a Bukkit server.
HuskSync stores player data in a central cache on the Proxy server and servers request data from said cache; data is only queried from the database when a player joins the network, not when switching servers within it.
HuskSync should operate faster in theory, then, as it does not need to query large amounts of data from a database file as often. However, any performance enhancements you might see will heavily depend on the speed of your existing database and your server hardware.
#### Are modded items supported?
Most likely not - and I cannot support it - but feel free to test it, as depending on the implementation of your modding API it may work just fine.
## Developers
### API
HuskSync has an API for Bukkit providing events that fire when synchronisation takes place as well as a method to access and deserialize player data on demand. There is no API for the proxy side currently.
HuskSync's API is available on [JitPack](https://jitpack.io/#net.william278/HuskSync/Tag). You can view the [HuskSync JavaDocs here](https://javadoc.jitpack.io/net/william278/HuskSync/latest/javadoc/index.html). You should only use stuff in the `husksync.bukkit.api` and `husksync.bukkit.data` packages (as well as the PlayerData class located in the `husksync` root package.
#### Including the API in your project
With Maven, add the repository to your pom.xml:
```xml
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
```
Then, add the dependency. Replace `version` with the latest version of HuskSync: [![](https://jitpack.io/v/WiIIiam278/HuskSync.svg)](https://jitpack.io/#net.william278/HuskSync)
```xml
<dependency>
<groupId>net.william278</groupId>
<artifactId>HuskSync</artifactId>
<version>version</version>
<scope>provided</scope>
</dependency>
```
Or, with Gradle, add the dependency like so to your build.gradle:
```
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
}
```
Then add the dependency as follows. Replace `version` with the latest version of HuskSync: [![](https://jitpack.io/v/net.william278/HuskSync.svg)](https://jitpack.io/#net.william278/HuskSync)
```
dependencies {
compileOnly 'net.william278:HuskSync:version'
}
```
#### API Events
* **SyncCompleteEvent** - Fires when a player's data has finished synchronising. Use #getData to get the PlayerData being set.
* **SyncEvent** - Fires just before a player's data is synchronised. Can be cancelled. Use #getData to get the PlayerData being set, and #setData to set it.
#### Fetching player data on demand
To fetch PlayerData from a UUID as you need it, create an instance of the HuskSyncAPI class and use the `#getPlayerData` method. Note that data returned in this method is only the data from the central cache. That is to say, if the player is online, the data returned in this way will not necessarily be the same as the player's actual current data.
```java
HuskSyncAPI huskSyncApi = HuskSyncAPI.getInstance();
try {
CompletableFuture<PlayerData> playerDataCompletableFuture = huskSyncApi.getPlayerData(playerUUID);
// thenAccept blocks the thread until HuskSync has grabbed the data, so you may wish to run this asynchronously (e.g. Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {});.
playerDataCompletableFuture.thenAccept(playerData -> {
// You now have a PlayerData object which you can get serialized data from and deserialize with the DataSerializer static methods
});
} catch (IOException e) {
Bukkit.getLogger().severe("An error occurred fetching player data!");
}
```
#### Getting ItemStacks and usable data from PlayerData
Use the static methods provided in the [DataSerializer class](https://javadoc.jitpack.io/net.william278/HuskSync/latest/javadoc/net/william278/husksync/bukkit/data/DataSerializer.html). For instance, to get a player's inventory as an `ItemStack[]` from a `PlayerData` object.
```java
ItemStack[] inventoryItems = DataSerializer.serializeInventory(playerData.getSerializedInventory());
ItemStack[] enderChestItems = DataSerializer.serializeInventory(playerData.getSerializedEnderChest());
```
#### Updating PlayerData
You can then update PlayerData back to the central cache using the `HuskSyncAPI#updatePlayerData(playerData)` method. For example:
```java
// Update a value in the player data object
playerData.setHealth(20);
try {
// Update the player data to the cache
huskSyncApi.updatePlayerData(playerData);
} catch (IOException e) {
Bukkit.getLogger().severe("An error occurred updating player data!");
}
```
### Contributing
A code bounty program is in place for HuskSync, where developers making significant code contributions to HuskSync may be entitled to a discretionary license to use HuskSync in commercial contexts without having to purchase the resource, so please feel free to submit pull requests with improvements, fixes and features!
### Translation
While the code bounty program is not available for translation contributors, they are still strongly appreciated in making the plugin more accessible. If you'd like to contribute translated message strings for your language, you can submit a Pull Request that creates a .yml file in `bungeecord/src/main/resources/languages` with the correct translations.
### Building
You can build HuskSync yourself, though please read the license and buy yourself a copy as HuskSync is indeed a premium resource.
To build HuskSync, you'll need to get the [MPDBConverter](https://github.com/WiIIiam278/MPDBDataConverter) library, either by authenticating through GitHub packages or by downloading and running `mvn install-file` to publish it to your local maven repository.
Then, to build the plugin, run the following in the root of the repository:
```
```bash
./gradlew clean build
```
## bStats
This plugin uses bStats to provide me with metrics about its usage:
* [View Bukkit metrics](https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140)
* [View BungeeCord metrics](https://bstats.org/plugin/bungeecord/HuskSync%20-%20BungeeCord/13141)
* [View Velocity metrics](https://bstats.org/plugin/velocity/HuskSync%20-%20Velocity/13489)
### License
HuskSync is licensed under the Apache 2.0 license.
You can turn metric collection off by navigating to `~/plugins/bStats/config.yml` and editing the config to disable plugin metrics.
- [License](https://github.com/WiIIiam278/HuskSync/blob/master/LICENSE)
## Support
* Report bugs: [Click here](https://github.com/WiIIiam278/HuskSync/issues)
* Discord support: Join the [HuskHelp Discord](https://discord.gg/tVYhJfyDWG)!
* Proof of purchase is required for support.
Contributions to the project are welcome&mdash;feel free to open a pull request with new features, improvements and/or fixes!
### Support
Due to its complexity, official support for HuskSync is provided through a paid model. This means that support is only available to users who have purchased a license to the plugin from Spigot, Polymart, or Craftaro and have provided proof of purchase. Please join our Discord server if you have done so and need help!
### Translations
Translations of the plugin locales are welcome to help make the plugin more accessible. Please submit a pull request with your translations as a `.yml` file.
- [Locales Directory](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/languages)
- [English Locales](https://github.com/WiIIiam278/HuskSync/tree/master/common/src/main/resources/languages/en-gb.yml)
## Links
- [Docs](https://william278.net/docs/husksync/) &mdash; Read the plugin documentation!
- [Spigot](https://www.spigotmc.org/resources/husksync.97144/) &mdash; View the Spigot resource page (Also: [Polymart](https://polymart.org/resource/husksync.1634), [Craftaro](https://craftaro.com/marketplace/product/husksync.758))
- [Issues](https://github.com/WiIIiam278/HuskSync/issues) &mdash; File a bug report or feature request
- [Discord](https://discord.gg/tVYhJfyDWG) &mdash; Get help, ask questions (Purchase required)
- [bStats](https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140) &mdash; View plugin metrics
---
&copy; [William278](https://william278.net/), 2023. Licensed under the Apache-2.0 License.

View File

@@ -1,20 +0,0 @@
dependencies {
compileOnly project(path: ':common')
implementation project(path: ':bukkit')
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
compileOnly 'org.jetbrains:annotations:23.0.0'
}
shadowJar {
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'redis.clients', 'net.william278.husksync.libraries'
relocate 'org.apache', 'net.william278.husksync.libraries'
relocate 'net.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter'
}
java {
withSourcesJar()
withJavadocJar()
}

View File

@@ -1,81 +0,0 @@
package net.william278.husksync.bukkit.api;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.listener.BukkitRedisListener;
import net.william278.husksync.redis.RedisMessage;
import java.io.IOException;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* HuskSync's API. To access methods, use the {@link #getInstance()} entrypoint.
*
* @author William
*/
public class HuskSyncAPI {
private HuskSyncAPI() {
}
private static HuskSyncAPI instance;
/**
* The API entry point. Returns an instance of the {@link HuskSyncAPI}
*
* @return instance of the {@link HuskSyncAPI}
*/
public static HuskSyncAPI getInstance() {
if (instance == null) {
instance = new HuskSyncAPI();
}
return instance;
}
/**
* Returns a {@link CompletableFuture} that will fetch the {@link PlayerData} for a user given their {@link UUID},
* which contains serialized synchronised data.
* <p>
* This can then be deserialized into ItemStacks and other usable values using the {@code DataSerializer} class.
* <p>
* If no data could be returned, such as if an invalid UUID is specified, the CompletableFuture will be cancelled.
*
* @param playerUUID The {@link UUID} of the player to get data for
* @return a {@link CompletableFuture} with the user's {@link PlayerData} accessible on completion
* @throws IOException If an exception occurs with serializing during processing of the request
* @apiNote This only returns the latest saved and cached data of the user. This is <b>not</b> necessarily the current state of their inventory if they are online.
*/
public CompletableFuture<PlayerData> getPlayerData(UUID playerUUID) throws IOException {
// Create the request to be completed
final UUID requestUUID = UUID.randomUUID();
BukkitRedisListener.apiRequests.put(requestUUID, new CompletableFuture<>());
// Remove the request from the map on completion
BukkitRedisListener.apiRequests.get(requestUUID).whenComplete((playerData, throwable) -> BukkitRedisListener.apiRequests.remove(requestUUID));
// Request the data via the proxy
new RedisMessage(RedisMessage.MessageType.API_DATA_REQUEST,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
playerUUID.toString(), requestUUID.toString()).send();
return BukkitRedisListener.apiRequests.get(requestUUID);
}
/**
* Updates a player's {@link PlayerData} to the proxy cache and database.
* <p>
* If the player is online on the Proxy network, they will be updated and overwritten with this data.
*
* @param playerData The {@link PlayerData} (which contains the {@link UUID}) of the player data to update to the central cache and database
* @throws IOException If an exception occurs with serializing during processing of the update
*/
public void updatePlayerData(PlayerData playerData) throws IOException {
// Serialize and send the updated player data
final String serializedPlayerData = RedisMessage.serialize(playerData);
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
serializedPlayerData, Boolean.toString(true)).send();
}
}

View File

@@ -1,20 +1,31 @@
plugins {
id 'com.github.johnrengelman.shadow' version '7.1.0'
id 'org.ajoberstar.grgit' version '4.1.1'
id 'com.github.johnrengelman.shadow' version '8.1.1'
id 'org.cadixdev.licenser' version '0.6.1' apply false
id 'org.ajoberstar.grgit' version '5.2.0'
id 'maven-publish'
id 'java'
}
group 'net.william278'
version "$ext.plugin_version+${versionMetadata()}"
version "$ext.plugin_version${versionMetadata()}"
description "$ext.plugin_description"
defaultTasks 'licenseFormat', 'build'
ext {
set 'version', version.toString()
set 'description', description.toString()
set 'jedis_version', jedis_version.toString()
set 'mysql_driver_version', mysql_driver_version.toString()
set 'mariadb_driver_version', mariadb_driver_version.toString()
set 'snappy_version', snappy_version.toString()
}
import org.apache.tools.ant.filters.ReplaceTokens
allprojects {
apply plugin: 'com.github.johnrengelman.shadow'
apply plugin: 'org.cadixdev.licenser'
apply plugin: 'java'
compileJava.options.encoding = 'UTF-8'
@@ -27,18 +38,29 @@ allprojects {
mavenLocal()
mavenCentral()
maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
maven { url 'https://repo.velocitypowered.com/snapshots/' }
maven { url 'https://repo.codemc.io/repository/maven-public/' }
maven { url 'https://repo.minebench.de/' }
maven { url 'https://repo.codemc.org/repository/maven-public' }
maven { url 'https://repo.alessiodp.com/releases/' }
maven { url 'https://jitpack.io' }
maven { url 'https://mvn-repo.arim.space/lesser-gpl3/' }
maven { url 'https://libraries.minecraft.net/' }
maven { url 'https://repo.william278.net/releases/' }
}
dependencies {
implementation('redis.clients:jedis:4.2.3') {
//noinspection GroovyAssignabilityCheck
exclude module: 'slf4j-api'
}
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.0'
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.0'
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.0'
}
test {
useJUnitPlatform()
}
license {
header = rootProject.file('HEADER')
include '**/*.java'
newLine = true
}
processResources {
@@ -51,12 +73,74 @@ subprojects {
version rootProject.version
archivesBaseName = "${rootProject.name}-${project.name.capitalize()}"
if (['bukkit', 'api', 'bungeecord', 'velocity', 'plugin'].contains(project.name)) {
jar {
from '../LICENSE'
}
if (['bukkit', 'plugin'].contains(project.name)) {
shadowJar {
destinationDirectory.set(file("$rootDir/target"))
archiveClassifier.set('')
}
jar.dependsOn shadowJar
// API publishing
if ('bukkit'.contains(project.name)) {
java {
withSourcesJar()
withJavadocJar()
}
sourcesJar {
destinationDirectory.set(file("$rootDir/target"))
}
javadocJar {
destinationDirectory.set(file("$rootDir/target"))
}
shadowJar.dependsOn(sourcesJar, javadocJar)
publishing {
repositories {
if (System.getenv("RELEASES_MAVEN_USERNAME") != null) {
maven {
name = "william278-releases"
url = "https://repo.william278.net/releases"
credentials {
username = System.getenv("RELEASES_MAVEN_USERNAME")
password = System.getenv("RELEASES_MAVEN_PASSWORD")
}
authentication {
basic(BasicAuthentication)
}
}
}
if (System.getenv("SNAPSHOTS_MAVEN_USERNAME") != null) {
maven {
name = "william278-snapshots"
url = "https://repo.william278.net/snapshots"
credentials {
username = System.getenv("SNAPSHOTS_MAVEN_USERNAME")
password = System.getenv("SNAPSHOTS_MAVEN_PASSWORD")
}
authentication {
basic(BasicAuthentication)
}
}
}
}
publications {
mavenJava(MavenPublication) {
groupId = 'net.william278'
artifactId = 'husksync'
version = "$rootProject.version"
artifact shadowJar
artifact javadocJar
artifact sourcesJar
}
}
}
}
jar.dependsOn(shadowJar)
clean.delete "$rootDir/target"
}
}
@@ -65,8 +149,15 @@ logger.lifecycle("Building HuskSync ${version} by William278")
@SuppressWarnings('GrMethodMayBeStatic')
def versionMetadata() {
if (grgit == null) {
return System.getenv("GITHUB_RUN_NUMBER") ? 'build.' + System.getenv("GITHUB_RUN_NUMBER") : 'unknown'
// Get if there is a tag for this commit
def tag = grgit.tag.list().find { it.commit.id == grgit.head().id }
if (tag != null) {
return ''
}
return 'rev.' + grgit.head().abbreviatedId + (grgit.status().clean ? '' : '-indev')
// Otherwise, get the last commit hash and if it's a clean head
if (grgit == null) {
return '-' + System.getenv("GITHUB_RUN_NUMBER") ? 'build.' + System.getenv("GITHUB_RUN_NUMBER") : 'unknown'
}
return '-' + grgit.head().abbreviatedId + (grgit.status().clean ? '' : '-indev')
}

View File

@@ -1,18 +1,60 @@
dependencies {
implementation project(path: ':common')
implementation 'org.bstats:bstats-bukkit:3.0.0'
implementation 'de.themoep:minedown:1.7.1-SNAPSHOT'
implementation 'net.william278:mpdbdataconverter:1.0'
implementation 'org.bstats:bstats-bukkit:3.0.2'
implementation 'net.william278:mpdbdataconverter:1.0.1'
implementation 'net.william278:hsldataconverter:1.0'
implementation 'net.william278:mapdataapi:1.0.3'
implementation 'net.william278:andjam:1.0.2'
implementation 'me.lucko:commodore:2.2'
implementation 'net.kyori:adventure-platform-bukkit:4.3.0'
implementation 'dev.triumphteam:triumph-gui:3.1.5'
implementation 'space.arim.morepaperlib:morepaperlib:0.4.3'
implementation 'de.tr7zw:item-nbt-api:2.12.0'
compileOnly 'org.spigotmc:spigot-api:1.16.5-R0.1-SNAPSHOT'
compileOnly 'org.jetbrains:annotations:23.0.0'
compileOnly 'commons-io:commons-io:2.13.0'
compileOnly 'org.json:json:20230618'
compileOnly 'de.themoep:minedown-adventure:1.7.2-SNAPSHOT'
compileOnly 'dev.dejvokep:boosted-yaml:1.3.1'
compileOnly 'com.zaxxer:HikariCP:5.0.1'
compileOnly 'net.william278:DesertWell:2.0.4'
compileOnly 'net.william278:annotaml:2.0.7'
compileOnly 'net.william278:AdvancementAPI:97a9583413'
compileOnly "redis.clients:jedis:$jedis_version"
}
shadowJar {
dependencies {
exclude(dependency('com.mojang:brigadier'))
}
relocate 'org.apache.commons.io', 'net.william278.husksync.libraries.commons.io'
relocate 'org.apache.commons.text', 'net.william278.husksync.libraries.commons.text'
relocate 'org.apache.commons.lang3', 'net.william278.husksync.libraries.commons.lang3'
relocate 'com.google.gson', 'net.william278.husksync.libraries.gson'
relocate 'org.json', 'net.william278.husksync.libraries.json'
relocate 'com.fatboyindustrial', 'net.william278.husksync.libraries'
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'net.kyori', 'net.william278.husksync.libraries'
relocate 'org.jetbrains', 'net.william278.husksync.libraries'
relocate 'org.intellij', 'net.william278.husksync.libraries'
relocate 'com.zaxxer', 'net.william278.husksync.libraries'
relocate 'dev.dejvokep', 'net.william278.husksync.libraries'
relocate 'net.william278.desertwell', 'net.william278.husksync.libraries.desertwell'
relocate 'net.william278.paginedown', 'net.william278.husksync.libraries.paginedown'
relocate 'net.william278.mapdataapi', 'net.william278.husksync.libraries.mapdataapi'
relocate 'net.william278.andjam', 'net.william278.husksync.libraries.andjam'
relocate 'net.querz', 'net.william278.husksync.libraries.nbtparser'
relocate 'net.roxeez', 'net.william278.husksync.libraries'
relocate 'me.lucko.commodore', 'net.william278.husksync.libraries.commodore'
relocate 'net.byteflux.libby', 'net.william278.husksync.libraries.libby'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'redis.clients', 'net.william278.husksync.libraries'
relocate 'org.apache', 'net.william278.husksync.libraries'
relocate 'dev.triumphteam.gui', 'net.william278.husksync.libraries.triumphgui'
relocate 'net.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter'
relocate 'net.william278.hslmigrator', 'net.william278.husksync.libraries.hslconverter'
relocate 'net.william278.annotaml', 'net.william278.husksync.libraries.annotaml'
relocate 'space.arim.morepaperlib', 'net.william278.husksync.libraries.paperlib'
relocate 'de.tr7zw.changeme.nbtapi', 'net.william278.husksync.libraries.nbtapi'
}

View File

@@ -1,57 +0,0 @@
package me.william278.husksync.bukkit.data;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.World;
import org.bukkit.entity.EntityType;
import java.io.Serializable;
import java.time.Instant;
import java.util.*;
/**
* Holds legacy data store methods for data storage
*/
@Deprecated
@SuppressWarnings("DeprecatedIsStillUsed")
public class DataSerializer {
/**
* A record used to store data for advancement synchronisation
*
* @deprecated Old format - Use {@link AdvancementRecordDate} instead
*/
@Deprecated
@SuppressWarnings("DeprecatedIsStillUsed")
// Suppress deprecation warnings here (still used for backwards compatibility)
public record AdvancementRecord(String advancementKey,
ArrayList<String> awardedAdvancementCriteria) implements Serializable {
}
/**
* A record used to store data for a player's statistics
*/
public record StatisticData(HashMap<Statistic, Integer> untypedStatisticValues,
HashMap<Statistic, HashMap<Material, Integer>> blockStatisticValues,
HashMap<Statistic, HashMap<Material, Integer>> itemStatisticValues,
HashMap<Statistic, HashMap<EntityType, Integer>> entityStatisticValues) implements Serializable {
}
/**
* A record used to store data for native advancement synchronisation, tracking advancement date progress
*/
public record AdvancementRecordDate(String key, Map<String, Date> criteriaMap) implements Serializable {
public AdvancementRecordDate(String key, List<String> criteriaList) {
this(key, new HashMap<>() {{
criteriaList.forEach(s -> put(s, Date.from(Instant.EPOCH)));
}});
}
}
/**
* A record used to store data for a player's location
*/
public record PlayerLocation(double x, double y, double z, float yaw, float pitch,
String worldName, World.Environment environment) implements Serializable {
}
}

View File

@@ -0,0 +1,378 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync;
import com.google.gson.Gson;
import net.kyori.adventure.platform.bukkit.BukkitAudiences;
import net.william278.desertwell.util.Version;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.adapter.GsonAdapter;
import net.william278.husksync.adapter.SnappyGsonAdapter;
import net.william278.husksync.api.BukkitHuskSyncAPI;
import net.william278.husksync.command.BukkitCommand;
import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.BukkitSerializer;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer;
import net.william278.husksync.database.Database;
import net.william278.husksync.database.MySqlDatabase;
import net.william278.husksync.event.BukkitEventDispatcher;
import net.william278.husksync.hook.PlanHook;
import net.william278.husksync.listener.BukkitEventListener;
import net.william278.husksync.listener.EventListener;
import net.william278.husksync.migrator.LegacyMigrator;
import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.migrator.MpdbMigrator;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.ConsoleUser;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.BukkitLegacyConverter;
import net.william278.husksync.util.BukkitMapPersister;
import net.william278.husksync.util.BukkitTask;
import net.william278.husksync.util.LegacyConverter;
import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.map.MapView;
import org.bukkit.plugin.java.JavaPlugin;
import org.jetbrains.annotations.NotNull;
import space.arim.morepaperlib.MorePaperLib;
import space.arim.morepaperlib.commands.CommandRegistration;
import space.arim.morepaperlib.scheduling.AsynchronousScheduler;
import space.arim.morepaperlib.scheduling.GracefulScheduling;
import space.arim.morepaperlib.scheduling.RegionalScheduler;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.Supplier, BukkitEventDispatcher,
BukkitMapPersister {
/**
* Metrics ID for <a href="https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140">HuskSync on Bukkit</a>.
*/
private static final int METRICS_ID = 13140;
private static final String PLATFORM_TYPE_ID = "bukkit";
private Database database;
private RedisManager redisManager;
private EventListener eventListener;
private DataAdapter dataAdapter;
private Map<Identifier, Serializer<? extends Data>> serializers;
private Map<UUID, Map<Identifier, Data>> playerCustomDataStore;
private Settings settings;
private Locales locales;
private List<Migrator> availableMigrators;
private LegacyConverter legacyConverter;
private Map<Integer, MapView> mapViews;
private BukkitAudiences audiences;
private MorePaperLib paperLib;
private AsynchronousScheduler asyncScheduler;
private RegionalScheduler regionalScheduler;
private Gson gson;
@Override
public void onEnable() {
// Initial plugin setup
this.gson = createGson();
this.audiences = BukkitAudiences.create(this);
this.paperLib = new MorePaperLib(this);
this.availableMigrators = new ArrayList<>();
this.serializers = new LinkedHashMap<>();
this.playerCustomDataStore = new ConcurrentHashMap<>();
this.mapViews = new ConcurrentHashMap<>();
// Load settings and locales
initialize("plugin config & locale files", (plugin) -> this.loadConfigs());
// Prepare data adapter
initialize("data adapter", (plugin) -> {
if (settings.doCompressData()) {
dataAdapter = new SnappyGsonAdapter(this);
} else {
dataAdapter = new GsonAdapter(this);
}
});
// Prepare serializers
initialize("data serializers", (plugin) -> {
registerSerializer(Identifier.INVENTORY, new BukkitSerializer.Inventory(this));
registerSerializer(Identifier.ENDER_CHEST, new BukkitSerializer.EnderChest(this));
registerSerializer(Identifier.ADVANCEMENTS, new BukkitSerializer.Advancements(this));
registerSerializer(Identifier.LOCATION, new BukkitSerializer.Location(this));
registerSerializer(Identifier.HEALTH, new BukkitSerializer.Health(this));
registerSerializer(Identifier.HUNGER, new BukkitSerializer.Hunger(this));
registerSerializer(Identifier.GAME_MODE, new BukkitSerializer.GameMode(this));
registerSerializer(Identifier.POTION_EFFECTS, new BukkitSerializer.PotionEffects(this));
registerSerializer(Identifier.STATISTICS, new BukkitSerializer.Statistics(this));
registerSerializer(Identifier.EXPERIENCE, new BukkitSerializer.Experience(this));
registerSerializer(Identifier.PERSISTENT_DATA, new BukkitSerializer.PersistentData(this));
});
// Setup available migrators
initialize("data migrators/converters", (plugin) -> {
availableMigrators.add(new LegacyMigrator(this));
if (isDependencyLoaded("MySqlPlayerDataBridge")) {
availableMigrators.add(new MpdbMigrator(this));
}
legacyConverter = new BukkitLegacyConverter(this);
});
// Initialize the database
initialize(getSettings().getDatabaseType().getDisplayName() + " database connection", (plugin) -> {
this.database = new MySqlDatabase(this);
this.database.initialize();
});
// Prepare redis connection
initialize("Redis server connection", (plugin) -> {
this.redisManager = new RedisManager(this);
this.redisManager.initialize();
});
// Register events
initialize("events", (plugin) -> this.eventListener = new BukkitEventListener(this));
// Register commands
initialize("commands", (plugin) -> BukkitCommand.Type.registerCommands(this));
// Register plugin hooks
initialize("hooks", (plugin) -> {
if (isDependencyLoaded("Plan") && getSettings().usePlanHook()) {
new PlanHook(this).hookIntoPlan();
}
});
// Register API
initialize("api", (plugin) -> BukkitHuskSyncAPI.register(this));
// Hook into bStats and check for updates
initialize("metrics", (plugin) -> this.registerMetrics(METRICS_ID));
this.checkForUpdates();
}
@Override
public void onDisable() {
// Handle shutdown
if (this.eventListener != null) {
this.eventListener.handlePluginDisable();
}
// Unregister API and cancel tasks
BukkitHuskSyncAPI.unregister();
this.cancelTasks();
// Complete shutdown
log(Level.INFO, "Successfully disabled HuskSync v" + getPluginVersion());
}
@Override
@NotNull
public Set<OnlineUser> getOnlineUsers() {
return Bukkit.getOnlinePlayers().stream()
.map(player -> BukkitUser.adapt(player, this))
.collect(Collectors.toSet());
}
@Override
@NotNull
public Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid) {
final Player player = Bukkit.getPlayer(uuid);
if (player == null) {
return Optional.empty();
}
return Optional.of(BukkitUser.adapt(player, this));
}
@Override
@NotNull
public Database getDatabase() {
return database;
}
@Override
@NotNull
public RedisManager getRedisManager() {
return redisManager;
}
@NotNull
@Override
public DataAdapter getDataAdapter() {
return dataAdapter;
}
@NotNull
@Override
public Map<Identifier, Serializer<? extends Data>> getSerializers() {
return serializers;
}
@NotNull
@Override
public List<Migrator> getAvailableMigrators() {
return availableMigrators;
}
@NotNull
@Override
public Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user) {
if (playerCustomDataStore.containsKey(user.getUuid())) {
return playerCustomDataStore.get(user.getUuid());
}
final Map<Identifier, Data> data = new HashMap<>();
playerCustomDataStore.put(user.getUuid(), data);
return data;
}
@Override
@NotNull
public Settings getSettings() {
return settings;
}
@Override
public void setSettings(@NotNull Settings settings) {
this.settings = settings;
}
@Override
@NotNull
public Locales getLocales() {
return locales;
}
@Override
public void setLocales(@NotNull Locales locales) {
this.locales = locales;
}
@Override
public boolean isDependencyLoaded(@NotNull String name) {
return Bukkit.getPluginManager().getPlugin(name) != null;
}
// Register bStats metrics
public void registerMetrics(int metricsId) {
if (!getPluginVersion().getMetadata().isBlank()) {
return;
}
try {
new Metrics(this, metricsId);
} catch (Throwable e) {
log(Level.WARNING, "Failed to register bStats metrics (" + e.getMessage() + ")");
}
}
@Override
public void log(@NotNull Level level, @NotNull String message, @NotNull Throwable... throwable) {
if (throwable.length > 0) {
getLogger().log(level, message, throwable[0]);
} else {
getLogger().log(level, message);
}
}
@NotNull
@Override
public ConsoleUser getConsole() {
return new ConsoleUser(audiences.console());
}
@NotNull
@Override
public Version getPluginVersion() {
return Version.fromString(getDescription().getVersion(), "-");
}
@NotNull
@Override
public Version getMinecraftVersion() {
return Version.fromString(Bukkit.getBukkitVersion());
}
@NotNull
@Override
public String getPlatformType() {
return PLATFORM_TYPE_ID;
}
@Override
public Optional<LegacyConverter> getLegacyConverter() {
return Optional.of(legacyConverter);
}
@NotNull
@Override
public Set<UUID> getLockedPlayers() {
return this.eventListener.getLockedPlayers();
}
@NotNull
@Override
public Gson getGson() {
return gson;
}
@NotNull
public Map<Integer, MapView> getMapViews() {
return mapViews;
}
@NotNull
public GracefulScheduling getScheduler() {
return paperLib.scheduling();
}
@NotNull
public AsynchronousScheduler getAsyncScheduler() {
return asyncScheduler == null
? asyncScheduler = getScheduler().asyncScheduler() : asyncScheduler;
}
@NotNull
public RegionalScheduler getRegionalScheduler() {
return regionalScheduler == null
? regionalScheduler = getScheduler().globalRegionalScheduler() : regionalScheduler;
}
@NotNull
public BukkitAudiences getAudiences() {
return audiences;
}
@NotNull
public CommandRegistration getCommandRegistrar() {
return paperLib.commandRegistration();
}
@Override
@NotNull
public HuskSync getPlugin() {
return this;
}
}

View File

@@ -1,163 +0,0 @@
package net.william278.husksync;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.util.BukkitUpdateChecker;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.william278.husksync.bukkit.config.ConfigLoader;
import net.william278.husksync.bukkit.data.BukkitDataCache;
import net.william278.husksync.bukkit.listener.BukkitRedisListener;
import net.william278.husksync.bukkit.listener.BukkitEventListener;
import net.william278.husksync.bukkit.migrator.MPDBDeserializer;
import net.william278.husksync.redis.RedisMessage;
import org.bstats.bukkit.Metrics;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.bukkit.plugin.java.JavaPlugin;
import org.bukkit.scheduler.BukkitTask;
import java.io.IOException;
import java.util.UUID;
import java.util.logging.Level;
public final class HuskSyncBukkit extends JavaPlugin {
// Bukkit bStats ID (Different to BungeeCord)
private static final int METRICS_ID = 13140;
private static HuskSyncBukkit instance;
public static HuskSyncBukkit getInstance() {
return instance;
}
public static BukkitDataCache bukkitCache;
public static BukkitRedisListener redisListener;
// Used for establishing a handshake with redis
public static UUID serverUUID;
// Has a handshake been established with the Bungee?
public static boolean handshakeCompleted = false;
// The handshake task to execute
private static BukkitTask handshakeTask;
// Whether MySqlPlayerDataBridge is installed
public static boolean isMySqlPlayerDataBridgeInstalled;
// Establish the handshake with the proxy
public static void establishRedisHandshake() {
serverUUID = UUID.randomUUID();
getInstance().getLogger().log(Level.INFO, "Executing handshake with Proxy server...");
final int[] attempts = {0}; // How many attempts to establish communication have been made
handshakeTask = Bukkit.getScheduler().runTaskTimerAsynchronously(getInstance(), () -> {
if (handshakeCompleted) {
handshakeTask.cancel();
return;
}
try {
new RedisMessage(RedisMessage.MessageType.CONNECTION_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
serverUUID.toString(),
Boolean.toString(isMySqlPlayerDataBridgeInstalled),
Bukkit.getName(),
getInstance().getDescription().getVersion())
.send();
attempts[0]++;
if (attempts[0] == 10) {
getInstance().getLogger().log(Level.WARNING, "Failed to complete handshake with the Proxy server; Please make sure your Proxy server is online and has HuskSync installed in its' /plugins/ folder. HuskSync will continue to try and establish a connection.");
}
} catch (IOException e) {
getInstance().getLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake establishment", e);
}
}, 0, 60);
}
private void closeRedisHandshake() {
if (!handshakeCompleted) return;
try {
new RedisMessage(RedisMessage.MessageType.TERMINATE_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
serverUUID.toString(),
Bukkit.getName()).send();
} catch (IOException e) {
getInstance().getLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake termination", e);
}
}
@Override
public void onLoad() {
instance = this;
}
@Override
public void onEnable() {
// Plugin startup logic
// Load the config file
getConfig().options().copyDefaults(true);
saveDefaultConfig();
saveConfig();
reloadConfig();
ConfigLoader.loadSettings(getConfig());
// Do update checker
if (Settings.automaticUpdateChecks) {
new BukkitUpdateChecker().logToConsole();
}
// Check if MySqlPlayerDataBridge is installed
Plugin mySqlPlayerDataBridge = Bukkit.getPluginManager().getPlugin("MySqlPlayerDataBridge");
if (mySqlPlayerDataBridge != null) {
isMySqlPlayerDataBridgeInstalled = mySqlPlayerDataBridge.isEnabled();
MPDBDeserializer.setMySqlPlayerDataBridge();
getLogger().info("MySQLPlayerDataBridge detected! Disabled data synchronisation to prevent data loss. To perform a migration, run \"husksync migrate\" in your Proxy (Bungeecord, Waterfall, etc) server console.");
}
// Initialize last data update UUID cache
bukkitCache = new BukkitDataCache();
// Initialize event listener
getServer().getPluginManager().registerEvents(new BukkitEventListener(), this);
// Initialize the redis listener
redisListener = new BukkitRedisListener();
// Ensure redis is connected; establish a handshake
establishRedisHandshake();
// Initialize bStats metrics
try {
new Metrics(this, METRICS_ID);
} catch (Exception e) {
getLogger().info("Skipped metrics initialization");
}
// Log to console
getLogger().info("Enabled HuskSync (" + getServer().getName() + ") v" + getDescription().getVersion());
}
@Override
public void onDisable() {
// Update player data for disconnecting players
if (HuskSyncBukkit.handshakeCompleted && !HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled && Bukkit.getOnlinePlayers().size() > 0) {
getLogger().info("Saving data for remaining online players...");
for (Player player : Bukkit.getOnlinePlayers()) {
PlayerSetter.updatePlayerData(player, false);
// Clear player inventory and ender chest
player.getInventory().clear();
player.getEnderChest().clear();
}
getLogger().info("Data save complete!");
}
// Send termination handshake to proxy
closeRedisHandshake();
// Plugin shutdown logic
getLogger().info("Disabled HuskSync (" + getServer().getName() + ") v" + getDescription().getVersion());
}
}

View File

@@ -0,0 +1,270 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.api;
import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.data.BukkitData;
import net.william278.husksync.data.DataHolder;
import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.function.Consumer;
/**
* The HuskSync API implementation for the Bukkit platform
* </p>
* Retrieve an instance of the API class via {@link #getInstance()}.
*/
@SuppressWarnings("unused")
public class BukkitHuskSyncAPI extends HuskSyncAPI {
// Instance of the plugin
private static BukkitHuskSyncAPI instance;
/**
* <b>(Internal use only)</b> - Constructor, instantiating the API.
*/
@ApiStatus.Internal
private BukkitHuskSyncAPI(@NotNull BukkitHuskSync plugin) {
super(plugin);
}
/**
* Entrypoint to the HuskSync API - returns an instance of the API
*
* @return instance of the HuskSync API
* @since 3.0
*/
@NotNull
public static BukkitHuskSyncAPI getInstance() {
if (instance == null) {
throw new NotRegisteredException();
}
return instance;
}
/**
* <b>(Internal use only)</b> - Register the API for this platform.
*
* @param plugin the plugin instance
* @since 3.0
*/
@ApiStatus.Internal
public static void register(@NotNull BukkitHuskSync plugin) {
instance = new BukkitHuskSyncAPI(plugin);
}
/**
* <b>(Internal use only)</b> - Unregister the API for this platform.
*/
@ApiStatus.Internal
public static void unregister() {
instance = null;
}
/**
* Returns a {@link OnlineUser} instance for the given bukkit {@link Player}.
*
* @param player the bukkit player to get the {@link OnlineUser} instance for
* @return the {@link OnlineUser} instance for the given bukkit {@link Player}
* @since 2.0
*/
@NotNull
public BukkitUser getUser(@NotNull Player player) {
return BukkitUser.adapt(player, plugin);
}
/**
* Get the current {@link BukkitData.Items.Inventory} of the given {@link User}
*
* @param user the user to get the inventory of
* @return the {@link BukkitData.Items.Inventory} of the given {@link User}
* @since 3.0
*/
public CompletableFuture<Optional<BukkitData.Items.Inventory>> getCurrentInventory(@NotNull User user) {
return getCurrentData(user).thenApply(data -> data.flatMap(DataHolder::getInventory)
.map(BukkitData.Items.Inventory.class::cast));
}
/**
* Get the current {@link BukkitData.Items.Inventory} of the given {@link Player}
*
* @param user the user to get the inventory of
* @return the {@link BukkitData.Items.Inventory} of the given {@link Player}
* @since 3.0
*/
public CompletableFuture<Optional<ItemStack[]>> getCurrentInventoryContents(@NotNull User user) {
return getCurrentInventory(user)
.thenApply(inventory -> inventory.map(BukkitData.Items.Inventory::getContents));
}
/**
* Set the current {@link BukkitData.Items.Inventory} of the given {@link User}
*
* @param user the user to set the inventory of
* @param contents the contents to set the inventory to
* @since 3.0
*/
public void setCurrentInventory(@NotNull User user, @NotNull BukkitData.Items.Inventory contents) {
editCurrentData(user, dataHolder -> dataHolder.setInventory(contents));
}
/**
* Set the current {@link BukkitData.Items.Inventory} of the given {@link User}
*
* @param user the user to set the inventory of
* @param contents the contents to set the inventory to
* @since 3.0
*/
public void setCurrentInventoryContents(@NotNull User user, @NotNull ItemStack[] contents) {
editCurrentData(
user,
dataHolder -> dataHolder.getInventory().ifPresent(
inv -> inv.setContents(adaptItems(contents))
)
);
}
/**
* Edit the current {@link BukkitData.Items.Inventory} of the given {@link User}
*
* @param user the user to edit the inventory of
* @param editor the editor to apply to the inventory
* @since 3.0
*/
public void editCurrentInventory(@NotNull User user, ThrowingConsumer<BukkitData.Items.Inventory> editor) {
editCurrentData(user, dataHolder -> dataHolder.getInventory()
.map(BukkitData.Items.Inventory.class::cast)
.ifPresent(editor));
}
/**
* Edit the current {@link BukkitData.Items.Inventory} of the given {@link User}
*
* @param user the user to edit the inventory of
* @param editor the editor to apply to the inventory
* @since 3.0
*/
public void editCurrentInventoryContents(@NotNull User user, ThrowingConsumer<ItemStack[]> editor) {
editCurrentData(user, dataHolder -> dataHolder.getInventory()
.map(BukkitData.Items.Inventory.class::cast)
.ifPresent(inventory -> editor.accept(inventory.getContents())));
}
/**
* Get the current {@link BukkitData.Items.EnderChest} of the given {@link User}
*
* @param user the user to get the ender chest of
* @return the {@link BukkitData.Items.EnderChest} of the given {@link User}, or {@link Optional#empty()} if the
* user data could not be found
* @since 3.0
*/
public CompletableFuture<Optional<BukkitData.Items.EnderChest>> getCurrentEnderChest(@NotNull User user) {
return getCurrentData(user).thenApply(data -> data.flatMap(DataHolder::getEnderChest)
.map(BukkitData.Items.EnderChest.class::cast));
}
/**
* Get the current {@link BukkitData.Items.EnderChest} of the given {@link Player}
*
* @param user the user to get the ender chest of
* @return the {@link BukkitData.Items.EnderChest} of the given {@link Player}, or {@link Optional#empty()} if the
* user data could not be found
* @since 3.0
*/
public CompletableFuture<Optional<ItemStack[]>> getCurrentEnderChestContents(@NotNull User user) {
return getCurrentEnderChest(user)
.thenApply(enderChest -> enderChest.map(BukkitData.Items.EnderChest::getContents));
}
/**
* Set the current {@link BukkitData.Items.EnderChest} of the given {@link User}
*
* @param user the user to set the ender chest of
* @param contents the contents to set the ender chest to
* @since 3.0
*/
public void setCurrentEnderChest(@NotNull User user, @NotNull BukkitData.Items.EnderChest contents) {
editCurrentData(user, dataHolder -> dataHolder.setEnderChest(contents));
}
/**
* Set the current {@link BukkitData.Items.EnderChest} of the given {@link User}
*
* @param user the user to set the ender chest of
* @param contents the contents to set the ender chest to
* @since 3.0
*/
public void setCurrentEnderChestContents(@NotNull User user, @NotNull ItemStack[] contents) {
editCurrentData(
user,
dataHolder -> dataHolder.getEnderChest().ifPresent(
enderChest -> enderChest.setContents(adaptItems(contents))
)
);
}
/**
* Edit the current {@link BukkitData.Items.EnderChest} of the given {@link User}
*
* @param user the user to edit the ender chest of
* @param editor the editor to apply to the ender chest
* @since 3.0
*/
public void editCurrentEnderChest(@NotNull User user, Consumer<BukkitData.Items.EnderChest> editor) {
editCurrentData(user, dataHolder -> dataHolder.getEnderChest()
.map(BukkitData.Items.EnderChest.class::cast)
.ifPresent(editor));
}
/**
* Edit the current {@link BukkitData.Items.EnderChest} of the given {@link User}
*
* @param user the user to edit the ender chest of
* @param editor the editor to apply to the ender chest
* @since 3.0
*/
public void editCurrentEnderChestContents(@NotNull User user, Consumer<ItemStack[]> editor) {
editCurrentData(user, dataHolder -> dataHolder.getEnderChest()
.map(BukkitData.Items.EnderChest.class::cast)
.ifPresent(enderChest -> editor.accept(enderChest.getContents())));
}
/**
* Adapts an array of {@link ItemStack} to a {@link BukkitData.Items} instance
*
* @param contents the contents to adapt
* @return the adapted {@link BukkitData.Items} instance
* @since 3.0
*/
@NotNull
public BukkitData.Items adaptItems(@NotNull ItemStack[] contents) {
return BukkitData.Items.ItemArray.adapt(contents);
}
}

View File

@@ -1,34 +0,0 @@
package net.william278.husksync.bukkit.config;
import net.william278.husksync.Settings;
import org.bukkit.configuration.file.FileConfiguration;
public class ConfigLoader {
public static void loadSettings(FileConfiguration config) throws IllegalArgumentException {
Settings.serverType = Settings.ServerType.BUKKIT;
Settings.automaticUpdateChecks = config.getBoolean("check_for_updates", true);
Settings.cluster = config.getString("cluster_id", "main");
Settings.redisHost = config.getString("redis_settings.host", "localhost");
Settings.redisPort = config.getInt("redis_settings.port", 6379);
Settings.redisPassword = config.getString("redis_settings.password", "");
Settings.redisSSL = config.getBoolean("redis_settings.use_ssl", false);
Settings.syncInventories = config.getBoolean("synchronisation_settings.inventories", true);
Settings.syncEnderChests = config.getBoolean("synchronisation_settings.ender_chests", true);
Settings.syncHealth = config.getBoolean("synchronisation_settings.health", true);
Settings.syncHunger = config.getBoolean("synchronisation_settings.hunger", true);
Settings.syncExperience = config.getBoolean("synchronisation_settings.experience", true);
Settings.syncPotionEffects = config.getBoolean("synchronisation_settings.potion_effects", true);
Settings.syncStatistics = config.getBoolean("synchronisation_settings.statistics", true);
Settings.syncGameMode = config.getBoolean("synchronisation_settings.game_mode", true);
Settings.syncAdvancements = config.getBoolean("synchronisation_settings.advancements", true);
Settings.syncLocation = config.getBoolean("synchronisation_settings.location", false);
Settings.syncFlight = config.getBoolean("synchronisation_settings.flight", false);
Settings.useNativeImplementation = config.getBoolean("native_advancement_synchronization", false);
Settings.saveOnWorldSave = config.getBoolean("save_on_world_save", true);
Settings.synchronizationTimeoutRetryDelay = config.getLong("synchronization_timeout_retry_delay", 15L);
}
}

View File

@@ -1,74 +0,0 @@
package net.william278.husksync.bukkit.data;
import java.util.HashMap;
import java.util.HashSet;
import java.util.UUID;
public class BukkitDataCache {
/**
* Map of Player UUIDs to request on join
*/
private static HashSet<UUID> requestOnJoin;
public boolean isPlayerRequestingOnJoin(UUID uuid) {
return requestOnJoin.contains(uuid);
}
public void setRequestOnJoin(UUID uuid) {
requestOnJoin.add(uuid);
}
public void removeRequestOnJoin(UUID uuid) {
requestOnJoin.remove(uuid);
}
/**
* Map of Player UUIDs whose data has not been set yet
*/
private static HashSet<UUID> awaitingDataFetch;
public boolean isAwaitingDataFetch(UUID uuid) {
return awaitingDataFetch.contains(uuid);
}
public void setAwaitingDataFetch(UUID uuid) {
awaitingDataFetch.add(uuid);
}
public void removeAwaitingDataFetch(UUID uuid) {
awaitingDataFetch.remove(uuid);
}
public HashSet<UUID> getAwaitingDataFetch() {
return awaitingDataFetch;
}
/**
* Map of data being viewed by players
*/
private static HashMap<UUID, DataViewer.DataView> viewingPlayerData;
public void setViewing(UUID uuid, DataViewer.DataView dataView) {
viewingPlayerData.put(uuid, dataView);
}
public void removeViewing(UUID uuid) {
viewingPlayerData.remove(uuid);
}
public boolean isViewing(UUID uuid) {
return viewingPlayerData.containsKey(uuid);
}
public DataViewer.DataView getViewing(UUID uuid) {
return viewingPlayerData.get(uuid);
}
// Cache object
public BukkitDataCache() {
requestOnJoin = new HashSet<>();
viewingPlayerData = new HashMap<>();
awaitingDataFetch = new HashSet<>();
}
}

View File

@@ -1,327 +0,0 @@
package net.william278.husksync.bukkit.data;
import net.william278.husksync.redis.RedisMessage;
import org.bukkit.*;
import org.bukkit.advancement.Advancement;
import org.bukkit.advancement.AdvancementProgress;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.bukkit.util.io.BukkitObjectOutputStream;
import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.*;
/**
* Class that contains static methods for serializing and deserializing data from {@link net.william278.husksync.PlayerData}
*/
public class DataSerializer {
/**
* Returns a serialized array of {@link ItemStack}s
*
* @param inventoryContents The contents of the inventory
* @return The serialized inventory contents
*/
public static String serializeInventory(ItemStack[] inventoryContents) {
// Return an empty string if there is no inventory item data to serialize
if (inventoryContents.length == 0) {
return "";
}
// Create an output stream that will be encoded into base 64
ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
try (BukkitObjectOutputStream bukkitOutputStream = new BukkitObjectOutputStream(byteOutputStream)) {
// Define the length of the inventory array to serialize
bukkitOutputStream.writeInt(inventoryContents.length);
// Write each serialize each ItemStack to the output stream
for (ItemStack inventoryItem : inventoryContents) {
bukkitOutputStream.writeObject(serializeItemStack(inventoryItem));
}
// Return encoded data, using the encoder from SnakeYaml to get a ByteArray conversion
return Base64Coder.encodeLines(byteOutputStream.toByteArray());
} catch (IOException e) {
throw new IllegalArgumentException("Failed to serialize item stack data");
}
}
/**
* Returns an array of ItemStacks from serialized inventory data. Note: empty slots will be represented by {@code null}
*
* @param inventoryData The serialized {@link ItemStack[]} array
* @return The inventory contents as an array of {@link ItemStack}s
* @throws IOException If the deserialization fails reading data from the InputStream
* @throws ClassNotFoundException If the deserialization class cannot be found
*/
public static ItemStack[] deserializeInventory(String inventoryData) throws IOException, ClassNotFoundException {
// Return empty array if there is no inventory data (set the player as having an empty inventory)
if (inventoryData.isEmpty()) {
return new ItemStack[0];
}
// Create a byte input stream to read the serialized data
try (ByteArrayInputStream byteInputStream = new ByteArrayInputStream(Base64Coder.decodeLines(inventoryData))) {
try (BukkitObjectInputStream bukkitInputStream = new BukkitObjectInputStream(byteInputStream)) {
// Read the length of the Bukkit input stream and set the length of the array to this value
ItemStack[] inventoryContents = new ItemStack[bukkitInputStream.readInt()];
// Set the ItemStacks in the array from deserialized ItemStack data
int slotIndex = 0;
for (ItemStack ignored : inventoryContents) {
inventoryContents[slotIndex] = deserializeItemStack(bukkitInputStream.readObject());
slotIndex++;
}
// Return the finished, serialized inventory contents
return inventoryContents;
}
}
}
/**
* Returns the serialized version of an {@link ItemStack} as a string to object Map
*
* @param item The {@link ItemStack} to serialize
* @return The serialized {@link ItemStack}
*/
private static Map<String, Object> serializeItemStack(ItemStack item) {
return item != null ? item.serialize() : null;
}
/**
* Returns the deserialized {@link ItemStack} from the Object read from the {@link BukkitObjectInputStream}
*
* @param serializedItemStack The serialized item stack; a String-Object map
* @return The deserialized {@link ItemStack}
*/
@SuppressWarnings("unchecked") // Ignore the "Unchecked cast" warning
private static ItemStack deserializeItemStack(Object serializedItemStack) {
return serializedItemStack != null ? ItemStack.deserialize((Map<String, Object>) serializedItemStack) : null;
}
/**
* Returns a serialized array of {@link PotionEffect}s
*
* @param potionEffects The potion effect array
* @return The serialized potion effects
*/
public static String serializePotionEffects(PotionEffect[] potionEffects) {
// Return an empty string if there are no effects to serialize
if (potionEffects.length == 0) {
return "";
}
// Create an output stream that will be encoded into base 64
ByteArrayOutputStream byteOutputStream = new ByteArrayOutputStream();
try (BukkitObjectOutputStream bukkitOutputStream = new BukkitObjectOutputStream(byteOutputStream)) {
// Define the length of the potion effect array to serialize
bukkitOutputStream.writeInt(potionEffects.length);
// Write each serialize each PotionEffect to the output stream
for (PotionEffect potionEffect : potionEffects) {
bukkitOutputStream.writeObject(serializePotionEffect(potionEffect));
}
// Return encoded data, using the encoder from SnakeYaml to get a ByteArray conversion
return Base64Coder.encodeLines(byteOutputStream.toByteArray());
} catch (IOException e) {
throw new IllegalArgumentException("Failed to serialize potion effect data");
}
}
/**
* Returns an array of ItemStacks from serialized potion effect data
*
* @param potionEffectData The serialized {@link PotionEffect[]} array
* @return The {@link PotionEffect}s
* @throws IOException If the deserialization fails reading data from the InputStream
* @throws ClassNotFoundException If the deserialization class cannot be found
*/
public static PotionEffect[] deserializePotionEffects(String potionEffectData) throws IOException, ClassNotFoundException {
// Return empty array if there is no potion effect data (don't apply any effects to the player)
if (potionEffectData.isEmpty()) {
return new PotionEffect[0];
}
// Create a byte input stream to read the serialized data
try (ByteArrayInputStream byteInputStream = new ByteArrayInputStream(Base64Coder.decodeLines(potionEffectData))) {
try (BukkitObjectInputStream bukkitInputStream = new BukkitObjectInputStream(byteInputStream)) {
// Read the length of the Bukkit input stream and set the length of the array to this value
PotionEffect[] potionEffects = new PotionEffect[bukkitInputStream.readInt()];
// Set the potion effects in the array from deserialized PotionEffect data
int potionIndex = 0;
for (PotionEffect ignored : potionEffects) {
potionEffects[potionIndex] = deserializePotionEffect(bukkitInputStream.readObject());
potionIndex++;
}
// Return the finished, serialized potion effect array
return potionEffects;
}
}
}
/**
* Returns the serialized version of an {@link ItemStack} as a string to object Map
*
* @param potionEffect The {@link ItemStack} to serialize
* @return The serialized {@link ItemStack}
*/
private static Map<String, Object> serializePotionEffect(PotionEffect potionEffect) {
return potionEffect != null ? potionEffect.serialize() : null;
}
/**
* Returns the deserialized {@link PotionEffect} from the Object read from the {@link BukkitObjectInputStream}
*
* @param serializedPotionEffect The serialized potion effect; a String-Object map
* @return The deserialized {@link PotionEffect}
*/
@SuppressWarnings("unchecked") // Ignore the "Unchecked cast" warning
private static PotionEffect deserializePotionEffect(Object serializedPotionEffect) {
return serializedPotionEffect != null ? new PotionEffect((Map<String, Object>) serializedPotionEffect) : null;
}
public static me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation deserializePlayerLocationData(String serializedLocationData) throws IOException {
if (serializedLocationData.isEmpty()) {
return null;
}
try {
return (me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation) RedisMessage.deserialize(serializedLocationData);
} catch (ClassNotFoundException e) {
throw new IOException("Unable to decode class type.", e);
}
}
public static String getSerializedLocation(Player player) throws IOException {
final Location playerLocation = player.getLocation();
return RedisMessage.serialize(new me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation(playerLocation.getX(), playerLocation.getY(), playerLocation.getZ(),
playerLocation.getYaw(), playerLocation.getPitch(), player.getWorld().getName(), player.getWorld().getEnvironment()));
}
/**
* Deserializes a player's advancement data as serialized with {@link #getSerializedAdvancements(Player)} into {@link me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate} data.
*
* @param serializedAdvancementData The serialized advancement data {@link String}
* @return The deserialized {@link me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate} for the player
* @throws IOException If the deserialization fails
*/
@SuppressWarnings("unchecked") // Ignore the unchecked cast here
public static List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> deserializeAdvancementData(String serializedAdvancementData) throws IOException {
if (serializedAdvancementData.isEmpty()) {
return new ArrayList<>();
}
try {
List<?> deserialize = (List<?>) RedisMessage.deserialize(serializedAdvancementData);
// Migrate old AdvancementRecord into date format
if (!deserialize.isEmpty() && deserialize.get(0) instanceof me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecord) {
deserialize = ((List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecord>) deserialize).stream()
.map(o -> new me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate(
o.advancementKey(),
o.awardedAdvancementCriteria()
)).toList();
}
return (List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate>) deserialize;
} catch (ClassNotFoundException e) {
throw new IOException("Unable to decode class type.", e);
}
}
/**
* Returns a serialized {@link String} of a player's advancements that can be deserialized with {@link #deserializeStatisticData(String)}
*
* @param player {@link Player} to serialize advancement data of
* @return The serialized advancement data as a {@link String}
* @throws IOException If the serialization fails
*/
public static String getSerializedAdvancements(Player player) throws IOException {
Iterator<Advancement> serverAdvancements = Bukkit.getServer().advancementIterator();
ArrayList<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementData = new ArrayList<>();
while (serverAdvancements.hasNext()) {
final AdvancementProgress progress = player.getAdvancementProgress(serverAdvancements.next());
final NamespacedKey advancementKey = progress.getAdvancement().getKey();
final Map<String, Date> awardedCriteria = new HashMap<>();
progress.getAwardedCriteria().forEach(s -> awardedCriteria.put(s, progress.getDateAwarded(s)));
advancementData.add(new me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate(advancementKey.getNamespace() + ":" + advancementKey.getKey(), awardedCriteria));
}
return RedisMessage.serialize(advancementData);
}
/**
* Deserializes a player's statistic data as serialized with {@link #getSerializedStatisticData(Player)} into {@link me.william278.husksync.bukkit.data.DataSerializer.StatisticData}.
*
* @param serializedStatisticData The serialized statistic data {@link String}
* @return The deserialized {@link me.william278.husksync.bukkit.data.DataSerializer.StatisticData} for the player
* @throws IOException If the deserialization fails
*/
public static me.william278.husksync.bukkit.data.DataSerializer.StatisticData deserializeStatisticData(String serializedStatisticData) throws IOException {
if (serializedStatisticData.isEmpty()) {
return new me.william278.husksync.bukkit.data.DataSerializer.StatisticData(new HashMap<>(), new HashMap<>(), new HashMap<>(), new HashMap<>());
}
try {
return (me.william278.husksync.bukkit.data.DataSerializer.StatisticData) RedisMessage.deserialize(serializedStatisticData);
} catch (ClassNotFoundException e) {
throw new IOException("Unable to decode class type.", e);
}
}
/**
* Returns a serialized {@link String} of a player's statistic data that can be deserialized with {@link #deserializeStatisticData(String)}
*
* @param player {@link Player} to serialize statistic data of
* @return The serialized statistic data as a {@link String}
* @throws IOException If the serialization fails
*/
public static String getSerializedStatisticData(Player player) throws IOException {
HashMap<Statistic, Integer> untypedStatisticValues = new HashMap<>();
HashMap<Statistic, HashMap<Material, Integer>> blockStatisticValues = new HashMap<>();
HashMap<Statistic, HashMap<Material, Integer>> itemStatisticValues = new HashMap<>();
HashMap<Statistic, HashMap<EntityType, Integer>> entityStatisticValues = new HashMap<>();
for (Statistic statistic : Statistic.values()) {
switch (statistic.getType()) {
case ITEM -> {
HashMap<Material, Integer> itemValues = new HashMap<>();
for (Material itemMaterial : Arrays.stream(Material.values()).filter(Material::isItem).toList()) {
itemValues.put(itemMaterial, player.getStatistic(statistic, itemMaterial));
}
itemStatisticValues.put(statistic, itemValues);
}
case BLOCK -> {
HashMap<Material, Integer> blockValues = new HashMap<>();
for (Material blockMaterial : Arrays.stream(Material.values()).filter(Material::isBlock).toList()) {
blockValues.put(blockMaterial, player.getStatistic(statistic, blockMaterial));
}
blockStatisticValues.put(statistic, blockValues);
}
case ENTITY -> {
HashMap<EntityType, Integer> entityValues = new HashMap<>();
for (EntityType type : Arrays.stream(EntityType.values()).filter(EntityType::isAlive).toList()) {
entityValues.put(type, player.getStatistic(statistic, type));
}
entityStatisticValues.put(statistic, entityValues);
}
case UNTYPED -> untypedStatisticValues.put(statistic, player.getStatistic(statistic));
}
}
me.william278.husksync.bukkit.data.DataSerializer.StatisticData statisticData = new me.william278.husksync.bukkit.data.DataSerializer.StatisticData(untypedStatisticValues, blockStatisticValues, itemStatisticValues, entityStatisticValues);
return RedisMessage.serialize(statisticData);
}
}

View File

@@ -1,115 +0,0 @@
package net.william278.husksync.bukkit.data;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.william278.husksync.redis.RedisMessage;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import java.io.IOException;
/**
* Class used for managing viewing inventories using inventory-see command
*/
public class DataViewer {
/**
* Show a viewer's data to a viewer
*
* @param viewer The viewing {@link Player} who will see the data
* @param data The {@link DataView} to show the viewer
* @throws IOException If an exception occurred deserializing item data
*/
public static void showData(Player viewer, DataView data) throws IOException, ClassNotFoundException {
// Show an inventory with the viewer's inventory and equipment
viewer.closeInventory();
viewer.openInventory(createInventory(viewer, data));
// Set the viewer as viewing
HuskSyncBukkit.bukkitCache.setViewing(viewer.getUniqueId(), data);
}
/**
* Handles what happens after a data viewer finishes viewing data
*
* @param viewer The viewing {@link Player} who was looking at data
* @param inventory The {@link Inventory} that was being viewed
* @throws IOException If an exception occurred serializing item data
*/
public static void stopShowing(Player viewer, Inventory inventory) throws IOException {
// Get the DataView the player was looking at
DataView dataView = HuskSyncBukkit.bukkitCache.getViewing(viewer.getUniqueId());
// Set the player as no longer viewing an inventory
HuskSyncBukkit.bukkitCache.removeViewing(viewer.getUniqueId());
// Get and update the PlayerData with the new item data
PlayerData playerData = dataView.playerData();
String serializedItemData = DataSerializer.serializeInventory(inventory.getContents());
switch (dataView.inventoryType()) {
case INVENTORY -> playerData.setSerializedInventory(serializedItemData);
case ENDER_CHEST -> playerData.setSerializedEnderChest(serializedItemData);
}
// Send a redis message with the updated data after the viewing
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
RedisMessage.serialize(playerData), Boolean.toString(true))
.send();
}
/**
* Creates the inventory object that the viewer will see
*
* @param viewer The {@link Player} who will view the data
* @param data The {@link DataView} data to view
* @return The {@link Inventory} that the viewer will see
* @throws IOException If an exception occurred deserializing item data
*/
private static Inventory createInventory(Player viewer, DataView data) throws IOException, ClassNotFoundException {
Inventory inventory = switch (data.inventoryType) {
case INVENTORY -> Bukkit.createInventory(viewer, 45, data.ownerName + "'s Inventory");
case ENDER_CHEST -> Bukkit.createInventory(viewer, 27, data.ownerName + "'s Ender Chest");
};
PlayerSetter.setInventory(inventory, data.getDeserializedData());
return inventory;
}
/**
* Represents Player Data being viewed by a {@link Player}
*/
public record DataView(PlayerData playerData, String ownerName, InventoryType inventoryType) {
/**
* What kind of item data is being viewed
*/
public enum InventoryType {
/**
* A player's inventory
*/
INVENTORY,
/**
* A player's ender chest
*/
ENDER_CHEST
}
/**
* Gets the deserialized data currently being viewed
*
* @return The deserialized item data, as an {@link ItemStack[]} array
* @throws IOException If an exception occurred deserializing item data
*/
public ItemStack[] getDeserializedData() throws IOException, ClassNotFoundException {
return switch (inventoryType) {
case INVENTORY -> DataSerializer.deserializeInventory(playerData.getSerializedInventory());
case ENDER_CHEST -> DataSerializer.deserializeInventory(playerData.getSerializedEnderChest());
};
}
}
}

View File

@@ -1,38 +0,0 @@
package net.william278.husksync.bukkit.events;
import net.william278.husksync.PlayerData;
import org.bukkit.entity.Player;
import org.bukkit.event.HandlerList;
import org.bukkit.event.player.PlayerEvent;
import org.jetbrains.annotations.NotNull;
/**
* Represents an event that will be fired when a {@link Player} has finished being synchronised with the correct {@link PlayerData}.
*/
public class SyncCompleteEvent extends PlayerEvent {
private static final HandlerList HANDLER_LIST = new HandlerList();
private final PlayerData data;
public SyncCompleteEvent(Player player, PlayerData data) {
super(player);
this.data = data;
}
/**
* Returns the {@link PlayerData} which has just been set on the {@link Player}
* @return The {@link PlayerData} that has been set
*/
public PlayerData getData() {
return data;
}
@Override
public @NotNull HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}

View File

@@ -1,70 +0,0 @@
package net.william278.husksync.bukkit.events;
import net.william278.husksync.PlayerData;
import org.bukkit.entity.Player;
import org.bukkit.event.Cancellable;
import org.bukkit.event.HandlerList;
import org.bukkit.event.player.PlayerEvent;
import org.jetbrains.annotations.NotNull;
/**
* Represents an event that will be fired before a {@link Player} is about to be synchronised with their {@link PlayerData}.
*/
public class SyncEvent extends PlayerEvent implements Cancellable {
private boolean cancelled;
private static final HandlerList HANDLER_LIST = new HandlerList();
private PlayerData data;
public SyncEvent(Player player, PlayerData data) {
super(player);
this.data = data;
}
/**
* Returns the {@link PlayerData} which has just been set on the {@link Player}
*
* @return The {@link PlayerData} that has been set
*/
public PlayerData getData() {
return data;
}
/**
* Sets the {@link PlayerData} to be synchronised to this player
*
* @param data The {@link PlayerData} to set to the player
*/
public void setData(PlayerData data) {
this.data = data;
}
@Override
public @NotNull HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
/**
* Gets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins
*
* @return true if this event is cancelled
*/
@Override
public boolean isCancelled() {
return cancelled;
}
/**
* Sets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins.
*
* @param cancel true if you wish to cancel this event
*/
@Override
public void setCancelled(boolean cancel) {
this.cancelled = cancel;
}
}

View File

@@ -1,166 +0,0 @@
package net.william278.husksync.bukkit.listener;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.data.DataViewer;
import net.william278.husksync.bukkit.util.PlayerSetter;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.inventory.InventoryCloseEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.player.*;
import org.bukkit.event.world.WorldSaveEvent;
import java.io.IOException;
import java.util.logging.Level;
public class BukkitEventListener implements Listener {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
@EventHandler(priority = EventPriority.LOWEST)
public void onPlayerQuit(PlayerQuitEvent event) {
// When a player leaves a Bukkit server
final Player player = event.getPlayer();
// If the player was awaiting data fetch, remove them and prevent data from being overwritten
if (HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(player.getUniqueId())) {
HuskSyncBukkit.bukkitCache.removeAwaitingDataFetch(player.getUniqueId());
return;
}
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled)
return; // If the plugin has not been initialized correctly
// Update the player's data
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
// Update data to proxy
PlayerSetter.updatePlayerData(player, true);
// Clear player inventory and ender chest
player.getInventory().clear();
player.getEnderChest().clear();
});
}
@EventHandler(priority = EventPriority.LOWEST)
public void onPlayerJoin(PlayerJoinEvent event) {
if (!plugin.isEnabled()) return; // If the plugin has not been initialized correctly
// When a player joins a Bukkit server
final Player player = event.getPlayer();
// Mark the player as awaiting data fetch
HuskSyncBukkit.bukkitCache.setAwaitingDataFetch(player.getUniqueId());
if (!HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled) {
return; // If the data handshake has not been completed yet (or MySqlPlayerDataBridge is installed)
}
// Send a redis message requesting the player data (if they need to)
if (HuskSyncBukkit.bukkitCache.isPlayerRequestingOnJoin(player.getUniqueId())) {
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
PlayerSetter.requestPlayerData(player.getUniqueId());
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to send a PlayerData fetch request", e);
}
});
} else {
// If the player's data wasn't set after the synchronization timeout retry delay ticks, ensure it will be
Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, () -> {
if (player.isOnline()) {
try {
if (HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(player.getUniqueId())) {
PlayerSetter.requestPlayerData(player.getUniqueId());
}
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to send a PlayerData fetch request", e);
}
}
}, Settings.synchronizationTimeoutRetryDelay);
}
}
@EventHandler
public void onInventoryClose(InventoryCloseEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId()))
return; // If the plugin has not been initialized correctly
// When a player closes an Inventory
final Player player = (Player) event.getPlayer();
// Handle a player who has finished viewing a player's item data
if (HuskSyncBukkit.bukkitCache.isViewing(player.getUniqueId())) {
try {
DataViewer.stopShowing(player, event.getInventory());
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to serialize updated item data", e);
}
}
}
/*
* Events to cancel if the player has not been set yet
*/
@EventHandler(priority = EventPriority.HIGHEST)
public void onDropItem(PlayerDropItemEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPickupItem(EntityPickupItemEvent event) {
if (event.getEntity() instanceof Player player) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(player.getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onPlayerInteract(PlayerInteractEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onBlockPlace(BlockPlaceEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onBlockBreak(BlockBreakEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.HIGHEST)
public void onInventoryOpen(InventoryOpenEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted || HuskSyncBukkit.bukkitCache.isAwaitingDataFetch(event.getPlayer().getUniqueId())) {
event.setCancelled(true); // If the plugin / player has not been set
}
}
@EventHandler(priority = EventPriority.NORMAL)
public void onWorldSave(WorldSaveEvent event) {
if (!plugin.isEnabled() || !HuskSyncBukkit.handshakeCompleted) {
return;
}
for (Player playerInWorld : event.getWorld().getPlayers()) {
PlayerSetter.updatePlayerData(playerInWorld, false);
}
}
}

View File

@@ -1,221 +0,0 @@
package net.william278.husksync.bukkit.listener;
import de.themoep.minedown.MineDown;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.config.ConfigLoader;
import net.william278.husksync.bukkit.data.DataViewer;
import net.william278.husksync.bukkit.migrator.MPDBDeserializer;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.william278.husksync.migrator.MPDBPlayerData;
import net.william278.husksync.redis.RedisListener;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.MessageManager;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import java.io.IOException;
import java.util.HashMap;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.logging.Level;
public class BukkitRedisListener extends RedisListener {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
public static HashMap<UUID, CompletableFuture<PlayerData>> apiRequests = new HashMap<>();
// Initialize the listener on the bukkit server
public BukkitRedisListener() {
super();
listen();
}
/**
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link RedisMessage} to handle
*/
@Override
public void handleMessage(RedisMessage message) {
// Ignore messages for proxy servers
if (!message.getMessageTarget().targetServerType().equals(Settings.ServerType.BUKKIT)) {
return;
}
// Ignore messages if the plugin is disabled
if (!plugin.isEnabled()) {
return;
}
// Ignore messages for other clusters if applicable
final String targetClusterId = message.getMessageTarget().targetClusterId();
if (targetClusterId != null) {
if (!targetClusterId.equalsIgnoreCase(Settings.cluster)) {
return;
}
}
// Handle the incoming redis message; either for a specific player or the system
if (message.getMessageTarget().targetPlayerUUID() == null) {
switch (message.getMessageType()) {
case REQUEST_DATA_ON_JOIN -> {
UUID playerUUID = UUID.fromString(message.getMessageDataElements()[1]);
switch (RedisMessage.RequestOnJoinUpdateType.valueOf(message.getMessageDataElements()[0])) {
case ADD_REQUESTER -> HuskSyncBukkit.bukkitCache.setRequestOnJoin(playerUUID);
case REMOVE_REQUESTER -> HuskSyncBukkit.bukkitCache.removeRequestOnJoin(playerUUID);
}
}
case CONNECTION_HANDSHAKE -> {
UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
String proxyBrand = message.getMessageDataElements()[1];
if (serverUUID.equals(HuskSyncBukkit.serverUUID)) {
HuskSyncBukkit.handshakeCompleted = true;
log(Level.INFO, "Completed handshake with " + proxyBrand + " proxy (" + serverUUID + ")");
// If there are any players awaiting a data update, request it
for (UUID uuid : HuskSyncBukkit.bukkitCache.getAwaitingDataFetch()) {
try {
PlayerSetter.requestPlayerData(uuid);
} catch (IOException e) {
log(Level.SEVERE, "Failed to serialize handshake message data");
}
}
}
}
case TERMINATE_HANDSHAKE -> {
UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
String proxyBrand = message.getMessageDataElements()[1];
if (serverUUID.equals(HuskSyncBukkit.serverUUID)) {
HuskSyncBukkit.handshakeCompleted = false;
log(Level.WARNING, proxyBrand + " proxy has terminated communications; attempting to re-establish (" + serverUUID + ")");
// Attempt to re-establish communications via another handshake
Bukkit.getScheduler().runTaskLaterAsynchronously(plugin, HuskSyncBukkit::establishRedisHandshake, 20);
}
}
case DECODE_MPDB_DATA -> {
UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
String encodedData = message.getMessageDataElements()[1];
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
if (serverUUID.equals(HuskSyncBukkit.serverUUID)) {
try {
MPDBPlayerData data = (MPDBPlayerData) RedisMessage.deserialize(encodedData);
new RedisMessage(RedisMessage.MessageType.DECODED_MPDB_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
RedisMessage.serialize(MPDBDeserializer.convertMPDBData(data)),
data.playerName)
.send();
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to serialize encoded MPDB data");
}
}
});
}
case API_DATA_RETURN -> {
final UUID requestUUID = UUID.fromString(message.getMessageDataElements()[0]);
if (apiRequests.containsKey(requestUUID)) {
try {
final PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageDataElements()[1]);
apiRequests.get(requestUUID).complete(data);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to serialize returned API-requested player data");
}
}
}
case API_DATA_CANCEL -> {
final UUID requestUUID = UUID.fromString(message.getMessageDataElements()[0]);
// Cancel requests if no data could be found on the proxy
if (apiRequests.containsKey(requestUUID)) {
apiRequests.get(requestUUID).cancel(true);
}
}
case RELOAD_CONFIG -> {
plugin.reloadConfig();
ConfigLoader.loadSettings(plugin.getConfig());
}
}
} else {
for (Player player : Bukkit.getOnlinePlayers()) {
if (player.getUniqueId().equals(message.getMessageTarget().targetPlayerUUID())) {
switch (message.getMessageType()) {
case PLAYER_DATA_SET -> {
if (HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled) return;
try {
// Deserialize the received PlayerData
PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageData());
// Set the player's data
PlayerSetter.setPlayerFrom(player, data);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling data from the proxy");
e.printStackTrace();
}
}
case SEND_PLUGIN_INFORMATION -> {
String proxyBrand = message.getMessageDataElements()[0];
String proxyVersion = message.getMessageDataElements()[1];
assert plugin.getDescription().getDescription() != null;
player.spigot().sendMessage(new MineDown(MessageManager.PLUGIN_INFORMATION.toString()
.replaceAll("%plugin_description%", plugin.getDescription().getDescription())
.replaceAll("%proxy_brand%", proxyBrand)
.replaceAll("%proxy_version%", proxyVersion)
.replaceAll("%bukkit_brand%", Bukkit.getName())
.replaceAll("%bukkit_version%", plugin.getDescription().getVersion()))
.toComponent());
}
case OPEN_INVENTORY -> {
// Get the name of the inventory owner
String inventoryOwnerName = message.getMessageDataElements()[0];
// Synchronously do inventory setting, etc
Bukkit.getScheduler().runTask(plugin, () -> {
try {
// Get that player's data
PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageDataElements()[1]);
// Show the data to the player
DataViewer.showData(player, new DataViewer.DataView(data, inventoryOwnerName, DataViewer.DataView.InventoryType.INVENTORY));
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling inventory-see data from the proxy");
e.printStackTrace();
}
});
}
case OPEN_ENDER_CHEST -> {
// Get the name of the inventory owner
String enderChestOwnerName = message.getMessageDataElements()[0];
// Synchronously do inventory setting, etc
Bukkit.getScheduler().runTask(plugin, () -> {
try {
// Get that player's data
PlayerData data = (PlayerData) RedisMessage.deserialize(message.getMessageDataElements()[1]);
// Show the data to the player
DataViewer.showData(player, new DataViewer.DataView(data, enderChestOwnerName, DataViewer.DataView.InventoryType.ENDER_CHEST));
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling ender chest-see data from the proxy");
e.printStackTrace();
}
});
}
}
return;
}
}
}
}
/**
* Log to console
*
* @param level The {@link Level} to log
* @param message Message to log
*/
@Override
public void log(Level level, String message) {
plugin.getLogger().log(level, message);
}
}

View File

@@ -1,87 +0,0 @@
package net.william278.husksync.bukkit.migrator;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.bukkit.data.DataSerializer;
import net.william278.husksync.bukkit.util.PlayerSetter;
import net.william278.husksync.migrator.MPDBPlayerData;
import net.william278.mpdbconverter.MPDBConverter;
import org.bukkit.Bukkit;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.Plugin;
import java.util.logging.Level;
public class MPDBDeserializer {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
// Instance of MySqlPlayerDataBridge
private static MPDBConverter mpdbConverter;
public static void setMySqlPlayerDataBridge() {
Plugin mpdbPlugin = Bukkit.getPluginManager().getPlugin("MySqlPlayerDataBridge");
assert mpdbPlugin != null;
mpdbConverter = MPDBConverter.getInstance(mpdbPlugin);
}
/**
* Convert MySqlPlayerDataBridge ({@link MPDBPlayerData}) data to HuskSync's {@link PlayerData}
*
* @param mpdbPlayerData The {@link MPDBPlayerData} to convert
* @return The converted {@link PlayerData}
*/
public static PlayerData convertMPDBData(MPDBPlayerData mpdbPlayerData) {
PlayerData playerData = PlayerData.DEFAULT_PLAYER_DATA(mpdbPlayerData.playerUUID);
playerData.useDefaultData = false;
if (!HuskSyncBukkit.isMySqlPlayerDataBridgeInstalled) {
plugin.getLogger().log(Level.SEVERE, "MySqlPlayerDataBridge is not installed, failed to serialize data!");
return null;
}
// Convert the data
try {
// Set inventory contents
Inventory inventory = Bukkit.createInventory(null, InventoryType.PLAYER);
if (!mpdbPlayerData.inventoryData.isEmpty() && !mpdbPlayerData.inventoryData.equalsIgnoreCase("none")) {
PlayerSetter.setInventory(inventory, mpdbConverter.getItemStackFromSerializedData(mpdbPlayerData.inventoryData));
}
// Set armor (if there is data; MPDB stores empty data with literally the word "none". Obviously.)
int armorSlot = 36;
if (!mpdbPlayerData.armorData.isEmpty() && !mpdbPlayerData.armorData.equalsIgnoreCase("none")) {
ItemStack[] armorItems = mpdbConverter.getItemStackFromSerializedData(mpdbPlayerData.armorData);
for (ItemStack armorPiece : armorItems) {
if (armorPiece != null) {
inventory.setItem(armorSlot, armorPiece);
}
armorSlot++;
}
}
// Now apply the contents and clear the temporary inventory variable
playerData.setSerializedInventory(DataSerializer.serializeInventory(inventory.getContents()));
// Set ender chest (again, if there is data)
ItemStack[] enderChestData;
if (!mpdbPlayerData.enderChestData.isEmpty() && !mpdbPlayerData.enderChestData.equalsIgnoreCase("none")) {
enderChestData = mpdbConverter.getItemStackFromSerializedData(mpdbPlayerData.enderChestData);
} else {
enderChestData = new ItemStack[0];
}
playerData.setSerializedEnderChest(DataSerializer.serializeInventory(enderChestData));
// Set experience
playerData.setExpLevel(mpdbPlayerData.expLevel);
playerData.setExpProgress(mpdbPlayerData.expProgress);
playerData.setTotalExperience(mpdbPlayerData.totalExperience);
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING, "Failed to convert MPDB data to HuskSync's format!");
e.printStackTrace();
}
return playerData;
}
}

View File

@@ -1,20 +0,0 @@
package net.william278.husksync.bukkit.util;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.util.UpdateChecker;
import java.util.logging.Level;
public class BukkitUpdateChecker extends UpdateChecker {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
public BukkitUpdateChecker() {
super(plugin.getDescription().getVersion());
}
@Override
public void log(Level level, String message) {
plugin.getLogger().log(level, message);
}
}

View File

@@ -1,488 +0,0 @@
package net.william278.husksync.bukkit.util;
import net.william278.husksync.HuskSyncBukkit;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.bukkit.events.SyncCompleteEvent;
import net.william278.husksync.bukkit.events.SyncEvent;
import net.william278.husksync.bukkit.data.DataSerializer;
import net.william278.husksync.bukkit.util.nms.AdvancementUtils;
import net.william278.husksync.redis.RedisMessage;
import org.bukkit.*;
import org.bukkit.advancement.Advancement;
import org.bukkit.advancement.AdvancementProgress;
import org.bukkit.attribute.Attribute;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.potion.PotionEffect;
import org.bukkit.potion.PotionEffectType;
import java.io.IOException;
import java.util.*;
import java.util.logging.Level;
public class PlayerSetter {
private static final HuskSyncBukkit plugin = HuskSyncBukkit.getInstance();
/**
* Returns the new serialized PlayerData for a player.
*
* @param player The {@link Player} to get the new serialized PlayerData for
* @return The {@link PlayerData}, serialized as a {@link String}
* @throws IOException If the serialization fails
*/
private static String getNewSerializedPlayerData(Player player) throws IOException {
final double maxHealth = getMaxHealth(player); // Get the player's max health (used to determine health as well)
return RedisMessage.serialize(new PlayerData(player.getUniqueId(),
DataSerializer.serializeInventory(player.getInventory().getContents()),
DataSerializer.serializeInventory(player.getEnderChest().getContents()),
Math.min(player.getHealth(), maxHealth),
maxHealth,
player.isHealthScaled() ? player.getHealthScale() : 0D,
player.getFoodLevel(),
player.getSaturation(),
player.getExhaustion(),
player.getInventory().getHeldItemSlot(),
DataSerializer.serializePotionEffects(getPlayerPotionEffects(player)),
player.getTotalExperience(),
player.getLevel(),
player.getExp(),
player.getGameMode().toString(),
DataSerializer.getSerializedStatisticData(player),
player.isFlying(),
DataSerializer.getSerializedAdvancements(player),
DataSerializer.getSerializedLocation(player)));
}
/**
* Returns a {@link Player}'s maximum health, minus any health boost effects
*
* @param player The {@link Player} to get the maximum health of
* @return The {@link Player}'s max health
*/
private static double getMaxHealth(Player player) {
double maxHealth = Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH)).getBaseValue();
// If the player has additional health bonuses from synchronised potion effects, subtract these from this number as they are synchronised separately
if (player.hasPotionEffect(PotionEffectType.HEALTH_BOOST) && maxHealth > 20D) {
PotionEffect healthBoostEffect = player.getPotionEffect(PotionEffectType.HEALTH_BOOST);
assert healthBoostEffect != null;
double healthBoostBonus = 4 * (healthBoostEffect.getAmplifier() + 1);
maxHealth -= healthBoostBonus;
}
return maxHealth;
}
/**
* Returns a {@link Player}'s active potion effects in a {@link PotionEffect} array
*
* @param player The {@link Player} to get the effects of
* @return The {@link PotionEffect} array
*/
private static PotionEffect[] getPlayerPotionEffects(Player player) {
PotionEffect[] potionEffects = new PotionEffect[player.getActivePotionEffects().size()];
int arrayIndex = 0;
for (PotionEffect effect : player.getActivePotionEffects()) {
potionEffects[arrayIndex] = effect;
arrayIndex++;
}
return potionEffects;
}
/**
* Update a {@link Player}'s data, sending it to the proxy
*
* @param player {@link Player} to send data to proxy
* @param bounceBack whether the plugin should bounce-back the updated data to the player (used for server switching)
*/
public static void updatePlayerData(Player player, boolean bounceBack) {
// Send a redis message with the player's last updated PlayerData version UUID and their new PlayerData
try {
final String serializedPlayerData = getNewSerializedPlayerData(player);
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_UPDATE,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
serializedPlayerData, Boolean.toString(bounceBack)).send();
} catch (IOException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to send a PlayerData update to the proxy", e);
}
}
/**
* Request a {@link Player}'s data from the proxy
*
* @param playerUUID The {@link UUID} of the {@link Player} to fetch PlayerData from
* @throws IOException If the request Redis message data fails to serialize
*/
public static void requestPlayerData(UUID playerUUID) throws IOException {
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_REQUEST,
new RedisMessage.MessageTarget(Settings.ServerType.PROXY, null, Settings.cluster),
playerUUID.toString()).send();
}
/**
* Set a player from their PlayerData, based on settings
*
* @param player The {@link Player} to set
* @param dataToSet The {@link PlayerData} to assign to the player
*/
public static void setPlayerFrom(Player player, PlayerData dataToSet) {
Bukkit.getScheduler().runTask(plugin, () -> {
// Handle the SyncEvent
SyncEvent syncEvent = new SyncEvent(player, dataToSet);
Bukkit.getPluginManager().callEvent(syncEvent);
final PlayerData data = syncEvent.getData();
if (syncEvent.isCancelled()) {
return;
}
// If the data is flagged as being default data, skip setting
if (data.useDefaultData) {
HuskSyncBukkit.bukkitCache.removeAwaitingDataFetch(player.getUniqueId());
return;
}
// Clear player
player.getInventory().clear();
player.getEnderChest().clear();
player.setExp(0);
player.setLevel(0);
HuskSyncBukkit.bukkitCache.removeAwaitingDataFetch(player.getUniqueId());
// Set the player's data from the PlayerData
try {
// Don't sync the player if they are dead
if (player.isDead() || player.getHealth() <= 0) {
return;
}
if (Settings.syncAdvancements) {
List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementRecords
= DataSerializer.deserializeAdvancementData(data.getSerializedAdvancements());
if (Settings.useNativeImplementation) {
try {
nativeSyncPlayerAdvancements(player, advancementRecords);
} catch (Exception e) {
plugin.getLogger().log(Level.WARNING,
"Your server does not support a native implementation of achievements synchronization");
plugin.getLogger().log(Level.WARNING,
"Your server version is {0}. Please disable using native implementation!", Bukkit.getVersion());
Settings.useNativeImplementation = false;
setPlayerAdvancements(player, advancementRecords, data);
plugin.getLogger().log(Level.SEVERE, e.getMessage(), e);
}
} else {
setPlayerAdvancements(player, advancementRecords, data);
}
}
// Don't sync the player if they are dead
if (player.isDead() || player.getHealth() <= 0) {
Bukkit.getPluginManager().callEvent(new SyncCompleteEvent(player, data));
return;
}
if (Settings.syncInventories) {
setPlayerInventory(player, DataSerializer.deserializeInventory(data.getSerializedInventory()));
player.getInventory().setHeldItemSlot(data.getSelectedSlot());
}
if (Settings.syncEnderChests) {
setPlayerEnderChest(player, DataSerializer.deserializeInventory(data.getSerializedEnderChest()));
}
if (Settings.syncHealth) {
setPlayerHealth(player, data.getHealth(), data.getMaxHealth(), data.getHealthScale());
}
if (Settings.syncHunger) {
player.setFoodLevel(data.getHunger());
player.setSaturation(data.getSaturation());
player.setExhaustion(data.getSaturationExhaustion());
}
if (Settings.syncExperience) {
// This is also handled when syncing advancements to ensure its correct
setPlayerExperience(player, data);
}
if (Settings.syncPotionEffects) {
setPlayerPotionEffects(player, DataSerializer.deserializePotionEffects(data.getSerializedEffectData()));
}
if (Settings.syncStatistics) {
setPlayerStatistics(player, DataSerializer.deserializeStatisticData(data.getSerializedStatistics()));
}
if (Settings.syncGameMode) {
player.setGameMode(GameMode.valueOf(data.getGameMode()));
}
if (Settings.syncLocation) {
setPlayerLocation(player, DataSerializer.deserializePlayerLocationData(data.getSerializedLocation()));
}
if (Settings.syncFlight) {
if (data.isFlying()) {
player.setAllowFlight(true);
}
player.setFlying(player.getAllowFlight() && data.isFlying());
}
// Handle the SyncCompleteEvent
Bukkit.getPluginManager().callEvent(new SyncCompleteEvent(player, data));
} catch (IOException | ClassNotFoundException e) {
plugin.getLogger().log(Level.SEVERE, "Failed to deserialize PlayerData", e);
}
});
}
/**
* Sets a player's ender chest from a set of {@link ItemStack}s
*
* @param player The player to set the inventory of
* @param items The array of {@link ItemStack}s to set
*/
private static void setPlayerEnderChest(Player player, ItemStack[] items) {
setInventory(player.getEnderChest(), items);
}
/**
* Sets a player's inventory from a set of {@link ItemStack}s
*
* @param player The player to set the inventory of
* @param items The array of {@link ItemStack}s to set
*/
private static void setPlayerInventory(Player player, ItemStack[] items) {
setInventory(player.getInventory(), items);
}
/**
* Sets an inventory's contents from an array of {@link ItemStack}s
*
* @param inventory The inventory to set
* @param items The {@link ItemStack}s to fill it with
*/
public static void setInventory(Inventory inventory, ItemStack[] items) {
inventory.clear();
int index = 0;
for (ItemStack item : items) {
if (item != null) {
inventory.setItem(index, item);
}
index++;
}
}
/**
* Set a player's current potion effects from a set of {@link PotionEffect[]}
*
* @param player The player to set the potion effects of
* @param effects The array of {@link PotionEffect}s to set
*/
private static void setPlayerPotionEffects(Player player, PotionEffect[] effects) {
for (PotionEffect effect : player.getActivePotionEffects()) {
player.removePotionEffect(effect.getType());
}
for (PotionEffect effect : effects) {
player.addPotionEffect(effect);
}
}
private static void nativeSyncPlayerAdvancements(final Player player, final List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementRecords) {
final Object playerAdvancements = AdvancementUtils.getPlayerAdvancements(player);
// Clear
AdvancementUtils.clearPlayerAdvancements(playerAdvancements);
AdvancementUtils.clearVisibleAdvancements(playerAdvancements);
advancementRecords.forEach(advancementRecord -> {
NamespacedKey namespacedKey = Objects.requireNonNull(
NamespacedKey.fromString(advancementRecord.key()),
"Invalid Namespaced key of " + advancementRecord.key()
);
Advancement bukkitAdvancement = Bukkit.getAdvancement(namespacedKey);
if (bukkitAdvancement == null) {
plugin.getLogger().log(Level.WARNING, "Ignored advancement '{0}' - it doesn't exist anymore?", namespacedKey);
return;
}
Object advancement = AdvancementUtils.getHandle(bukkitAdvancement);
Map<String, Date> criteriaList = advancementRecord.criteriaMap();
{
Map<String, Object> nativeCriteriaMap = new HashMap<>();
criteriaList.forEach((criteria, date) ->
nativeCriteriaMap.put(criteria, AdvancementUtils.newCriterionProgress(date))
);
Object nativeAdvancementProgress = AdvancementUtils.newAdvancementProgress(nativeCriteriaMap);
AdvancementUtils.startProgress(playerAdvancements, advancement, nativeAdvancementProgress);
}
});
AdvancementUtils.ensureAllVisible(playerAdvancements); // Set all completed advancement is visible
AdvancementUtils.markPlayerAdvancementsFirst(playerAdvancements); // Mark the sending of visible advancement as the first
}
/**
* Update a player's advancements and progress to match the advancementData
*
* @param player The player to set the advancements of
* @param advancementData The ArrayList of {@link me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate}s to set
*/
private static void setPlayerAdvancements(Player player, List<me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate> advancementData, PlayerData data) {
// Temporarily disable advancement announcing if needed
boolean announceAdvancementUpdate = false;
if (Boolean.TRUE.equals(player.getWorld().getGameRuleValue(GameRule.ANNOUNCE_ADVANCEMENTS))) {
player.getWorld().setGameRule(GameRule.ANNOUNCE_ADVANCEMENTS, false);
announceAdvancementUpdate = true;
}
final boolean finalAnnounceAdvancementUpdate = announceAdvancementUpdate;
// Run async because advancement loading is very slow
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
// Apply the advancements to the player
final Iterator<Advancement> serverAdvancements = Bukkit.getServer().advancementIterator();
while (serverAdvancements.hasNext()) { // Iterate through all advancements
boolean correctExperienceCheck = false; // Determines whether the experience might have changed warranting an update
Advancement advancement = serverAdvancements.next();
AdvancementProgress playerProgress = player.getAdvancementProgress(advancement);
for (me.william278.husksync.bukkit.data.DataSerializer.AdvancementRecordDate record : advancementData) {
// If the advancement is one on the data
if (record.key().equals(advancement.getKey().getNamespace() + ":" + advancement.getKey().getKey())) {
// Award all criteria that the player does not have that they do on the cache
ArrayList<String> currentlyAwardedCriteria = new ArrayList<>(playerProgress.getAwardedCriteria());
for (String awardCriteria : record.criteriaMap().keySet()) {
if (!playerProgress.getAwardedCriteria().contains(awardCriteria)) {
Bukkit.getScheduler().runTask(plugin, () -> player.getAdvancementProgress(advancement).awardCriteria(awardCriteria));
correctExperienceCheck = true;
}
currentlyAwardedCriteria.remove(awardCriteria);
}
// Revoke all criteria that the player does have but should not
for (String awardCriteria : currentlyAwardedCriteria) {
Bukkit.getScheduler().runTask(plugin, () -> player.getAdvancementProgress(advancement).revokeCriteria(awardCriteria));
}
break;
}
}
// Update the player's experience in case the advancement changed that
if (correctExperienceCheck) {
if (Settings.syncExperience) {
setPlayerExperience(player, data);
}
}
}
// Re-enable announcing advancements (back on main thread again)
Bukkit.getScheduler().runTask(plugin, () -> {
if (finalAnnounceAdvancementUpdate) {
player.getWorld().setGameRule(GameRule.ANNOUNCE_ADVANCEMENTS, true);
}
});
});
}
/**
* Set a player's statistics (in the Statistic menu)
*
* @param player The player to set the statistics of
* @param statisticData The {@link me.william278.husksync.bukkit.data.DataSerializer.StatisticData} to set
*/
private static void setPlayerStatistics(Player player, me.william278.husksync.bukkit.data.DataSerializer.StatisticData statisticData) {
// Set untyped statistics
for (Statistic statistic : statisticData.untypedStatisticValues().keySet()) {
player.setStatistic(statistic, statisticData.untypedStatisticValues().get(statistic));
}
// Set block statistics
for (Statistic statistic : statisticData.blockStatisticValues().keySet()) {
for (Material blockMaterial : statisticData.blockStatisticValues().get(statistic).keySet()) {
player.setStatistic(statistic, blockMaterial, statisticData.blockStatisticValues().get(statistic).get(blockMaterial));
}
}
// Set item statistics
for (Statistic statistic : statisticData.itemStatisticValues().keySet()) {
for (Material itemMaterial : statisticData.itemStatisticValues().get(statistic).keySet()) {
player.setStatistic(statistic, itemMaterial, statisticData.itemStatisticValues().get(statistic).get(itemMaterial));
}
}
// Set entity statistics
for (Statistic statistic : statisticData.entityStatisticValues().keySet()) {
for (EntityType entityType : statisticData.entityStatisticValues().get(statistic).keySet()) {
player.setStatistic(statistic, entityType, statisticData.entityStatisticValues().get(statistic).get(entityType));
}
}
}
/**
* Set a player's exp level, exp points & score
*
* @param player The {@link Player} to set
* @param data The {@link PlayerData} to set them
*/
private static void setPlayerExperience(Player player, PlayerData data) {
player.setTotalExperience(data.getTotalExperience());
player.setLevel(data.getExpLevel());
player.setExp(data.getExpProgress());
}
/**
* Set a player's location from {@link me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation} data
*
* @param player The {@link Player} to teleport
* @param location The {@link me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation}
*/
private static void setPlayerLocation(Player player, me.william278.husksync.bukkit.data.DataSerializer.PlayerLocation location) {
// Don't teleport if the location is invalid
if (location == null) {
return;
}
// Determine the world; if the names match, use that
World world = Bukkit.getWorld(location.worldName());
if (world == null) {
// If the names don't match, find the corresponding world with the same dimension environment
for (World worldOnServer : Bukkit.getWorlds()) {
if (worldOnServer.getEnvironment().equals(location.environment())) {
world = worldOnServer;
}
}
// If that still fails, return
if (world == null) {
return;
}
}
// Teleport the player
player.teleport(new Location(world, location.x(), location.y(), location.z(), location.yaw(), location.pitch()));
}
/**
* Correctly set a {@link Player}'s health data
*
* @param player The {@link Player} to set
* @param health Health to set to the player
* @param maxHealth Max health to set to the player
* @param healthScale Health scaling to apply to the player
*/
private static void setPlayerHealth(Player player, double health, double maxHealth, double healthScale) {
// Set max health
if (maxHealth != 0D) {
Objects.requireNonNull(player.getAttribute(Attribute.GENERIC_MAX_HEALTH)).setBaseValue(maxHealth);
}
// Set health
double currentHealth = player.getHealth();
if (health != currentHealth) player.setHealth(currentHealth > maxHealth ? maxHealth : health);
// Set health scaling if needed
if (healthScale != 0D) {
player.setHealthScale(healthScale);
} else {
player.setHealthScale(maxHealth);
}
player.setHealthScaled(healthScale != 0D);
}
}

View File

@@ -1,146 +0,0 @@
package net.william278.husksync.bukkit.util.nms;
import net.william278.husksync.util.ThrowSupplier;
import org.bukkit.advancement.Advancement;
import org.bukkit.entity.Player;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.Date;
import java.util.Map;
import java.util.Set;
public class AdvancementUtils {
public final static Class<?> PLAYER_ADVANCEMENT;
private final static Field PLAYER_ADVANCEMENTS_MAP;
private final static Field PLAYER_VISIBLE_SET;
private final static Field PLAYER_ADVANCEMENTS;
private final static Field CRITERIA_MAP;
private final static Field CRITERIA_DATE;
private final static Field IS_FIRST_PACKET;
private final static Method GET_HANDLE;
private final static Method START_PROGRESS;
private final static Method ENSURE_ALL_VISIBLE;
private final static Class<?> ADVANCEMENT_PROGRESS;
private final static Class<?> CRITERION_PROGRESS;
static {
Class<?> SERVER_PLAYER = MinecraftVersionUtils.getMinecraftClass("level.EntityPlayer");
PLAYER_ADVANCEMENTS = ThrowSupplier.get(() -> SERVER_PLAYER.getDeclaredField("cs"));
PLAYER_ADVANCEMENTS.setAccessible(true);
Class<?> CRAFT_ADVANCEMENT = MinecraftVersionUtils.getBukkitClass("advancement.CraftAdvancement");
GET_HANDLE = ThrowSupplier.get(() -> CRAFT_ADVANCEMENT.getDeclaredMethod("getHandle"));
ADVANCEMENT_PROGRESS = ThrowSupplier.get(() -> Class.forName("net.minecraft.advancements.AdvancementProgress"));
CRITERIA_MAP = ThrowSupplier.get(() -> ADVANCEMENT_PROGRESS.getDeclaredField("a"));
CRITERIA_MAP.setAccessible(true);
CRITERION_PROGRESS = ThrowSupplier.get(() -> Class.forName("net.minecraft.advancements.CriterionProgress"));
CRITERIA_DATE = ThrowSupplier.get(() -> CRITERION_PROGRESS.getDeclaredField("b"));
CRITERIA_DATE.setAccessible(true);
Class<?> ADVANCEMENT = ThrowSupplier.get(() -> Class.forName("net.minecraft.advancements.Advancement"));
PLAYER_ADVANCEMENT = MinecraftVersionUtils.getMinecraftClass("AdvancementDataPlayer");
PLAYER_ADVANCEMENTS_MAP = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredField("h"));
PLAYER_ADVANCEMENTS_MAP.setAccessible(true);
PLAYER_VISIBLE_SET = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredField("i"));
PLAYER_VISIBLE_SET.setAccessible(true);
START_PROGRESS = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredMethod("a", ADVANCEMENT, ADVANCEMENT_PROGRESS));
START_PROGRESS.setAccessible(true);
ENSURE_ALL_VISIBLE = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredMethod("c"));
ENSURE_ALL_VISIBLE.setAccessible(true);
IS_FIRST_PACKET = ThrowSupplier.get(() -> PLAYER_ADVANCEMENT.getDeclaredField("n"));
IS_FIRST_PACKET.setAccessible(true);
}
public static void markPlayerAdvancementsFirst(final Object playerAdvancements) {
try {
IS_FIRST_PACKET.set(playerAdvancements, true);
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static Object getPlayerAdvancements(Player player) {
Object nativePlayer = EntityUtils.getHandle(player);
try {
return PLAYER_ADVANCEMENTS.get(nativePlayer);
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void clearPlayerAdvancements(final Object playerAdvancement) {
try {
((Map<?, ?>) PLAYER_ADVANCEMENTS_MAP.get(playerAdvancement))
.clear();
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static Object getHandle(Advancement advancement) {
try {
return GET_HANDLE.invoke(advancement);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static Object newCriterionProgress(final Date date) {
try {
Object nativeCriterionProgress = CRITERION_PROGRESS.getDeclaredConstructor().newInstance();
CRITERIA_DATE.set(nativeCriterionProgress, date);
return nativeCriterionProgress;
} catch (InstantiationException | IllegalAccessException | InvocationTargetException | NoSuchMethodException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
@SuppressWarnings("unchecked") // Suppress unchecked cast warnings here
public static Object newAdvancementProgress(final Map<String, Object> criteria) {
try {
Object nativeAdvancementProgress = ADVANCEMENT_PROGRESS.getDeclaredConstructor().newInstance();
final Map<String, Object> criteriaMap = (Map<String, Object>) CRITERIA_MAP.get(nativeAdvancementProgress);
criteriaMap.putAll(criteria);
return nativeAdvancementProgress;
} catch (NoSuchMethodException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void startProgress(final Object playerAdvancements, final Object advancement, final Object advancementProgress) {
try {
START_PROGRESS.invoke(playerAdvancements, advancement, advancementProgress);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void ensureAllVisible(final Object playerAdvancements) {
try {
ENSURE_ALL_VISIBLE.invoke(playerAdvancements);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
public static void clearVisibleAdvancements(final Object playerAdvancements) {
try {
((Set<?>) PLAYER_VISIBLE_SET.get(playerAdvancements))
.clear();
} catch (IllegalAccessException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
}

View File

@@ -1,26 +0,0 @@
package net.william278.husksync.bukkit.util.nms;
import net.william278.husksync.util.ThrowSupplier;
import org.bukkit.entity.LivingEntity;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
public class EntityUtils {
private final static Method GET_HANDLE;
static {
final Class<?> CRAFT_ENTITY = MinecraftVersionUtils.getBukkitClass("entity.CraftEntity");
GET_HANDLE = ThrowSupplier.get(() -> CRAFT_ENTITY.getDeclaredMethod("getHandle"));
}
public static Object getHandle(LivingEntity livingEntity) throws RuntimeException {
try {
return GET_HANDLE.invoke(livingEntity);
} catch (IllegalAccessException | InvocationTargetException e) {
throw new RuntimeException(e.getMessage(), e);
}
}
}

View File

@@ -1,25 +0,0 @@
package net.william278.husksync.bukkit.util.nms;
import net.william278.husksync.util.ThrowSupplier;
import net.william278.husksync.util.VersionUtils;
import org.bukkit.Bukkit;
public class MinecraftVersionUtils {
public final static String CRAFTBUKKIT_PACKAGE_PATH = Bukkit.getServer().getClass().getPackage().getName();
public final static String PACKAGE_VERSION = CRAFTBUKKIT_PACKAGE_PATH.split("\\.")[3];
public final static VersionUtils.Version SERVER_VERSION
= VersionUtils.Version.of(Bukkit.getBukkitVersion().split("-")[0]);
public final static String MINECRAFT_PACKAGE = SERVER_VERSION.compareTo(VersionUtils.Version.of("1.17")) < 0 ?
"net.minecraft.server.".concat(PACKAGE_VERSION) : "net.minecraft.server";
public static Class<?> getBukkitClass(String path) {
return ThrowSupplier.get(() -> Class.forName(CRAFTBUKKIT_PACKAGE_PATH.concat(".").concat(path)));
}
public static Class<?> getMinecraftClass(String path) {
return ThrowSupplier.get(() -> Class.forName(MINECRAFT_PACKAGE.concat(".").concat(path)));
}
}

View File

@@ -0,0 +1,60 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import me.lucko.commodore.CommodoreProvider;
import me.lucko.commodore.file.CommodoreFileReader;
import net.william278.husksync.BukkitHuskSync;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.io.InputStream;
import java.util.logging.Level;
public class BrigadierUtil {
/**
* Uses commodore to register command completions.
*
* @param plugin instance of the registering Bukkit plugin
* @param bukkitCommand the Bukkit PluginCommand to register completions for
* @param command the {@link Command} to register completions for
*/
protected static void registerCommodore(@NotNull BukkitHuskSync plugin,
@NotNull org.bukkit.command.Command bukkitCommand,
@NotNull Command command) {
final InputStream commodoreFile = plugin.getResource(
"commodore/" + bukkitCommand.getName() + ".commodore"
);
if (commodoreFile == null) {
return;
}
try {
CommodoreProvider.getCommodore(plugin).register(bukkitCommand,
CommodoreFileReader.INSTANCE.parse(commodoreFile),
player -> player.hasPermission(command.getPermission()));
} catch (IOException e) {
plugin.log(Level.SEVERE, String.format(
"Failed to read command commodore completions for %s", bukkitCommand.getName()), e
);
}
}
}

View File

@@ -0,0 +1,164 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import me.lucko.commodore.CommodoreProvider;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.CommandUser;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.permissions.Permission;
import org.bukkit.permissions.PermissionDefault;
import org.bukkit.plugin.PluginManager;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.function.Function;
public class BukkitCommand extends org.bukkit.command.Command {
private final BukkitHuskSync plugin;
private final Command command;
public BukkitCommand(@NotNull Command command, @NotNull BukkitHuskSync plugin) {
super(command.getName(), command.getDescription(), command.getUsage(), command.getAliases());
this.command = command;
this.plugin = plugin;
}
@Override
public boolean execute(@NotNull CommandSender sender, @NotNull String commandLabel, @NotNull String[] args) {
this.command.onExecuted(sender instanceof Player p ? BukkitUser.adapt(p, plugin) : plugin.getConsole(), args);
return true;
}
@NotNull
@Override
public List<String> tabComplete(@NotNull CommandSender sender, @NotNull String alias,
@NotNull String[] args) throws IllegalArgumentException {
if (!(this.command instanceof TabProvider provider)) {
return List.of();
}
final CommandUser user = sender instanceof Player p ? BukkitUser.adapt(p, plugin) : plugin.getConsole();
if (getPermission() == null || user.hasPermission(getPermission())) {
return provider.getSuggestions(user, args);
}
return List.of();
}
public void register() {
// Register with bukkit
plugin.getCommandRegistrar().getServerCommandMap().register("husksync", this);
// Register permissions
BukkitCommand.addPermission(
plugin,
command.getPermission(),
command.getUsage(),
BukkitCommand.getPermissionDefault(command.isOperatorCommand())
);
final List<Permission> childNodes = command.getAdditionalPermissions()
.entrySet().stream()
.map((entry) -> BukkitCommand.addPermission(
plugin,
entry.getKey(),
"",
BukkitCommand.getPermissionDefault(entry.getValue()))
)
.filter(Objects::nonNull)
.toList();
if (!childNodes.isEmpty()) {
BukkitCommand.addPermission(
plugin,
command.getPermission("*"),
command.getUsage(),
PermissionDefault.FALSE,
childNodes.toArray(new Permission[0])
);
}
// Register commodore TAB completion
if (CommodoreProvider.isSupported() && plugin.getSettings().doBrigadierTabCompletion()) {
BrigadierUtil.registerCommodore(plugin, this, command);
}
}
@Nullable
protected static Permission addPermission(@NotNull BukkitHuskSync plugin, @NotNull String node,
@NotNull String description, @NotNull PermissionDefault permissionDefault,
@NotNull Permission... children) {
final Map<String, Boolean> childNodes = Arrays.stream(children)
.map(Permission::getName)
.collect(HashMap::new, (map, child) -> map.put(child, true), HashMap::putAll);
final PluginManager manager = plugin.getServer().getPluginManager();
if (manager.getPermission(node) != null) {
return null;
}
Permission permission;
if (description.isEmpty()) {
permission = new Permission(node, permissionDefault, childNodes);
} else {
permission = new Permission(node, description, permissionDefault, childNodes);
}
manager.addPermission(permission);
return permission;
}
@NotNull
protected static PermissionDefault getPermissionDefault(boolean isOperatorCommand) {
return isOperatorCommand ? PermissionDefault.OP : PermissionDefault.TRUE;
}
/**
* Commands available on the Bukkit HuskSync implementation
*/
public enum Type {
HUSKSYNC_COMMAND(HuskSyncCommand::new),
USERDATA_COMMAND(UserDataCommand::new),
INVENTORY_COMMAND(InventoryCommand::new),
ENDER_CHEST_COMMAND(EnderChestCommand::new);
public final Function<BukkitHuskSync, Command> commandSupplier;
Type(@NotNull Function<BukkitHuskSync, Command> supplier) {
this.commandSupplier = supplier;
}
@NotNull
public Command createCommand(@NotNull BukkitHuskSync plugin) {
return commandSupplier.apply(plugin);
}
public static void registerCommands(@NotNull BukkitHuskSync plugin) {
Arrays.stream(values())
.map((type) -> type.createCommand(plugin))
.forEach((command) -> new BukkitCommand(command, plugin).register());
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import com.google.gson.reflect.TypeToken;
import de.tr7zw.changeme.nbtapi.NBT;
import de.tr7zw.changeme.nbtapi.NBTContainer;
import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.api.HuskSyncAPI;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import static net.william278.husksync.data.BukkitData.Items.Inventory.INVENTORY_SLOT_COUNT;
public class BukkitSerializer {
protected final HuskSync plugin;
private BukkitSerializer(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
@SuppressWarnings("unused")
public BukkitSerializer(@NotNull HuskSyncAPI api) {
this.plugin = api.getPlugin();
}
@ApiStatus.Internal
@NotNull
public HuskSync getPlugin() {
return plugin;
}
public static class Inventory extends BukkitSerializer implements Serializer<BukkitData.Items.Inventory> {
private static final String ITEMS_TAG = "items";
private static final String HELD_ITEM_SLOT_TAG = "held_item_slot";
public Inventory(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.Items.Inventory deserialize(@NotNull String serialized) throws DeserializationException {
final ReadWriteNBT root = NBT.parseNBT(serialized);
final ItemStack[] items = root.getItemStackArray(ITEMS_TAG);
final int heldItemSlot = root.getInteger(HELD_ITEM_SLOT_TAG);
return BukkitData.Items.Inventory.from(
items == null ? new ItemStack[INVENTORY_SLOT_COUNT] : items,
heldItemSlot
);
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.Items.Inventory data) throws SerializationException {
final ReadWriteNBT root = NBT.createNBTObject();
root.setItemStackArray(ITEMS_TAG, data.getContents());
root.setInteger(HELD_ITEM_SLOT_TAG, data.getHeldItemSlot());
return root.toString();
}
}
public static class EnderChest extends BukkitSerializer implements Serializer<BukkitData.Items.EnderChest> {
public EnderChest(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.Items.EnderChest deserialize(@NotNull String serialized) throws DeserializationException {
final ItemStack[] items = NBT.itemStackArrayFromNBT(NBT.parseNBT(serialized));
return items == null ? BukkitData.Items.EnderChest.empty() : BukkitData.Items.EnderChest.adapt(items);
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.Items.EnderChest data) throws SerializationException {
return NBT.itemStackArrayToNBT(data.getContents()).toString();
}
}
public static class PotionEffects extends BukkitSerializer implements Serializer<BukkitData.PotionEffects> {
private static final TypeToken<List<Data.PotionEffects.Effect>> TYPE = new TypeToken<>() {
};
public PotionEffects(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.PotionEffects deserialize(@NotNull String serialized) throws DeserializationException {
return BukkitData.PotionEffects.adapt(
plugin.getGson().fromJson(serialized, TYPE.getType())
);
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.PotionEffects element) throws SerializationException {
return plugin.getGson().toJson(element.getActiveEffects());
}
}
public static class Advancements extends BukkitSerializer implements Serializer<BukkitData.Advancements> {
private static final TypeToken<List<Data.Advancements.Advancement>> TYPE = new TypeToken<>() {
};
public Advancements(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.Advancements deserialize(@NotNull String serialized) throws DeserializationException {
return BukkitData.Advancements.from(
plugin.getGson().fromJson(serialized, TYPE.getType())
);
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.Advancements element) throws SerializationException {
return plugin.getGson().toJson(element.getCompleted());
}
}
public static class Location extends BukkitSerializer implements Serializer<BukkitData.Location> {
public Location(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.Location deserialize(@NotNull String serialized) throws DeserializationException {
return plugin.getDataAdapter().fromJson(serialized, BukkitData.Location.class);
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.Location element) throws SerializationException {
return plugin.getDataAdapter().toJson(element);
}
}
public static class Statistics extends BukkitSerializer implements Serializer<BukkitData.Statistics> {
public Statistics(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.Statistics deserialize(@NotNull String serialized) throws DeserializationException {
return BukkitData.Statistics.from(plugin.getGson().fromJson(
serialized,
BukkitData.Statistics.StatisticsMap.class
));
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.Statistics element) throws SerializationException {
return plugin.getGson().toJson(element.getStatisticsSet());
}
}
public static class PersistentData extends BukkitSerializer implements Serializer<BukkitData.PersistentData> {
public PersistentData(@NotNull HuskSync plugin) {
super(plugin);
}
@Override
public BukkitData.PersistentData deserialize(@NotNull String serialized) throws DeserializationException {
return BukkitData.PersistentData.from(new NBTContainer(serialized));
}
@NotNull
@Override
public String serialize(@NotNull BukkitData.PersistentData element) throws SerializationException {
return element.getPersistentData().toString();
}
}
public static class Health extends Json<BukkitData.Health> implements Serializer<BukkitData.Health> {
public Health(@NotNull HuskSync plugin) {
super(plugin, BukkitData.Health.class);
}
}
public static class Hunger extends Json<BukkitData.Hunger> implements Serializer<BukkitData.Hunger> {
public Hunger(@NotNull HuskSync plugin) {
super(plugin, BukkitData.Hunger.class);
}
}
public static class Experience extends Json<BukkitData.Experience> implements Serializer<BukkitData.Experience> {
public Experience(@NotNull HuskSync plugin) {
super(plugin, BukkitData.Experience.class);
}
}
public static class GameMode extends Json<BukkitData.GameMode> implements Serializer<BukkitData.GameMode> {
public GameMode(@NotNull HuskSync plugin) {
super(plugin, BukkitData.GameMode.class);
}
}
public static abstract class Json<T extends Data & Adaptable> extends BukkitSerializer implements Serializer<T> {
private final Class<T> type;
protected Json(@NotNull HuskSync plugin, Class<T> type) {
super(plugin);
this.type = type;
}
@Override
public T deserialize(@NotNull String serialized) throws DeserializationException {
return plugin.getDataAdapter().fromJson(serialized, type);
}
@NotNull
@Override
public String serialize(@NotNull T element) throws SerializationException {
return plugin.getDataAdapter().toJson(element);
}
}
}

View File

@@ -0,0 +1,151 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.util.BukkitMapPersister;
import org.bukkit.entity.Player;
import org.bukkit.inventory.PlayerInventory;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Optional;
public interface BukkitUserDataHolder extends UserDataHolder {
@Override
default Optional<? extends Data> getData(@NotNull Identifier id) {
if (!id.isCustom()) {
return switch (id.getKeyValue()) {
case "inventory" -> getInventory();
case "ender_chest" -> getEnderChest();
case "potion_effects" -> getPotionEffects();
case "advancements" -> getAdvancements();
case "location" -> getLocation();
case "statistics" -> getStatistics();
case "health" -> getHealth();
case "hunger" -> getHunger();
case "experience" -> getExperience();
case "game_mode" -> getGameMode();
case "persistent_data" -> getPersistentData();
default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id));
};
}
return Optional.ofNullable(getCustomDataStore().get(id));
}
@Override
default void setData(@NotNull Identifier id, @NotNull Data data) {
if (id.isCustom()) {
getCustomDataStore().put(id, data);
}
UserDataHolder.super.setData(id, data);
}
@NotNull
@Override
default Optional<Data.Items.Inventory> getInventory() {
if ((isDead() && !getPlugin().getSettings().doSynchronizeDeadPlayersChangingServer())) {
return Optional.of(BukkitData.Items.Inventory.empty());
}
final PlayerInventory inventory = getBukkitPlayer().getInventory();
return Optional.of(BukkitData.Items.Inventory.from(
getMapPersister().persistLockedMaps(inventory.getContents(), getBukkitPlayer()),
inventory.getHeldItemSlot()
));
}
@NotNull
@Override
default Optional<Data.Items.EnderChest> getEnderChest() {
return Optional.of(BukkitData.Items.EnderChest.adapt(
getMapPersister().persistLockedMaps(getBukkitPlayer().getEnderChest().getContents(), getBukkitPlayer())
));
}
@NotNull
@Override
default Optional<Data.PotionEffects> getPotionEffects() {
return Optional.of(BukkitData.PotionEffects.from(getBukkitPlayer().getActivePotionEffects()));
}
@NotNull
@Override
default Optional<Data.Advancements> getAdvancements() {
return Optional.of(BukkitData.Advancements.adapt(getBukkitPlayer()));
}
@NotNull
@Override
default Optional<Data.Location> getLocation() {
return Optional.of(BukkitData.Location.adapt(getBukkitPlayer().getLocation()));
}
@NotNull
@Override
default Optional<Data.Statistics> getStatistics() {
return Optional.of(BukkitData.Statistics.adapt(getBukkitPlayer()));
}
@NotNull
@Override
default Optional<Data.Health> getHealth() {
return Optional.of(BukkitData.Health.adapt(getBukkitPlayer()));
}
@NotNull
@Override
default Optional<Data.Hunger> getHunger() {
return Optional.of(BukkitData.Hunger.adapt(getBukkitPlayer()));
}
@NotNull
@Override
default Optional<Data.Experience> getExperience() {
return Optional.of(BukkitData.Experience.adapt(getBukkitPlayer()));
}
@NotNull
@Override
default Optional<Data.GameMode> getGameMode() {
return Optional.of(BukkitData.GameMode.adapt(getBukkitPlayer()));
}
@NotNull
@Override
default Optional<Data.PersistentData> getPersistentData() {
return Optional.of(BukkitData.PersistentData.adapt(getBukkitPlayer().getPersistentDataContainer()));
}
boolean isDead();
@NotNull
Player getBukkitPlayer();
@NotNull
Map<Identifier, Data> getCustomDataStore();
@NotNull
default BukkitMapPersister getMapPersister() {
return (BukkitHuskSync) getPlugin();
}
}

View File

@@ -0,0 +1,80 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.event;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.bukkit.event.Cancellable;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
@SuppressWarnings("unused")
public class BukkitDataSaveEvent extends BukkitEvent implements DataSaveEvent, Cancellable {
private static final HandlerList HANDLER_LIST = new HandlerList();
private final HuskSync plugin;
private final DataSnapshot.Packed snapshot;
private final User user;
private boolean cancelled = false;
protected BukkitDataSaveEvent(@NotNull User user, @NotNull DataSnapshot.Packed snapshot, @NotNull HuskSync plugin) {
this.user = user;
this.snapshot = snapshot;
this.plugin = plugin;
}
@Override
public boolean isCancelled() {
return cancelled;
}
@Override
public void setCancelled(boolean cancelled) {
this.cancelled = cancelled;
}
@NotNull
@Override
public User getUser() {
return user;
}
@Override
@NotNull
public DataSnapshot.Packed getData() {
return snapshot;
}
@NotNull
@Override
public HuskSync getPlugin() {
return plugin;
}
@NotNull
@Override
public HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}

View File

@@ -0,0 +1,44 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.event;
import org.bukkit.event.Event;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
@SuppressWarnings("unused")
public abstract class BukkitEvent extends Event implements net.william278.husksync.event.Event {
private static final HandlerList HANDLER_LIST = new HandlerList();
protected BukkitEvent() {
}
@NotNull
@Override
public HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}

View File

@@ -0,0 +1,54 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.event;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.NotNull;
public interface BukkitEventDispatcher extends EventDispatcher {
@Override
default <T extends Event> boolean fireIsCancelled(@NotNull T event) {
Bukkit.getPluginManager().callEvent((org.bukkit.event.Event) event);
return event instanceof Cancellable cancellable && cancellable.isCancelled();
}
@NotNull
@Override
default PreSyncEvent getPreSyncEvent(@NotNull OnlineUser user, @NotNull DataSnapshot.Packed data) {
return new BukkitPreSyncEvent(user, data, getPlugin());
}
@NotNull
@Override
default DataSaveEvent getDataSaveEvent(@NotNull User user, @NotNull DataSnapshot.Packed data) {
return new BukkitDataSaveEvent(user, data, getPlugin());
}
@NotNull
@Override
default SyncCompleteEvent getSyncCompleteEvent(@NotNull OnlineUser user) {
return new BukkitSyncCompleteEvent(user, getPlugin());
}
}

View File

@@ -0,0 +1,54 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.event;
import net.william278.husksync.user.OnlineUser;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
@SuppressWarnings("unused")
public abstract class BukkitPlayerEvent extends BukkitEvent implements PlayerEvent {
private static final HandlerList HANDLER_LIST = new HandlerList();
protected final OnlineUser player;
protected BukkitPlayerEvent(@NotNull OnlineUser player) {
this.player = player;
}
@Override
@NotNull
public OnlineUser getUser() {
return player;
}
@NotNull
@Override
public HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}

View File

@@ -0,0 +1,73 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.event;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import org.bukkit.event.Cancellable;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
@SuppressWarnings("unused")
public class BukkitPreSyncEvent extends BukkitPlayerEvent implements PreSyncEvent, Cancellable {
private static final HandlerList HANDLER_LIST = new HandlerList();
private final HuskSync plugin;
private final DataSnapshot.Packed data;
private boolean cancelled = false;
protected BukkitPreSyncEvent(@NotNull OnlineUser player, @NotNull DataSnapshot.Packed data, @NotNull HuskSync plugin) {
super(player);
this.data = data;
this.plugin = plugin;
}
@Override
public boolean isCancelled() {
return cancelled;
}
@Override
public void setCancelled(boolean cancelled) {
this.cancelled = cancelled;
}
@Override
@NotNull
public DataSnapshot.Packed getData() {
return data;
}
@NotNull
@Override
public HuskSync getPlugin() {
return plugin;
}
@NotNull
@Override
public HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}

View File

@@ -0,0 +1,44 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.event;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.OnlineUser;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.NotNull;
@SuppressWarnings("unused")
public class BukkitSyncCompleteEvent extends BukkitPlayerEvent implements SyncCompleteEvent {
private static final HandlerList HANDLER_LIST = new HandlerList();
protected BukkitSyncCompleteEvent(@NotNull OnlineUser player, @NotNull HuskSync plugin) {
super(player);
}
@NotNull
@Override
public HandlerList getHandlers() {
return HANDLER_LIST;
}
public static HandlerList getHandlerList() {
return HANDLER_LIST;
}
}

View File

@@ -0,0 +1,55 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.listener;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.jetbrains.annotations.NotNull;
public interface BukkitDeathEventListener extends Listener {
boolean handleEvent(@NotNull EventListener.ListenerType type, @NotNull EventListener.Priority priority);
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
default void onPlayerDeathHighest(@NotNull PlayerDeathEvent event) {
if (handleEvent(EventListener.ListenerType.DEATH_LISTENER, EventListener.Priority.HIGHEST)) {
handlePlayerDeath(event);
}
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
default void onPlayerDeath(@NotNull PlayerDeathEvent event) {
if (handleEvent(EventListener.ListenerType.DEATH_LISTENER, EventListener.Priority.NORMAL)) {
handlePlayerDeath(event);
}
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
default void onPlayerDeathLowest(@NotNull PlayerDeathEvent event) {
if (handleEvent(EventListener.ListenerType.DEATH_LISTENER, EventListener.Priority.LOWEST)) {
handlePlayerDeath(event);
}
}
void handlePlayerDeath(@NotNull PlayerDeathEvent player);
}

View File

@@ -0,0 +1,202 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.listener;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.BukkitData;
import net.william278.husksync.user.BukkitUser;
import net.william278.husksync.user.OnlineUser;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.entity.Projectile;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.block.BlockBreakEvent;
import org.bukkit.event.block.BlockPlaceEvent;
import org.bukkit.event.entity.EntityDamageEvent;
import org.bukkit.event.entity.EntityPickupItemEvent;
import org.bukkit.event.entity.PlayerDeathEvent;
import org.bukkit.event.entity.ProjectileLaunchEvent;
import org.bukkit.event.inventory.InventoryClickEvent;
import org.bukkit.event.inventory.InventoryOpenEvent;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.event.player.PlayerCommandPreprocessEvent;
import org.bukkit.event.player.PlayerDropItemEvent;
import org.bukkit.event.player.PlayerInteractEntityEvent;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.world.WorldSaveEvent;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Locale;
import java.util.stream.Collectors;
public class BukkitEventListener extends EventListener implements BukkitJoinEventListener, BukkitQuitEventListener,
BukkitDeathEventListener, Listener {
protected final List<String> blacklistedCommands;
public BukkitEventListener(@NotNull BukkitHuskSync huskSync) {
super(huskSync);
this.blacklistedCommands = huskSync.getSettings().getBlacklistedCommandsWhileLocked();
Bukkit.getServer().getPluginManager().registerEvents(this, huskSync);
}
@Override
public boolean handleEvent(@NotNull ListenerType type, @NotNull Priority priority) {
return plugin.getSettings().getEventPriority(type).equals(priority);
}
@Override
public void handlePlayerQuit(@NotNull BukkitUser bukkitUser) {
final Player player = bukkitUser.getPlayer();
if (!bukkitUser.isLocked() && !player.getItemOnCursor().getType().isAir()) {
player.getWorld().dropItem(player.getLocation(), player.getItemOnCursor());
player.setItemOnCursor(null);
}
super.handlePlayerQuit(bukkitUser);
}
@Override
public void handlePlayerJoin(@NotNull BukkitUser bukkitUser) {
super.handlePlayerJoin(bukkitUser);
}
@Override
public void handlePlayerDeath(@NotNull PlayerDeathEvent event) {
final OnlineUser user = BukkitUser.adapt(event.getEntity(), plugin);
// If the player is locked or the plugin disabling, clear their drops
if (cancelPlayerEvent(user.getUuid())) {
event.getDrops().clear();
return;
}
// Handle saving player data snapshots on death
if (!plugin.getSettings().doSaveOnDeath()) {
return;
}
// Truncate the dropped items list to the inventory size and save the player's inventory
final int maxInventorySize = BukkitData.Items.Inventory.INVENTORY_SLOT_COUNT;
if (event.getDrops().size() > maxInventorySize) {
event.getDrops().subList(maxInventorySize, event.getDrops().size()).clear();
}
super.saveOnPlayerDeath(user, BukkitData.Items.ItemArray.adapt(event.getDrops()));
}
@EventHandler(ignoreCancelled = true)
public void onWorldSave(@NotNull WorldSaveEvent event) {
if (!plugin.getSettings().doSaveOnWorldSave()) {
return;
}
// Handle saving player data snapshots when the world saves
plugin.runAsync(() -> super.saveOnWorldSave(event.getWorld().getPlayers()
.stream().map(player -> BukkitUser.adapt(player, plugin))
.collect(Collectors.toList())));
}
/*
* Events to cancel if the player has not been set yet
*/
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onProjectileLaunch(@NotNull ProjectileLaunchEvent event) {
final Projectile projectile = event.getEntity();
if (projectile.getShooter() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onDropItem(@NotNull PlayerDropItemEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPickupItem(@NotNull EntityPickupItemEvent event) {
if (event.getEntity() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerInteract(@NotNull PlayerInteractEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerInteractEntity(@NotNull PlayerInteractEntityEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockPlace(@NotNull BlockPlaceEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onBlockBreak(@NotNull BlockBreakEvent event) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInventoryOpen(@NotNull InventoryOpenEvent event) {
if (event.getPlayer() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
}
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onInventoryClick(@NotNull InventoryClickEvent event) {
event.setCancelled(cancelPlayerEvent(event.getWhoClicked().getUniqueId()));
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onCraftItem(@NotNull PrepareItemCraftEvent event) {
}
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
public void onPlayerTakeDamage(@NotNull EntityDamageEvent event) {
if (event.getEntity() instanceof Player player) {
event.setCancelled(cancelPlayerEvent(player.getUniqueId()));
}
}
@EventHandler(priority = EventPriority.LOW, ignoreCancelled = true)
public void onPermissionCommand(@NotNull PlayerCommandPreprocessEvent event) {
final String[] commandArgs = event.getMessage().substring(1).split(" ");
final String commandLabel = commandArgs[0].toLowerCase(Locale.ENGLISH);
if (blacklistedCommands.contains("*") || blacklistedCommands.contains(commandLabel)) {
event.setCancelled(cancelPlayerEvent(event.getPlayer().getUniqueId()));
}
}
@NotNull
@Override
public HuskSync getPlugin() {
return plugin;
}
}

View File

@@ -0,0 +1,60 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.listener;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.BukkitUser;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerJoinEvent;
import org.jetbrains.annotations.NotNull;
public interface BukkitJoinEventListener extends Listener {
boolean handleEvent(@NotNull EventListener.ListenerType type, @NotNull EventListener.Priority priority);
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
default void onPlayerJoinHighest(@NotNull PlayerJoinEvent event) {
if (handleEvent(EventListener.ListenerType.JOIN_LISTENER, EventListener.Priority.HIGHEST)) {
handlePlayerJoin(BukkitUser.adapt(event.getPlayer(), getPlugin()));
}
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
default void onPlayerJoin(@NotNull PlayerJoinEvent event) {
if (handleEvent(EventListener.ListenerType.JOIN_LISTENER, EventListener.Priority.NORMAL)) {
handlePlayerJoin(BukkitUser.adapt(event.getPlayer(), getPlugin()));
}
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
default void onPlayerJoinLowest(@NotNull PlayerJoinEvent event) {
if (handleEvent(EventListener.ListenerType.JOIN_LISTENER, EventListener.Priority.LOWEST)) {
handlePlayerJoin(BukkitUser.adapt(event.getPlayer(), getPlugin()));
}
}
void handlePlayerJoin(@NotNull BukkitUser player);
@NotNull
HuskSync getPlugin();
}

View File

@@ -0,0 +1,60 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.listener;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.BukkitUser;
import org.bukkit.event.EventHandler;
import org.bukkit.event.EventPriority;
import org.bukkit.event.Listener;
import org.bukkit.event.player.PlayerQuitEvent;
import org.jetbrains.annotations.NotNull;
public interface BukkitQuitEventListener extends Listener {
boolean handleEvent(@NotNull EventListener.ListenerType type, @NotNull EventListener.Priority priority);
@EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true)
default void onPlayerQuitHighest(@NotNull PlayerQuitEvent event) {
if (handleEvent(EventListener.ListenerType.QUIT_LISTENER, EventListener.Priority.HIGHEST)) {
handlePlayerQuit(BukkitUser.adapt(event.getPlayer(), getPlugin()));
}
}
@EventHandler(priority = EventPriority.NORMAL, ignoreCancelled = true)
default void onPlayerQuit(@NotNull PlayerQuitEvent event) {
if (handleEvent(EventListener.ListenerType.QUIT_LISTENER, EventListener.Priority.NORMAL)) {
handlePlayerQuit(BukkitUser.adapt(event.getPlayer(), getPlugin()));
}
}
@EventHandler(priority = EventPriority.LOWEST, ignoreCancelled = true)
default void onPlayerQuitLowest(@NotNull PlayerQuitEvent event) {
if (handleEvent(EventListener.ListenerType.QUIT_LISTENER, EventListener.Priority.LOWEST)) {
handlePlayerQuit(BukkitUser.adapt(event.getPlayer(), getPlugin()));
}
}
void handlePlayerQuit(@NotNull BukkitUser player);
@NotNull
HuskSync getPlugin();
}

View File

@@ -0,0 +1,372 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.migrator;
import com.zaxxer.hikari.HikariDataSource;
import me.william278.husksync.bukkit.data.DataSerializer;
import net.william278.hslmigrator.HSLConverter;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.BukkitData;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import net.william278.husksync.util.BukkitLegacyConverter;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.entity.EntityType;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.regex.Pattern;
public class LegacyMigrator extends Migrator {
private final HSLConverter hslConverter;
private String sourceHost;
private int sourcePort;
private String sourceUsername;
private String sourcePassword;
private String sourceDatabase;
private String sourcePlayersTable;
private String sourceDataTable;
public LegacyMigrator(@NotNull HuskSync plugin) {
super(plugin);
this.hslConverter = HSLConverter.getInstance();
this.sourceHost = plugin.getSettings().getMySqlHost();
this.sourcePort = plugin.getSettings().getMySqlPort();
this.sourceUsername = plugin.getSettings().getMySqlUsername();
this.sourcePassword = plugin.getSettings().getMySqlPassword();
this.sourceDatabase = plugin.getSettings().getMySqlDatabase();
this.sourcePlayersTable = "husksync_players";
this.sourceDataTable = "husksync_data";
}
@Override
public CompletableFuture<Boolean> start() {
plugin.log(Level.INFO, "Starting migration of legacy HuskSync v1.x data...");
final long startTime = System.currentTimeMillis();
return plugin.supplyAsync(() -> {
// Wipe the existing database, preparing it for data import
plugin.log(Level.INFO, "Preparing existing database (wiping)...");
plugin.getDatabase().wipeDatabase();
plugin.log(Level.INFO, "Successfully wiped user data database (took " + (System.currentTimeMillis() - startTime) + "ms)");
// Create jdbc driver connection url
final String jdbcUrl = "jdbc:mysql://" + sourceHost + ":" + sourcePort + "/" + sourceDatabase;
// Create a new data source for the mpdb converter
try (final HikariDataSource connectionPool = new HikariDataSource()) {
plugin.log(Level.INFO, "Establishing connection to legacy database...");
connectionPool.setJdbcUrl(jdbcUrl);
connectionPool.setUsername(sourceUsername);
connectionPool.setPassword(sourcePassword);
connectionPool.setPoolName((getIdentifier() + "_migrator_pool").toUpperCase(Locale.ENGLISH));
plugin.log(Level.INFO, "Downloading raw data from the legacy database (this might take a while)...");
final List<LegacyData> dataToMigrate = new ArrayList<>();
try (final Connection connection = connectionPool.getConnection()) {
try (final PreparedStatement statement = connection.prepareStatement("""
SELECT `uuid`, `username`, `inventory`, `ender_chest`, `health`, `max_health`, `health_scale`, `hunger`, `saturation`, `saturation_exhaustion`, `selected_slot`, `status_effects`, `total_experience`, `exp_level`, `exp_progress`, `game_mode`, `statistics`, `is_flying`, `advancements`, `location`
FROM `%source_players_table%`
INNER JOIN `%source_data_table%`
ON `%source_players_table%`.`id` = `%source_data_table%`.`player_id`
WHERE `username` IS NOT NULL;
""".replaceAll(Pattern.quote("%source_players_table%"), sourcePlayersTable)
.replaceAll(Pattern.quote("%source_data_table%"), sourceDataTable))) {
try (final ResultSet resultSet = statement.executeQuery()) {
int playersMigrated = 0;
while (resultSet.next()) {
dataToMigrate.add(new LegacyData(
new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")),
resultSet.getString("inventory"),
resultSet.getString("ender_chest"),
resultSet.getDouble("health"),
resultSet.getDouble("max_health"),
resultSet.getDouble("health_scale"),
resultSet.getInt("hunger"),
resultSet.getFloat("saturation"),
resultSet.getFloat("saturation_exhaustion"),
resultSet.getInt("selected_slot"),
resultSet.getString("status_effects"),
resultSet.getInt("total_experience"),
resultSet.getInt("exp_level"),
resultSet.getFloat("exp_progress"),
resultSet.getString("game_mode"),
resultSet.getString("statistics"),
resultSet.getBoolean("is_flying"),
resultSet.getString("advancements"),
resultSet.getString("location")
));
playersMigrated++;
if (playersMigrated % 50 == 0) {
plugin.log(Level.INFO, "Downloaded legacy data for " + playersMigrated + " players...");
}
}
}
}
}
plugin.log(Level.INFO, "Completed download of " + dataToMigrate.size() + " entries from the legacy database!");
plugin.log(Level.INFO, "Converting HuskSync 1.x data to the new user data format (this might take a while)...");
final AtomicInteger playersConverted = new AtomicInteger();
dataToMigrate.forEach(data -> {
final DataSnapshot.Packed convertedData = data.toUserData(hslConverter, plugin);
plugin.getDatabase().ensureUser(data.user());
try {
plugin.getDatabase().addSnapshot(data.user(), convertedData);
} catch (Throwable e) {
plugin.log(Level.SEVERE, "Failed to migrate legacy data for " + data.user().getUsername() + ": " + e.getMessage());
return;
}
playersConverted.getAndIncrement();
if (playersConverted.get() % 50 == 0) {
plugin.log(Level.INFO, "Converted legacy data for " + playersConverted + " players...");
}
});
plugin.log(Level.INFO, "Migration complete for " + dataToMigrate.size() + " users in " + ((System.currentTimeMillis() - startTime) / 1000) + " seconds!");
return true;
} catch (Throwable e) {
plugin.log(Level.SEVERE, "Error while migrating legacy data: " + e.getMessage() + " - are your source database credentials correct?", e);
return false;
}
});
}
@Override
public void handleConfigurationCommand(@NotNull String[] args) {
if (args.length == 2) {
if (switch (args[0].toLowerCase(Locale.ENGLISH)) {
case "host" -> {
this.sourceHost = args[1];
yield true;
}
case "port" -> {
try {
this.sourcePort = Integer.parseInt(args[1]);
yield true;
} catch (NumberFormatException e) {
yield false;
}
}
case "username" -> {
this.sourceUsername = args[1];
yield true;
}
case "password" -> {
this.sourcePassword = args[1];
yield true;
}
case "database" -> {
this.sourceDatabase = args[1];
yield true;
}
case "players_table" -> {
this.sourcePlayersTable = args[1];
yield true;
}
case "data_table" -> {
this.sourceDataTable = args[1];
yield true;
}
default -> false;
}) {
plugin.log(Level.INFO, getHelpMenu());
plugin.log(Level.INFO, "Successfully set " + args[0] + " to " +
obfuscateDataString(args[1]));
} else {
plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " +
obfuscateDataString(args[1]) + " (is it a valid option?)");
}
} else {
plugin.log(Level.INFO, getHelpMenu());
}
}
@NotNull
@Override
public String getIdentifier() {
return "legacy";
}
@NotNull
@Override
public String getName() {
return "HuskSync v1.x --> v2.x Migrator";
}
@NotNull
@Override
public String getHelpMenu() {
return """
=== HuskSync v1.x --> v2.x Migration Wizard =========
This will migrate all user data from HuskSync v1.x to
HuskSync v2.x's new format. To perform the migration,
please follow the steps below carefully.
[!] Existing data in the database will be wiped. [!]
STEP 1] Please ensure no players are on any servers.
STEP 2] HuskSync will need to connect to the database
used to hold the existing, legacy HuskSync data.
If this is the same database as the one you are
currently using, you probably don't need to change
anything.
Please check that the credentials below are the
correct credentials of the source legacy HuskSync
database.
- host: %source_host%
- port: %source_port%
- username: %source_username%
- password: %source_password%
- database: %source_database%
- players_table: %source_players_table%
- data_table: %source_data_table%
If any of these are not correct, please correct them
using the command:
"husksync migrate legacy set <parameter> <value>"
(e.g.: "husksync migrate legacy set host 1.2.3.4")
STEP 3] HuskSync will migrate data into the database
tables configures in the config.yml file of this
server. Please make sure you're happy with this
before proceeding.
STEP 4] To start the migration, please run:
"husksync migrate legacy start"
""".replaceAll(Pattern.quote("%source_host%"), obfuscateDataString(sourceHost))
.replaceAll(Pattern.quote("%source_port%"), Integer.toString(sourcePort))
.replaceAll(Pattern.quote("%source_username%"), obfuscateDataString(sourceUsername))
.replaceAll(Pattern.quote("%source_password%"), obfuscateDataString(sourcePassword))
.replaceAll(Pattern.quote("%source_database%"), sourceDatabase)
.replaceAll(Pattern.quote("%source_players_table%"), sourcePlayersTable)
.replaceAll(Pattern.quote("%source_data_table%"), sourceDataTable);
}
private record LegacyData(@NotNull User user,
@NotNull String serializedInventory, @NotNull String serializedEnderChest,
double health, double maxHealth, double healthScale, int hunger, float saturation,
float saturationExhaustion, int selectedSlot, @NotNull String serializedPotionEffects,
int totalExp, int expLevel, float expProgress,
@NotNull String gameMode, @NotNull String serializedStatistics, boolean isFlying,
@NotNull String serializedAdvancements, @NotNull String serializedLocation) {
@NotNull
public DataSnapshot.Packed toUserData(@NotNull HSLConverter converter, @NotNull HuskSync plugin) {
try {
final DataSerializer.StatisticData stats = converter.deserializeStatisticData(serializedStatistics);
final DataSerializer.PlayerLocation loc = converter.deserializePlayerLocationData(serializedLocation);
final BukkitLegacyConverter adapter = (BukkitLegacyConverter) plugin.getLegacyConverter()
.orElseThrow(() -> new IllegalStateException("Legacy converter not present"));
return DataSnapshot.builder(plugin)
// Inventory
.inventory(BukkitData.Items.Inventory.from(
adapter.deserializeLegacyItemStacks(serializedInventory),
selectedSlot
))
// Ender chest
.enderChest(BukkitData.Items.EnderChest.adapt(
adapter.deserializeLegacyItemStacks(serializedEnderChest)
))
// Location
.location(BukkitData.Location.from(
loc == null ? 0d : loc.x(),
loc == null ? 64d : loc.y(),
loc == null ? 0d : loc.z(),
loc == null ? 90f : loc.yaw(),
loc == null ? 180f : loc.pitch(),
new Data.Location.World(
loc == null ? "world" : loc.worldName(),
UUID.randomUUID(), "NORMAL"
)))
// Advancements
.advancements(BukkitData.Advancements.from(converter
.deserializeAdvancementData(serializedAdvancements).stream()
.map(data -> Data.Advancements.Advancement.adapt(data.key(), data.criteriaMap()))
.toList()))
// Stats
.statistics(BukkitData.Statistics.from(
BukkitData.Statistics.createStatisticsMap(
convertStatisticMap(stats.untypedStatisticValues()),
convertMaterialStatisticMap(stats.blockStatisticValues()),
convertMaterialStatisticMap(stats.itemStatisticValues()),
convertEntityStatisticMap(stats.entityStatisticValues())
)))
// Health, hunger, experience & game mode
.health(BukkitData.Health.from(health, maxHealth, healthScale))
.hunger(BukkitData.Hunger.from(hunger, saturation, saturationExhaustion))
.experience(BukkitData.Experience.from(totalExp, expLevel, expProgress))
.gameMode(BukkitData.GameMode.from(gameMode, isFlying, isFlying))
// Build & pack into new format
.saveCause(DataSnapshot.SaveCause.LEGACY_MIGRATION).buildAndPack();
} catch (Throwable e) {
throw new IllegalStateException(e);
}
}
private Map<String, Integer> convertStatisticMap(@NotNull HashMap<Statistic, Integer> rawMap) {
final HashMap<String, Integer> convertedMap = new HashMap<>();
for (Map.Entry<Statistic, Integer> entry : rawMap.entrySet()) {
convertedMap.put(entry.getKey().getKey().toString(), entry.getValue());
}
return convertedMap;
}
private Map<String, Map<String, Integer>> convertMaterialStatisticMap(@NotNull HashMap<Statistic, HashMap<Material, Integer>> rawMap) {
final Map<String, Map<String, Integer>> convertedMap = new HashMap<>();
for (Map.Entry<Statistic, HashMap<Material, Integer>> entry : rawMap.entrySet()) {
for (Map.Entry<Material, Integer> materialEntry : entry.getValue().entrySet()) {
convertedMap.computeIfAbsent(entry.getKey().getKey().toString(), k -> new HashMap<>())
.put(materialEntry.getKey().getKey().toString(), materialEntry.getValue());
}
}
return convertedMap;
}
private Map<String, Map<String, Integer>> convertEntityStatisticMap(@NotNull HashMap<Statistic, HashMap<EntityType, Integer>> rawMap) {
final Map<String, Map<String, Integer>> convertedMap = new HashMap<>();
for (Map.Entry<Statistic, HashMap<EntityType, Integer>> entry : rawMap.entrySet()) {
for (Map.Entry<EntityType, Integer> materialEntry : entry.getValue().entrySet()) {
convertedMap.computeIfAbsent(entry.getKey().getKey().toString(), k -> new HashMap<>())
.put(materialEntry.getKey().getKey().toString(), materialEntry.getValue());
}
}
return convertedMap;
}
}
}

View File

@@ -0,0 +1,317 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.migrator;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.BukkitData;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import net.william278.mpdbconverter.MPDBConverter;
import org.bukkit.Bukkit;
import org.bukkit.event.inventory.InventoryType;
import org.bukkit.inventory.Inventory;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.util.*;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.regex.Pattern;
/**
* A migrator for migrating MySQLPlayerDataBridge data to HuskSync {@link DataSnapshot}s
*/
public class MpdbMigrator extends Migrator {
private final MPDBConverter mpdbConverter;
private String sourceHost;
private int sourcePort;
private String sourceUsername;
private String sourcePassword;
private String sourceDatabase;
private String sourceInventoryTable;
private String sourceEnderChestTable;
private String sourceExperienceTable;
public MpdbMigrator(@NotNull BukkitHuskSync plugin) {
super(plugin);
this.mpdbConverter = MPDBConverter.getInstance(Objects.requireNonNull(
Bukkit.getPluginManager().getPlugin("MySQLPlayerDataBridge"),
"MySQLPlayerDataBridge dependency not found!"
));
this.sourceHost = plugin.getSettings().getMySqlHost();
this.sourcePort = plugin.getSettings().getMySqlPort();
this.sourceUsername = plugin.getSettings().getMySqlUsername();
this.sourcePassword = plugin.getSettings().getMySqlPassword();
this.sourceDatabase = plugin.getSettings().getMySqlDatabase();
this.sourceInventoryTable = "mpdb_inventory";
this.sourceEnderChestTable = "mpdb_enderchest";
this.sourceExperienceTable = "mpdb_experience";
}
@Override
public CompletableFuture<Boolean> start() {
plugin.log(Level.INFO, "Starting migration from MySQLPlayerDataBridge to HuskSync...");
final long startTime = System.currentTimeMillis();
return plugin.supplyAsync(() -> {
// Wipe the existing database, preparing it for data import
plugin.log(Level.INFO, "Preparing existing database (wiping)...");
plugin.getDatabase().wipeDatabase();
plugin.log(Level.INFO, "Successfully wiped user data database (took " + (System.currentTimeMillis() - startTime) + "ms)");
// Create jdbc driver connection url
final String jdbcUrl = "jdbc:mysql://" + sourceHost + ":" + sourcePort + "/" + sourceDatabase;
// Create a new data source for the mpdb converter
try (final HikariDataSource connectionPool = new HikariDataSource()) {
plugin.log(Level.INFO, "Establishing connection to MySQLPlayerDataBridge database...");
connectionPool.setJdbcUrl(jdbcUrl);
connectionPool.setUsername(sourceUsername);
connectionPool.setPassword(sourcePassword);
connectionPool.setPoolName((getIdentifier() + "_migrator_pool").toUpperCase(Locale.ENGLISH));
plugin.log(Level.INFO, "Downloading raw data from the MySQLPlayerDataBridge database (this might take a while)...");
final List<MpdbData> dataToMigrate = new ArrayList<>();
try (final Connection connection = connectionPool.getConnection()) {
try (final PreparedStatement statement = connection.prepareStatement("""
SELECT `%source_inventory_table%`.`player_uuid`, `%source_inventory_table%`.`player_name`, `inventory`, `armor`, `enderchest`, `exp_lvl`, `exp`, `total_exp`
FROM `%source_inventory_table%`
INNER JOIN `%source_ender_chest_table%`
ON `%source_inventory_table%`.`player_uuid` = `%source_ender_chest_table%`.`player_uuid`
INNER JOIN `%source_xp_table%`
ON `%source_inventory_table%`.`player_uuid` = `%source_xp_table%`.`player_uuid`;
""".replaceAll(Pattern.quote("%source_inventory_table%"), sourceInventoryTable)
.replaceAll(Pattern.quote("%source_ender_chest_table%"), sourceEnderChestTable)
.replaceAll(Pattern.quote("%source_xp_table%"), sourceExperienceTable))) {
try (final ResultSet resultSet = statement.executeQuery()) {
int playersMigrated = 0;
while (resultSet.next()) {
dataToMigrate.add(new MpdbData(
new User(UUID.fromString(resultSet.getString("player_uuid")),
resultSet.getString("player_name")),
resultSet.getString("inventory"),
resultSet.getString("armor"),
resultSet.getString("enderchest"),
resultSet.getInt("exp_lvl"),
resultSet.getInt("exp"),
resultSet.getInt("total_exp")
));
playersMigrated++;
if (playersMigrated % 25 == 0) {
plugin.log(Level.INFO, "Downloaded MySQLPlayerDataBridge data for " + playersMigrated + " players...");
}
}
}
}
}
plugin.log(Level.INFO, "Completed download of " + dataToMigrate.size() + " entries from the MySQLPlayerDataBridge database!");
plugin.log(Level.INFO, "Converting raw MySQLPlayerDataBridge data to HuskSync user data (this might take a while)...");
final AtomicInteger playersConverted = new AtomicInteger();
dataToMigrate.forEach(data -> {
final DataSnapshot.Packed convertedData = data.toUserData(mpdbConverter, plugin);
plugin.getDatabase().ensureUser(data.user());
plugin.getDatabase().addSnapshot(data.user(), convertedData);
playersConverted.getAndIncrement();
if (playersConverted.get() % 50 == 0) {
plugin.log(Level.INFO, "Converted MySQLPlayerDataBridge data for " + playersConverted + " players...");
}
});
plugin.log(Level.INFO, "Migration complete for " + dataToMigrate.size() + " users in " + ((System.currentTimeMillis() - startTime) / 1000) + " seconds!");
return true;
} catch (Exception e) {
plugin.log(Level.SEVERE, "Error while migrating data: " + e.getMessage() + " - are your source database credentials correct?");
return false;
}
});
}
@Override
public void handleConfigurationCommand(@NotNull String[] args) {
if (args.length == 2) {
if (switch (args[0].toLowerCase(Locale.ENGLISH)) {
case "host" -> {
this.sourceHost = args[1];
yield true;
}
case "port" -> {
try {
this.sourcePort = Integer.parseInt(args[1]);
yield true;
} catch (NumberFormatException e) {
yield false;
}
}
case "username" -> {
this.sourceUsername = args[1];
yield true;
}
case "password" -> {
this.sourcePassword = args[1];
yield true;
}
case "database" -> {
this.sourceDatabase = args[1];
yield true;
}
case "inventory_table" -> {
this.sourceInventoryTable = args[1];
yield true;
}
case "ender_chest_table" -> {
this.sourceEnderChestTable = args[1];
yield true;
}
case "experience_table" -> {
this.sourceExperienceTable = args[1];
yield true;
}
default -> false;
}) {
plugin.log(Level.INFO, getHelpMenu());
plugin.log(Level.INFO, "Successfully set " + args[0] + " to " +
obfuscateDataString(args[1]));
} else {
plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " +
obfuscateDataString(args[1]) + " (is it a valid option?)");
}
} else {
plugin.log(Level.INFO, getHelpMenu());
}
}
@NotNull
@Override
public String getIdentifier() {
return "mpdb";
}
@NotNull
@Override
public String getName() {
return "MySQLPlayerDataBridge Migrator";
}
@NotNull
@Override
public String getHelpMenu() {
return """
=== MySQLPlayerDataBridge Migration Wizard ==========
This will migrate inventories, ender chests and XP
from the MySQLPlayerDataBridge plugin to HuskSync.
To prevent excessive migration times, other non-vital
data will not be transferred.
[!] Existing data in the database will be wiped. [!]
STEP 1] Please ensure no players are on any servers.
STEP 2] HuskSync will need to connect to the database
used to hold the source MySQLPlayerDataBridge data.
Please check these database parameters are OK:
- host: %source_host%
- port: %source_port%
- username: %source_username%
- password: %source_password%
- database: %source_database%
- inventory_table: %source_inventory_table%
- ender_chest_table: %source_ender_chest_table%
- experience_table: %source_xp_table%
If any of these are not correct, please correct them
using the command:
"husksync migrate mpdb set <parameter> <value>"
(e.g.: "husksync migrate mpdb set host 1.2.3.4")
STEP 3] HuskSync will migrate data into the database
tables configures in the config.yml file of this
server. Please make sure you're happy with this
before proceeding.
STEP 4] To start the migration, please run:
"husksync migrate mpdb start"
""".replaceAll(Pattern.quote("%source_host%"), obfuscateDataString(sourceHost))
.replaceAll(Pattern.quote("%source_port%"), Integer.toString(sourcePort))
.replaceAll(Pattern.quote("%source_username%"), obfuscateDataString(sourceUsername))
.replaceAll(Pattern.quote("%source_password%"), obfuscateDataString(sourcePassword))
.replaceAll(Pattern.quote("%source_database%"), sourceDatabase)
.replaceAll(Pattern.quote("%source_inventory_table%"), sourceInventoryTable)
.replaceAll(Pattern.quote("%source_ender_chest_table%"), sourceEnderChestTable)
.replaceAll(Pattern.quote("%source_xp_table%"), sourceExperienceTable);
}
/**
* Represents data exported from the MySQLPlayerDataBridge source database
*
* @param user The user whose data is being migrated
* @param serializedInventory The serialized inventory data
* @param serializedArmor The serialized armor data
* @param serializedEnderChest The serialized ender chest data
* @param expLevel The player's current XP level
* @param expProgress The player's current XP progress
* @param totalExp The player's total XP score
*/
private record MpdbData(
@NotNull User user,
@NotNull String serializedInventory,
@NotNull String serializedArmor,
@NotNull String serializedEnderChest,
int expLevel,
float expProgress,
int totalExp
) {
/**
* Converts exported MySQLPlayerDataBridge data into HuskSync's {@link DataSnapshot} object format
*
* @param converter The {@link MPDBConverter} to use for converting to {@link ItemStack}s
* @return A {@link CompletableFuture} that will resolve to the converted {@link DataSnapshot} object
*/
@NotNull
public DataSnapshot.Packed toUserData(@NotNull MPDBConverter converter, @NotNull HuskSync plugin) {
// Combine inventory and armor
final Inventory inventory = Bukkit.createInventory(null, InventoryType.PLAYER);
inventory.setContents(converter.getItemStackFromSerializedData(serializedInventory));
final ItemStack[] armor = converter.getItemStackFromSerializedData(serializedArmor).clone();
for (int i = 36; i < 36 + armor.length; i++) {
inventory.setItem(i, armor[i - 36]);
}
final ItemStack[] enderChest = converter.getItemStackFromSerializedData(serializedEnderChest);
// Create user data record
return DataSnapshot.builder(plugin)
.inventory(BukkitData.Items.Inventory.from(inventory.getContents(), 0))
.enderChest(BukkitData.Items.EnderChest.adapt(enderChest))
.experience(BukkitData.Experience.from(totalExp, expLevel, expProgress))
.gameMode(BukkitData.GameMode.from("SURVIVAL", false, false))
.saveCause(DataSnapshot.SaveCause.MPDB_MIGRATION)
.buildAndPack();
}
}
}

View File

@@ -0,0 +1,152 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.user;
import de.themoep.minedown.adventure.MineDown;
import dev.triumphteam.gui.builder.gui.StorageBuilder;
import dev.triumphteam.gui.guis.Gui;
import dev.triumphteam.gui.guis.StorageGui;
import net.kyori.adventure.audience.Audience;
import net.roxeez.advancement.display.FrameType;
import net.william278.andjam.Toast;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.BukkitData;
import net.william278.husksync.data.BukkitUserDataHolder;
import net.william278.husksync.data.Data;
import org.bukkit.Material;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.function.Consumer;
import java.util.logging.Level;
/**
* Bukkit platform implementation of an {@link OnlineUser}
*/
public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
private final HuskSync plugin;
private final Player player;
private BukkitUser(@NotNull Player player, @NotNull HuskSync plugin) {
super(player.getUniqueId(), player.getName());
this.player = player;
this.plugin = plugin;
}
@NotNull
@ApiStatus.Internal
public static BukkitUser adapt(@NotNull Player player, @NotNull HuskSync plugin) {
return new BukkitUser(player, plugin);
}
/**
* Get the Bukkit {@link Player} instance of this user
*
* @return the {@link Player} instance
* @since 3.0
*/
@NotNull
public Player getPlayer() {
return player;
}
@Override
public boolean isOffline() {
return player == null || !player.isOnline();
}
@NotNull
@Override
public Audience getAudience() {
return ((BukkitHuskSync) plugin).getAudiences().player(player);
}
@Override
public void sendToast(@NotNull MineDown title, @NotNull MineDown description,
@NotNull String iconMaterial, @NotNull String backgroundType) {
try {
final Material material = Material.matchMaterial(iconMaterial);
Toast.builder((BukkitHuskSync) plugin)
.setTitle(title.toComponent())
.setDescription(description.toComponent())
.setIcon(material != null ? material : Material.BARRIER)
.setFrameType(FrameType.valueOf(backgroundType))
.build()
.show(player);
} catch (Throwable e) {
plugin.log(Level.WARNING, "Failed to send toast to player " + player.getName(), e);
}
}
@Override
public void showGui(@NotNull Data.Items items, @NotNull MineDown title, boolean editable, int size,
@NotNull Consumer<Data.Items> onClose) {
final ItemStack[] contents = ((BukkitData.Items) items).getContents();
final StorageBuilder builder = Gui.storage().rows((int) Math.ceil(size / 9.0d));
if (!editable) {
builder.disableAllInteractions();
}
final StorageGui gui = builder.enableOtherActions()
.apply(a -> a.getInventory().setContents(contents))
.title(title.toComponent()).create();
gui.setCloseGuiAction((close) -> onClose.accept(BukkitData.Items.ItemArray.adapt(
Arrays.stream(close.getInventory().getContents()).limit(size).toArray(ItemStack[]::new)
)));
plugin.runSync(() -> gui.open(player));
}
@Override
public boolean hasPermission(@NotNull String node) {
return player.hasPermission(node);
}
@Override
public boolean isDead() {
return player.getHealth() <= 0;
}
@Override
public boolean isLocked() {
return plugin.getLockedPlayers().contains(player.getUniqueId());
}
@Override
public boolean isNpc() {
return player.hasMetadata("NPC");
}
@NotNull
@Override
public Player getBukkitPlayer() {
return player;
}
@NotNull
@Override
@ApiStatus.Internal
public HuskSync getPlugin() {
return plugin;
}
}

View File

@@ -0,0 +1,280 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.util;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.data.BukkitData;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier;
import org.bukkit.Material;
import org.bukkit.Statistic;
import org.bukkit.entity.EntityType;
import org.bukkit.inventory.ItemStack;
import org.bukkit.util.io.BukkitObjectInputStream;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.json.JSONArray;
import org.json.JSONObject;
import org.yaml.snakeyaml.external.biz.base64Coder.Base64Coder;
import java.io.ByteArrayInputStream;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.logging.Level;
public class BukkitLegacyConverter extends LegacyConverter {
public BukkitLegacyConverter(@NotNull HuskSync plugin) {
super(plugin);
}
@NotNull
@Override
public DataSnapshot.Packed convert(@NotNull byte[] data, @NotNull UUID id,
@NotNull OffsetDateTime timestamp) throws DataAdapter.AdaptionException {
final JSONObject object = new JSONObject(plugin.getDataAdapter().bytesToString(data));
final int version = object.getInt("format_version");
if (version != 3) {
plugin.log(Level.WARNING, String.format("Converting data from older v2 data format (%s).", version));
}
// Read legacy data from the JSON object
final DataSnapshot.Builder builder = DataSnapshot.builder(plugin)
.id(id).timestamp(timestamp)
.saveCause(DataSnapshot.SaveCause.CONVERTED_FROM_V2)
.data(readStatusData(object));
readInventory(object).ifPresent(builder::inventory);
readEnderChest(object).ifPresent(builder::enderChest);
readLocation(object).ifPresent(builder::location);
readAdvancements(object).ifPresent(builder::advancements);
readStatistics(object).ifPresent(builder::statistics);
return builder.buildAndPack();
}
@NotNull
private Map<Identifier, Data> readStatusData(@NotNull JSONObject object) {
if (!object.has("status_data")) {
return Map.of();
}
final JSONObject status = object.getJSONObject("status_data");
final HashMap<Identifier, Data> containers = new HashMap<>();
if (shouldImport(Identifier.HEALTH)) {
containers.put(Identifier.HEALTH, BukkitData.Health.from(
status.getDouble("health"),
status.getDouble("max_health"),
status.getDouble("health_scale")
));
}
if (shouldImport(Identifier.HUNGER)) {
containers.put(Identifier.HUNGER, BukkitData.Hunger.from(
status.getInt("hunger"),
status.getFloat("saturation"),
status.getFloat("saturation_exhaustion")
));
}
if (shouldImport(Identifier.EXPERIENCE)) {
containers.put(Identifier.EXPERIENCE, BukkitData.Experience.from(
status.getInt("total_experience"),
status.getInt("experience_level"),
status.getFloat("experience_progress")
));
}
if (shouldImport(Identifier.GAME_MODE)) {
containers.put(Identifier.GAME_MODE, BukkitData.GameMode.from(
status.getString("game_mode"),
status.getBoolean("is_flying"),
status.getBoolean("is_flying")
));
}
return containers;
}
@NotNull
private Optional<Data.Items.Inventory> readInventory(@NotNull JSONObject object) {
if (!object.has("inventory") || !shouldImport(Identifier.INVENTORY)) {
return Optional.empty();
}
final JSONObject inventoryData = object.getJSONObject("inventory");
return Optional.of(BukkitData.Items.Inventory.from(
deserializeLegacyItemStacks(inventoryData.getString("serialized_items")), 0
));
}
@NotNull
private Optional<Data.Items.EnderChest> readEnderChest(@NotNull JSONObject object) {
if (!object.has("ender_chest") || !shouldImport(Identifier.ENDER_CHEST)) {
return Optional.empty();
}
final JSONObject inventoryData = object.getJSONObject("ender_chest");
return Optional.of(BukkitData.Items.EnderChest.adapt(
deserializeLegacyItemStacks(inventoryData.getString("serialized_items"))
));
}
@NotNull
private Optional<Data.Location> readLocation(@NotNull JSONObject object) {
if (!object.has("location") || !shouldImport(Identifier.LOCATION)) {
return Optional.empty();
}
final JSONObject locationData = object.getJSONObject("location");
return Optional.of(BukkitData.Location.from(
locationData.getDouble("x"),
locationData.getDouble("y"),
locationData.getDouble("z"),
locationData.getFloat("yaw"),
locationData.getFloat("pitch"),
new Data.Location.World(
locationData.getString("world_name"),
UUID.fromString(locationData.getString("world_uuid")),
locationData.getString("world_environment")
)
));
}
@NotNull
private Optional<Data.Advancements> readAdvancements(@NotNull JSONObject object) {
if (!object.has("advancements") || !shouldImport(Identifier.ADVANCEMENTS)) {
return Optional.empty();
}
final JSONArray advancements = object.getJSONArray("advancements");
final List<Data.Advancements.Advancement> converted = new ArrayList<>();
advancements.iterator().forEachRemaining(o -> {
final JSONObject advancement = (JSONObject) JSONObject.wrap(o);
final String key = advancement.getString("key");
final JSONObject criteria = advancement.getJSONObject("completed_criteria");
final Map<String, Date> criteriaMap = new LinkedHashMap<>();
criteria.keys().forEachRemaining(criteriaKey -> criteriaMap.put(
criteriaKey, parseDate(criteria.getString(criteriaKey)))
);
converted.add(Data.Advancements.Advancement.adapt(key, criteriaMap));
});
return Optional.of(BukkitData.Advancements.from(converted));
}
@NotNull
private Optional<Data.Statistics> readStatistics(@NotNull JSONObject object) {
if (!object.has("statistics") || !shouldImport(Identifier.ADVANCEMENTS)) {
return Optional.empty();
}
final JSONObject stats = object.getJSONObject("statistics");
return Optional.of(readStatisticMaps(
stats.getJSONObject("untyped_statistics"),
stats.getJSONObject("block_statistics"),
stats.getJSONObject("item_statistics"),
stats.getJSONObject("entity_statistics")
));
}
@NotNull
private BukkitData.Statistics readStatisticMaps(@NotNull JSONObject untyped, @NotNull JSONObject blocks,
@NotNull JSONObject items, @NotNull JSONObject entities) {
final Map<Statistic, Integer> genericStats = new HashMap<>();
untyped.keys().forEachRemaining(stat -> genericStats.put(Statistic.valueOf(stat), untyped.getInt(stat)));
final Map<Statistic, Map<Material, Integer>> blockStats = new HashMap<>();
blocks.keys().forEachRemaining(stat -> {
final JSONObject blockStat = blocks.getJSONObject(stat);
final Map<Material, Integer> blockMap = new HashMap<>();
blockStat.keys().forEachRemaining(block -> blockMap.put(Material.valueOf(block), blockStat.getInt(block)));
blockStats.put(Statistic.valueOf(stat), blockMap);
});
final Map<Statistic, Map<Material, Integer>> itemStats = new HashMap<>();
items.keys().forEachRemaining(stat -> {
final JSONObject itemStat = items.getJSONObject(stat);
final Map<Material, Integer> itemMap = new HashMap<>();
itemStat.keys().forEachRemaining(item -> itemMap.put(Material.valueOf(item), itemStat.getInt(item)));
itemStats.put(Statistic.valueOf(stat), itemMap);
});
final Map<Statistic, Map<EntityType, Integer>> entityStats = new HashMap<>();
entities.keys().forEachRemaining(stat -> {
final JSONObject entityStat = entities.getJSONObject(stat);
final Map<EntityType, Integer> entityMap = new HashMap<>();
entityStat.keys().forEachRemaining(entity -> entityMap.put(EntityType.valueOf(entity), entityStat.getInt(entity)));
entityStats.put(Statistic.valueOf(stat), entityMap);
});
return BukkitData.Statistics.from(genericStats, blockStats, itemStats, entityStats);
}
// Deserialize a legacy item stack array
@NotNull
public ItemStack[] deserializeLegacyItemStacks(@NotNull String items) {
// Return an empty array if there is no inventory data (set the player as having an empty inventory)
if (items.isEmpty()) {
return new ItemStack[0];
}
// Create a byte input stream to read the serialized data
try (ByteArrayInputStream byteInputStream = new ByteArrayInputStream(Base64Coder.decodeLines(items))) {
try (BukkitObjectInputStream bukkitInputStream = new BukkitObjectInputStream(byteInputStream)) {
// Read the length of the Bukkit input stream and set the length of the array to this value
final ItemStack[] inventoryContents = new ItemStack[bukkitInputStream.readInt()];
// Set the ItemStacks in the array from deserialized ItemStack data
int slotIndex = 0;
for (ItemStack ignored : inventoryContents) {
final ItemStack deserialized = deserializeLegacyItemStack(bukkitInputStream.readObject());
inventoryContents[slotIndex] = deserialized;
slotIndex++;
}
// Return the converted contents
return inventoryContents;
}
} catch (Throwable e) {
throw new DataAdapter.AdaptionException("Failed to deserialize legacy item stack data", e);
}
}
// Deserialize a single legacy item stack
@Nullable
private static ItemStack deserializeLegacyItemStack(@Nullable Object serializedItemStack) {
return serializedItemStack != null ? ItemStack.deserialize((Map<String, Object>) serializedItemStack) : null;
}
private boolean shouldImport(@NotNull Identifier type) {
return plugin.getSettings().isSyncFeatureEnabled(type);
}
@NotNull
private Date parseDate(@NotNull String dateString) {
try {
return new SimpleDateFormat().parse(dateString);
} catch (ParseException e) {
return new Date();
}
}
}

View File

@@ -0,0 +1,383 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.util;
import de.tr7zw.changeme.nbtapi.NBT;
import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT;
import de.tr7zw.changeme.nbtapi.iface.ReadableNBT;
import net.william278.husksync.HuskSync;
import net.william278.mapdataapi.MapBanner;
import net.william278.mapdataapi.MapData;
import org.bukkit.Bukkit;
import org.bukkit.Material;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.meta.MapMeta;
import org.bukkit.map.*;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.awt.*;
import java.util.List;
import java.util.*;
import java.util.function.Function;
import java.util.logging.Level;
public interface BukkitMapPersister {
// The map used to store HuskSync data in ItemStack NBT
String MAP_DATA_KEY = "husksync:persisted_locked_map";
// The key used to store the serialized map data in NBT
String MAP_PIXEL_DATA_KEY = "canvas_data";
// The key used to store the map of World UIDs to MapView IDs in NBT
String MAP_VIEW_ID_MAPPINGS_KEY = "id_mappings";
/**
* Persist locked maps in an array of {@link ItemStack}s
*
* @param items the array of {@link ItemStack}s to persist locked maps in
* @param delegateRenderer the player to delegate the rendering of map pixel canvases to
* @return the array of {@link ItemStack}s with locked maps persisted to serialized NBT
*/
@NotNull
default ItemStack[] persistLockedMaps(@NotNull ItemStack[] items, @NotNull Player delegateRenderer) {
if (!getPlugin().getSettings().doPersistLockedMaps()) {
return items;
}
return forEachMap(items, map -> this.persistMapView(map, delegateRenderer));
}
/**
* Apply persisted locked maps to an array of {@link ItemStack}s
*
* @param items the array of {@link ItemStack}s to apply persisted locked maps to
* @return the array of {@link ItemStack}s with persisted locked maps applied
*/
@NotNull
default ItemStack[] setMapViews(@NotNull ItemStack[] items) {
if (!getPlugin().getSettings().doPersistLockedMaps()) {
return items;
}
return forEachMap(items, this::applyMapView);
}
// Perform an operation on each map in an array of ItemStacks
@NotNull
private ItemStack[] forEachMap(@NotNull ItemStack[] items, @NotNull Function<ItemStack, ItemStack> function) {
for (int i = 0; i < items.length; i++) {
final ItemStack item = items[i];
if (item == null) {
continue;
}
if (item.getType() == Material.FILLED_MAP && item.hasItemMeta()) {
items[i] = function.apply(item);
}
}
return items;
}
@NotNull
private ItemStack persistMapView(@NotNull ItemStack map, @NotNull Player delegateRenderer) {
final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta());
if (!meta.hasMapView()) {
return map;
}
final MapView view = meta.getMapView();
if (view == null || view.getWorld() == null || !view.isLocked() || view.isVirtual()) {
return map;
}
NBT.modify(map, nbt -> {
// Don't save the map's data twice
if (nbt.hasTag(MAP_DATA_KEY)) {
return;
}
// Render the map
final PersistentMapCanvas canvas = new PersistentMapCanvas(view);
for (MapRenderer renderer : view.getRenderers()) {
renderer.render(view, canvas, delegateRenderer);
getPlugin().debug(String.format("Rendered locked map canvas to view (#%s)", view.getId()));
}
// Persist map data
final ReadWriteNBT mapData = nbt.getOrCreateCompound(MAP_DATA_KEY);
final String worldUid = view.getWorld().getUID().toString();
mapData.setByteArray(MAP_PIXEL_DATA_KEY, canvas.extractMapData().toBytes());
nbt.getOrCreateCompound(MAP_VIEW_ID_MAPPINGS_KEY).setInteger(worldUid, view.getId());
getPlugin().debug(String.format("Saved data for locked map (#%s, UID: %s)", view.getId(), worldUid));
});
return map;
}
@NotNull
private ItemStack applyMapView(@NotNull ItemStack map) {
final MapMeta meta = Objects.requireNonNull((MapMeta) map.getItemMeta());
NBT.get(map, nbt -> {
if (!nbt.hasTag(MAP_DATA_KEY)) {
return;
}
final ReadableNBT mapData = nbt.getCompound(MAP_DATA_KEY);
final ReadableNBT mapIds = nbt.getCompound(MAP_VIEW_ID_MAPPINGS_KEY);
if (mapData == null || mapIds == null) {
return;
}
// Search for an existing map view
Optional<String> world = Optional.empty();
for (String worldUid : mapIds.getKeys()) {
world = Bukkit.getWorlds().stream()
.map(w -> w.getUID().toString()).filter(u -> u.equals(worldUid))
.findFirst();
if (world.isPresent()) {
break;
}
}
if (world.isPresent()) {
final String uid = world.get();
final Optional<MapView> existingView = this.getMapView(mapIds.getInteger(uid));
if (existingView.isPresent()) {
final MapView view = existingView.get();
view.setLocked(true);
meta.setMapView(view);
map.setItemMeta(meta);
getPlugin().debug(String.format("View exists (#%s); updated map (UID: %s)", view.getId(), uid));
return;
}
}
// Read the pixel data and generate a map view otherwise
final MapData canvasData;
try {
getPlugin().debug("Deserializing map data from NBT and generating view...");
canvasData = MapData.fromByteArray(Objects.requireNonNull(mapData.getByteArray(MAP_PIXEL_DATA_KEY),
"Map pixel data is null"));
} catch (Throwable e) {
getPlugin().log(Level.WARNING, "Failed to deserialize map data from NBT", e);
return;
}
// Add a renderer to the map with the data
final MapView view = generateRenderedMap(canvasData);
final String worldUid = getDefaultMapWorld().getUID().toString();
meta.setMapView(view);
map.setItemMeta(meta);
// Set the map view ID in NBT
NBT.modify(map, editable -> {
Objects.requireNonNull(editable.getCompound(MAP_VIEW_ID_MAPPINGS_KEY),
"Map view ID mappings compound is null")
.setInteger(worldUid, view.getId());
});
getPlugin().debug(String.format("Generated view (#%s) and updated map (UID: %s)", view.getId(), worldUid));
});
return map;
}
// Sets the renderer of a map, and returns the generated MapView
@NotNull
private MapView generateRenderedMap(@NotNull MapData canvasData) {
final MapView view = Bukkit.createMap(getDefaultMapWorld());
view.getRenderers().clear();
// Create a new map view renderer with the map data color at each pixel
view.addRenderer(new PersistentMapRenderer(canvasData));
view.setLocked(true);
view.setScale(MapView.Scale.NORMAL);
view.setTrackingPosition(false);
view.setUnlimitedTracking(false);
// Set the view to the map and return it
setMapView(view);
return view;
}
@NotNull
private static World getDefaultMapWorld() {
final World world = Bukkit.getWorlds().get(0);
if (world == null) {
throw new IllegalStateException("No worlds are loaded on the server!");
}
return world;
}
default Optional<MapView> getMapView(int id) {
return getMapViews().containsKey(id) ? Optional.of(getMapViews().get(id)) : Optional.empty();
}
default void setMapView(@NotNull MapView view) {
getMapViews().put(view.getId(), view);
}
/**
* A {@link MapRenderer} that can be used to render persistently serialized {@link MapData} to a {@link MapView}
*/
class PersistentMapRenderer extends MapRenderer {
private final MapData canvasData;
private PersistentMapRenderer(@NotNull MapData canvasData) {
super(false);
this.canvasData = canvasData;
}
@Override
public void render(@NotNull MapView map, @NotNull MapCanvas canvas, @NotNull Player player) {
// We set the pixels in this order to avoid the map being rendered upside down
for (int i = 0; i < 128; i++) {
for (int j = 0; j < 128; j++) {
canvas.setPixel(j, i, (byte) canvasData.getColorAt(i, j));
}
}
// Set the map banners and markers
final MapCursorCollection cursors = canvas.getCursors();
canvasData.getBanners().forEach(banner -> cursors.addCursor(createBannerCursor(banner)));
canvas.setCursors(cursors);
}
}
@NotNull
private static MapCursor createBannerCursor(@NotNull MapBanner banner) {
return new MapCursor(
(byte) banner.getPosition().getX(),
(byte) banner.getPosition().getZ(),
(byte) 0,
switch (banner.getColor().toLowerCase(Locale.ENGLISH)) {
case "white" -> MapCursor.Type.BANNER_WHITE;
case "orange" -> MapCursor.Type.BANNER_ORANGE;
case "magenta" -> MapCursor.Type.BANNER_MAGENTA;
case "light_blue" -> MapCursor.Type.BANNER_LIGHT_BLUE;
case "yellow" -> MapCursor.Type.BANNER_YELLOW;
case "lime" -> MapCursor.Type.BANNER_LIME;
case "pink" -> MapCursor.Type.BANNER_PINK;
case "gray" -> MapCursor.Type.BANNER_GRAY;
case "light_gray" -> MapCursor.Type.BANNER_LIGHT_GRAY;
case "cyan" -> MapCursor.Type.BANNER_CYAN;
case "purple" -> MapCursor.Type.BANNER_PURPLE;
case "blue" -> MapCursor.Type.BANNER_BLUE;
case "brown" -> MapCursor.Type.BANNER_BROWN;
case "green" -> MapCursor.Type.BANNER_GREEN;
case "red" -> MapCursor.Type.BANNER_RED;
default -> MapCursor.Type.BANNER_BLACK;
},
true,
banner.getText().isEmpty() ? null : banner.getText()
);
}
/**
* A {@link MapCanvas} implementation used for pre-rendering maps to be converted into {@link MapData}
*/
class PersistentMapCanvas implements MapCanvas {
private final MapView mapView;
private final int[][] pixels = new int[128][128];
private MapCursorCollection cursors;
private PersistentMapCanvas(@NotNull MapView mapView) {
this.mapView = mapView;
}
@NotNull
@Override
public MapView getMapView() {
return mapView;
}
@NotNull
@Override
public MapCursorCollection getCursors() {
return cursors == null ? (cursors = new MapCursorCollection()) : cursors;
}
@Override
public void setCursors(@NotNull MapCursorCollection cursors) {
this.cursors = cursors;
}
@Override
public void setPixel(int x, int y, byte color) {
pixels[x][y] = color;
}
@Override
public byte getPixel(int x, int y) {
return (byte) pixels[x][y];
}
@Override
public byte getBasePixel(int x, int y) {
return getPixel(x, y);
}
@Override
public void drawImage(int x, int y, @NotNull Image image) {
// Not implemented
}
@Override
public void drawText(int x, int y, @NotNull MapFont font, @NotNull String text) {
// Not implemented
}
@NotNull
private String getDimension() {
return mapView.getWorld() != null ? switch (mapView.getWorld().getEnvironment()) {
case NETHER -> "minecraft:the_nether";
case THE_END -> "minecraft:the_end";
default -> "minecraft:overworld";
} : "minecraft:overworld";
}
/**
* Extract the map data from the canvas. Must be rendered first
*
* @return the extracted map data
*/
@NotNull
private MapData extractMapData() {
final List<MapBanner> banners = new ArrayList<>();
for (int i = 0; i < getCursors().size(); i++) {
final MapCursor cursor = getCursors().getCursor(i);
final String type = cursor.getType().name().toLowerCase(Locale.ENGLISH);
if (type.startsWith("banner_")) {
banners.add(new MapBanner(
type.replaceAll("banner_", ""),
cursor.getCaption() == null ? "" : cursor.getCaption(),
cursor.getX(),
mapView.getWorld() != null ? mapView.getWorld().getSeaLevel() : 128,
cursor.getY()
));
}
}
return MapData.fromPixels(pixels, getDimension(), (byte) 2, banners, List.of());
}
}
@NotNull
Map<Integer, MapView> getMapViews();
@ApiStatus.Internal
@NotNull
HuskSync getPlugin();
}

View File

@@ -0,0 +1,172 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.util;
import net.william278.husksync.BukkitHuskSync;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import space.arim.morepaperlib.scheduling.AsynchronousScheduler;
import space.arim.morepaperlib.scheduling.RegionalScheduler;
import space.arim.morepaperlib.scheduling.ScheduledTask;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
public interface BukkitTask extends Task {
class Sync extends Task.Sync implements BukkitTask {
private ScheduledTask task;
protected Sync(@NotNull HuskSync plugin, @NotNull Runnable runnable, long delayTicks) {
super(plugin, runnable, delayTicks);
}
@Override
public void cancel() {
if (task != null && !cancelled) {
task.cancel();
}
super.cancel();
}
@Override
public void run() {
if (isPluginDisabled()) {
runnable.run();
return;
}
if (cancelled) {
return;
}
final RegionalScheduler scheduler = ((BukkitHuskSync) getPlugin()).getRegionalScheduler();
if (delayTicks > 0) {
this.task = scheduler.runDelayed(runnable, delayTicks);
} else {
this.task = scheduler.run(runnable);
}
}
}
class Async extends Task.Async implements BukkitTask {
private ScheduledTask task;
protected Async(@NotNull HuskSync plugin, @NotNull Runnable runnable, long delayTicks) {
super(plugin, runnable, delayTicks);
}
@Override
public void cancel() {
if (task != null && !cancelled) {
task.cancel();
}
super.cancel();
}
@Override
public void run() {
if (isPluginDisabled()) {
runnable.run();
return;
}
if (cancelled) {
return;
}
final AsynchronousScheduler scheduler = ((BukkitHuskSync) getPlugin()).getAsyncScheduler();
if (delayTicks > 0) {
plugin.debug("Running async task with delay of " + delayTicks + " ticks");
this.task = scheduler.runDelayed(
runnable,
Duration.of(delayTicks * 50L, ChronoUnit.MILLIS)
);
} else {
this.task = scheduler.run(runnable);
}
}
}
class Repeating extends Task.Repeating implements BukkitTask {
private ScheduledTask task;
protected Repeating(@NotNull HuskSync plugin, @NotNull Runnable runnable, long repeatingTicks) {
super(plugin, runnable, repeatingTicks);
}
@Override
public void cancel() {
if (task != null && !cancelled) {
task.cancel();
}
super.cancel();
}
@Override
public void run() {
if (isPluginDisabled()) {
return;
}
if (!cancelled) {
final AsynchronousScheduler scheduler = ((BukkitHuskSync) getPlugin()).getAsyncScheduler();
this.task = scheduler.runAtFixedRate(
runnable, Duration.ZERO,
Duration.of(repeatingTicks * 50L, ChronoUnit.MILLIS)
);
}
}
}
// Returns if the Bukkit HuskSync plugin is disabled
default boolean isPluginDisabled() {
return !((BukkitHuskSync) getPlugin()).isEnabled();
}
interface Supplier extends Task.Supplier {
@NotNull
@Override
default Task.Sync getSyncTask(@NotNull Runnable runnable, long delayTicks) {
return new Sync(getPlugin(), runnable, delayTicks);
}
@NotNull
@Override
default Task.Async getAsyncTask(@NotNull Runnable runnable, long delayTicks) {
return new Async(getPlugin(), runnable, delayTicks);
}
@NotNull
@Override
default Task.Repeating getRepeatingTask(@NotNull Runnable runnable, long repeatingTicks) {
return new Repeating(getPlugin(), runnable, repeatingTicks);
}
@Override
default void cancelTasks() {
((BukkitHuskSync) getPlugin()).getScheduler().cancelGlobalTasks();
}
}
}

View File

@@ -0,0 +1,3 @@
inventory {
name brigadier:string single_word;
}

View File

@@ -0,0 +1,5 @@
husksync {
update;
about;
reload;
}

View File

@@ -0,0 +1,3 @@
enderchest {
name brigadier:string single_word;
}

View File

@@ -0,0 +1,35 @@
userdata {
view {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
list {
name brigadier:string single_word {
page brigadier:integer;
}
}
delete {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
restore {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
pin {
name brigadier:string single_word {
version brigadier:string single_word;
}
}
dump {
name brigadier:string single_word {
version brigadier:string single_word {
web;
file;
}
}
}
}

View File

@@ -1,22 +0,0 @@
redis_settings:
host: 'localhost'
port: 6379
password: ''
use_ssl: false
synchronisation_settings:
inventories: true
ender_chests: true
health: true
hunger: true
experience: true
potion_effects: true
statistics: true
game_mode: true
advancements: true
location: false
flight: false
cluster_id: 'main'
check_for_updates: true
synchronization_timeout_retry_delay: 15
save_on_world_save: true
native_advancement_synchronization: false

View File

@@ -1,8 +1,15 @@
name: HuskSync
version: ${version}
main: net.william278.husksync.HuskSyncBukkit
name: 'HuskSync'
version: '${version}'
main: 'net.william278.husksync.BukkitHuskSync'
api-version: 1.16
author: William278
description: 'A modern, cross-server player data synchronization system'
author: 'William278'
description: '${description}'
website: 'https://william278.net'
softdepend: [MysqlPlayerDataBridge]
softdepend:
- 'MysqlPlayerDataBridge'
- 'Plan'
libraries:
- 'redis.clients:jedis:${jedis_version}'
- 'com.mysql:mysql-connector-j:${mysql_driver_version}'
- 'org.mariadb.jdbc:mariadb-java-client:${mariadb_driver_version}'
- 'org.xerial.snappy:snappy-java:${snappy_version}'

View File

@@ -1,23 +0,0 @@
dependencies {
implementation project(path: ':common')
implementation 'com.zaxxer:HikariCP:5.0.1'
implementation 'org.bstats:bstats-bungeecord:3.0.0'
implementation 'de.themoep:minedown:1.7.1-SNAPSHOT'
implementation 'net.byteflux:libby-bungee:1.1.5'
compileOnly 'net.md-5:bungeecord-api:1.16-R0.5-SNAPSHOT'
}
shadowJar {
relocate 'de.themoep', 'net.william278.husksync.libraries'
relocate 'net.byteflux', 'net.william278.husksync.libraries'
relocate 'org.bstats', 'net.william278.husksync.libraries.bstats'
relocate 'redis.clients', 'net.william278.husksync.libraries'
relocate 'org.apache', 'net.william278.husksync.libraries'
dependencies {
//noinspection GroovyAssignabilityCheck
exclude dependency(':slf4j-api')
}
}

View File

@@ -1,171 +0,0 @@
package net.william278.husksync;
import net.byteflux.libby.BungeeLibraryManager;
import net.byteflux.libby.Library;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.plugin.Plugin;
import net.william278.husksync.bungeecord.command.BungeeCommand;
import net.william278.husksync.bungeecord.config.ConfigLoader;
import net.william278.husksync.bungeecord.config.ConfigManager;
import net.william278.husksync.bungeecord.listener.BungeeEventListener;
import net.william278.husksync.bungeecord.listener.BungeeRedisListener;
import net.william278.husksync.bungeecord.util.BungeeLogger;
import net.william278.husksync.bungeecord.util.BungeeUpdateChecker;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.proxy.data.DataManager;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.Logger;
import org.bstats.bungeecord.Metrics;
import java.io.IOException;
import java.util.HashSet;
import java.util.Objects;
import java.util.logging.Level;
public final class HuskSyncBungeeCord extends Plugin {
// BungeeCord bStats ID (different to Bukkit)
private static final int METRICS_ID = 13141;
private static HuskSyncBungeeCord instance;
public static HuskSyncBungeeCord getInstance() {
return instance;
}
// Whether the plugin is ready to accept redis messages
public static boolean readyForRedis = false;
// Whether the plugin is in the process of disabling and should skip responding to handshake confirmations
public static boolean isDisabling = false;
/**
* Set of all the {@link Server}s that have completed the synchronisation handshake with HuskSync on the proxy
*/
public static HashSet<Server> synchronisedServers;
public static DataManager dataManager;
public static MPDBMigrator mpdbMigrator;
public static BungeeRedisListener redisListener;
private Logger logger;
public Logger getBungeeLogger() {
return logger;
}
@Override
public void onLoad() {
instance = this;
logger = new BungeeLogger(getLogger());
fetchDependencies();
}
@Override
public void onEnable() {
// Plugin startup logic
synchronisedServers = new HashSet<>();
// Load config
ConfigManager.loadConfig();
// Load settings from config
ConfigLoader.loadSettings(Objects.requireNonNull(ConfigManager.getConfig()));
// Load messages
ConfigManager.loadMessages();
// Load locales from messages
ConfigLoader.loadMessageStrings(Objects.requireNonNull(ConfigManager.getMessages()));
// Do update checker
if (Settings.automaticUpdateChecks) {
new BungeeUpdateChecker(getDescription().getVersion()).logToConsole();
}
// Setup data manager
dataManager = new DataManager(getBungeeLogger(), getDataFolder());
// Ensure the data manager initialized correctly
if (dataManager.hasFailedInitialization) {
getBungeeLogger().severe("Failed to initialize the HuskSync database(s).\n" +
"HuskSync will now abort loading itself (" + getProxy().getName() + ") v" + getDescription().getVersion());
}
// Setup player data cache
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
dataManager.playerDataCache.put(cluster, new DataManager.PlayerDataCache());
}
// Initialize the redis listener
redisListener = new BungeeRedisListener();
// Register listener
getProxy().getPluginManager().registerListener(this, new BungeeEventListener());
// Register command
getProxy().getPluginManager().registerCommand(this, new BungeeCommand());
// Prepare the migrator for use if needed
mpdbMigrator = new MPDBMigrator(getBungeeLogger());
// Initialize bStats metrics
try {
new Metrics(this, METRICS_ID);
} catch (Exception e) {
getBungeeLogger().info("Skipped metrics initialization");
}
// Log to console
getBungeeLogger().info("Enabled HuskSync (" + getProxy().getName() + ") v" + getDescription().getVersion());
// Mark as ready for redis message processing
readyForRedis = true;
}
@Override
public void onDisable() {
// Plugin shutdown logic
isDisabling = true;
// Send terminating handshake message
for (Server server : synchronisedServers) {
try {
new RedisMessage(RedisMessage.MessageType.TERMINATE_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, server.clusterId()),
server.serverUUID().toString(),
ProxyServer.getInstance().getName()).send();
} catch (IOException e) {
getBungeeLogger().log(Level.SEVERE, "Failed to serialize Redis message for handshake termination", e);
}
}
dataManager.closeDatabases();
// Log to console
getBungeeLogger().info("Disabled HuskSync (" + getProxy().getName() + ") v" + getDescription().getVersion());
}
// Load dependencies
private void fetchDependencies() {
BungeeLibraryManager manager = new BungeeLibraryManager(getInstance());
Library mySqlLib = Library.builder()
.groupId("mysql")
.artifactId("mysql-connector-java")
.version("8.0.29")
.build();
Library sqLiteLib = Library.builder()
.groupId("org.xerial")
.artifactId("sqlite-jdbc")
.version("3.36.0.3")
.build();
manager.addMavenCentral();
manager.loadLibrary(mySqlLib);
manager.loadLibrary(sqLiteLib);
}
}

View File

@@ -1,424 +0,0 @@
package net.william278.husksync.bungeecord.command;
import de.themoep.minedown.MineDown;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Server;
import net.william278.husksync.Settings;
import net.william278.husksync.bungeecord.config.ConfigLoader;
import net.william278.husksync.bungeecord.config.ConfigManager;
import net.william278.husksync.bungeecord.util.BungeeUpdateChecker;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.proxy.command.HuskSyncCommand;
import net.william278.husksync.redis.RedisMessage;
import net.william278.husksync.util.MessageManager;
import net.md_5.bungee.api.CommandSender;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.plugin.Command;
import net.md_5.bungee.api.plugin.TabExecutor;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Locale;
import java.util.Objects;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class BungeeCommand extends Command implements TabExecutor, HuskSyncCommand {
private final static HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
public BungeeCommand() {
super("husksync", null, "hs");
}
@Override
public void execute(CommandSender sender, String[] args) {
if (sender instanceof ProxiedPlayer player) {
if (HuskSyncBungeeCord.synchronisedServers.size() == 0) {
player.sendMessage(new MineDown(MessageManager.getMessage("error_no_servers_proxied")).toComponent());
return;
}
if (args.length >= 1) {
switch (args[0].toLowerCase(Locale.ROOT)) {
case "about", "info" -> sendAboutInformation(player);
case "update" -> {
if (!player.hasPermission("husksync.command.inventory")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
sender.sendMessage(new MineDown("[Checking for HuskSync updates...](gray)").toComponent());
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Check Bukkit servers needing updates
int updatesNeeded = 0;
String bukkitBrand = "Spigot";
String bukkitVersion = "1.0";
for (Server server : HuskSyncBungeeCord.synchronisedServers) {
BungeeUpdateChecker updateChecker = new BungeeUpdateChecker(server.huskSyncVersion());
if (!updateChecker.isUpToDate()) {
updatesNeeded++;
bukkitBrand = server.serverBrand();
bukkitVersion = server.huskSyncVersion();
}
}
// Check Bungee servers needing updates and send message
BungeeUpdateChecker proxyUpdateChecker = new BungeeUpdateChecker(plugin.getDescription().getVersion());
if (proxyUpdateChecker.isUpToDate() && updatesNeeded == 0) {
sender.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| HuskSync is up-to-date, running Version " + proxyUpdateChecker.getLatestVersion() + "](#00fb9a)").toComponent());
} else {
sender.sendMessage(new MineDown("[HuskSync](#00fb9a bold) [| Your server(s) are not up-to-date:](#00fb9a)").toComponent());
if (!proxyUpdateChecker.isUpToDate()) {
sender.sendMessage(new MineDown("[•](white) [HuskSync on the " + ProxyServer.getInstance().getName() + " proxy is outdated (Latest: " + proxyUpdateChecker.getLatestVersion() + ", Running: " + proxyUpdateChecker.getCurrentVersion() + ")](#00fb9a)").toComponent());
}
if (updatesNeeded > 0) {
sender.sendMessage(new MineDown("[•](white) [HuskSync on " + updatesNeeded + " connected " + bukkitBrand + " server(s) are outdated (Latest: " + proxyUpdateChecker.getLatestVersion() + ", Running: " + bukkitVersion + ")](#00fb9a)").toComponent());
}
sender.sendMessage(new MineDown("[•](white) [Download links:](#00fb9a) [[⏩ Spigot]](gray open_url=https://www.spigotmc.org/resources/husktowns.92672/updates) [•](#262626) [[⏩ Polymart]](gray open_url=https://polymart.org/resource/husktowns.1056/updates)").toComponent());
}
});
}
case "invsee", "openinv", "inventory" -> {
if (!player.hasPermission("husksync.command.inventory")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
String clusterId;
if (Settings.clusters.size() > 1) {
if (args.length == 3) {
clusterId = args[2];
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
return;
}
} else {
clusterId = "main";
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
clusterId = cluster.clusterId();
break;
}
}
if (args.length == 2 || args.length == 3) {
String playerName = args[1];
openInventory(player, playerName, clusterId);
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_syntax").replaceAll("%1%",
"/husksync invsee <player>")).toComponent());
}
}
case "echest", "enderchest" -> {
if (!player.hasPermission("husksync.command.ender_chest")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
String clusterId;
if (Settings.clusters.size() > 1) {
if (args.length == 3) {
clusterId = args[2];
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
return;
}
} else {
clusterId = "main";
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
clusterId = cluster.clusterId();
break;
}
}
if (args.length == 2 || args.length == 3) {
String playerName = args[1];
openEnderChest(player, playerName, clusterId);
} else {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_syntax")
.replaceAll("%1%", "/husksync echest <player>")).toComponent());
}
}
case "migrate" -> {
if (!player.hasPermission("husksync.command.admin")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
sender.sendMessage(new MineDown(MessageManager.getMessage("error_console_command_only")
.replaceAll("%1%", ProxyServer.getInstance().getName())).toComponent());
}
case "status" -> {
if (!player.hasPermission("husksync.command.admin")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
int playerDataSize = 0;
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
playerDataSize += HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).playerData.size();
}
sender.sendMessage(new MineDown(MessageManager.PLUGIN_STATUS.toString()
.replaceAll("%1%", String.valueOf(HuskSyncBungeeCord.synchronisedServers.size()))
.replaceAll("%2%", String.valueOf(playerDataSize))).toComponent());
}
case "reload" -> {
if (!player.hasPermission("husksync.command.admin")) {
sender.sendMessage(new MineDown(MessageManager.getMessage("error_no_permission")).toComponent());
return;
}
ConfigManager.loadConfig();
ConfigLoader.loadSettings(Objects.requireNonNull(ConfigManager.getConfig()));
ConfigManager.loadMessages();
ConfigLoader.loadMessageStrings(Objects.requireNonNull(ConfigManager.getMessages()));
// Send reload request to all bukkit servers
try {
new RedisMessage(RedisMessage.MessageType.RELOAD_CONFIG,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, null),
"reload")
.send();
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize reload notification message data");
}
sender.sendMessage(new MineDown(MessageManager.getMessage("reload_complete")).toComponent());
}
default -> sender.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_syntax").replaceAll("%1%",
"/husksync <about/status/invsee/echest>")).toComponent());
}
} else {
sendAboutInformation(player);
}
} else {
// Database migration wizard
if (args.length >= 1) {
if (args[0].equalsIgnoreCase("migrate")) {
MPDBMigrator migrator = HuskSyncBungeeCord.mpdbMigrator;
if (args.length == 1) {
sender.sendMessage(new MineDown(
"""
=== MySQLPlayerDataBridge Migration Wizard ==========
This will migrate data from the MySQLPlayerDataBridge
plugin to HuskSync.
Data that will be migrated:
- Inventories
- Ender Chests
- Experience points
Other non-vital data, such as current health, hunger
& potion effects will not be migrated to ensure that
migration does not take an excessive amount of time.
To do this, you need to have MySqlPlayerDataBridge
and HuskSync installed on one Spigot server as well
as HuskSync installed on the proxy (which you have)
>To proceed, type: husksync migrate setup""").toComponent());
} else {
switch (args[1].toLowerCase()) {
case "setup" -> sender.sendMessage(new MineDown(
"""
=== MySQLPlayerDataBridge Migration Wizard ==========
The following database settings will be used.
Please make sure they match the correct settings to
access your MySQLPlayerDataBridge Data
sourceHost: %1%
sourcePort: %2%
sourceDatabase: %3%
sourceUsername: %4%
sourcePassword: %5%
sourceInventoryTableName: %6%
sourceEnderChestTableName: %7%
sourceExperienceTableName: %8%
targetCluster: %9%
To change a setting, type:
husksync migrate setting <settingName> <value>
Please ensure no players are logged in to the network
and that at least one Spigot server is online with
both HuskSync AND MySqlPlayerDataBridge installed AND
that the server has been configured with the correct
Redis credentials.
Warning: Data will be saved to your configured data
source, which is currently a %10% database.
Please make sure you are happy with this, or stop
the proxy server and edit this in config.yml
Warning: Migration will overwrite any current data
saved by HuskSync. It will not, however, delete any
data from the source MySQLPlayerDataBridge database.
>When done, type: husksync migrate start"""
.replaceAll("%1%", migrator.migrationSettings.sourceHost)
.replaceAll("%2%", String.valueOf(migrator.migrationSettings.sourcePort))
.replaceAll("%3%", migrator.migrationSettings.sourceDatabase)
.replaceAll("%4%", migrator.migrationSettings.sourceUsername)
.replaceAll("%5%", migrator.migrationSettings.sourcePassword)
.replaceAll("%6%", migrator.migrationSettings.inventoryDataTable)
.replaceAll("%7%", migrator.migrationSettings.enderChestDataTable)
.replaceAll("%8%", migrator.migrationSettings.expDataTable)
.replaceAll("%9%", migrator.migrationSettings.targetCluster)
.replaceAll("%10%", Settings.dataStorageType.toString())
).toComponent());
case "setting" -> {
if (args.length == 4) {
String value = args[3];
switch (args[2]) {
case "sourceHost", "host" -> migrator.migrationSettings.sourceHost = value;
case "sourcePort", "port" -> {
try {
migrator.migrationSettings.sourcePort = Integer.parseInt(value);
} catch (NumberFormatException e) {
sender.sendMessage(new MineDown("Error: Invalid value; port must be a number").toComponent());
return;
}
}
case "sourceDatabase", "database" -> migrator.migrationSettings.sourceDatabase = value;
case "sourceUsername", "username" -> migrator.migrationSettings.sourceUsername = value;
case "sourcePassword", "password" -> migrator.migrationSettings.sourcePassword = value;
case "sourceInventoryTableName", "inventoryTableName", "inventoryTable" -> migrator.migrationSettings.inventoryDataTable = value;
case "sourceEnderChestTableName", "enderChestTableName", "enderChestTable" -> migrator.migrationSettings.enderChestDataTable = value;
case "sourceExperienceTableName", "experienceTableName", "experienceTable" -> migrator.migrationSettings.expDataTable = value;
case "targetCluster", "cluster" -> migrator.migrationSettings.targetCluster = value;
default -> {
sender.sendMessage(new MineDown("Error: Invalid setting; please use \"husksync migrate setup\" to view a list").toComponent());
return;
}
}
sender.sendMessage(new MineDown("Successfully updated setting: \"" + args[2] + "\" --> \"" + value + "\"").toComponent());
} else {
sender.sendMessage(new MineDown("Error: Invalid usage. Syntax: husksync migrate setting <settingName> <value>").toComponent());
}
}
case "start" -> {
sender.sendMessage(new MineDown("Starting MySQLPlayerDataBridge migration!...").toComponent());
// If the migrator is ready, execute the migration asynchronously
if (HuskSyncBungeeCord.mpdbMigrator.readyToMigrate(ProxyServer.getInstance().getOnlineCount(),
HuskSyncBungeeCord.synchronisedServers)) {
ProxyServer.getInstance().getScheduler().runAsync(plugin, () ->
HuskSyncBungeeCord.mpdbMigrator.executeMigrationOperations(HuskSyncBungeeCord.dataManager,
HuskSyncBungeeCord.synchronisedServers, HuskSyncBungeeCord.redisListener));
}
}
default -> sender.sendMessage(new MineDown("Error: Invalid argument for migration. Use \"husksync migrate\" to start the process").toComponent());
}
}
return;
}
}
sender.sendMessage(new MineDown("Error: Invalid syntax. Usage: husksync migrate <args>").toComponent());
}
}
// View the inventory of a player specified by their name
private void openInventory(ProxiedPlayer viewer, String targetPlayerName, String clusterId) {
if (viewer.getName().equalsIgnoreCase(targetPlayerName)) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_own_inventory")).toComponent());
return;
}
if (ProxyServer.getInstance().getPlayer(targetPlayerName) != null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_inventory_online")).toComponent());
return;
}
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (!cluster.clusterId().equals(clusterId)) continue;
PlayerData playerData = HuskSyncBungeeCord.dataManager.getPlayerDataByName(targetPlayerName, cluster.clusterId());
if (playerData == null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_player")).toComponent());
return;
}
try {
new RedisMessage(RedisMessage.MessageType.OPEN_INVENTORY,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, viewer.getUniqueId(), null),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_inventory_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
return;
}
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
});
}
// View the ender chest of a player specified by their name
public void openEnderChest(ProxiedPlayer viewer, String targetPlayerName, String clusterId) {
if (viewer.getName().equalsIgnoreCase(targetPlayerName)) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_own_ender_chest")).toComponent());
return;
}
if (ProxyServer.getInstance().getPlayer(targetPlayerName) != null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_cannot_view_ender_chest_online")).toComponent());
return;
}
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (!cluster.clusterId().equals(clusterId)) continue;
PlayerData playerData = HuskSyncBungeeCord.dataManager.getPlayerDataByName(targetPlayerName, cluster.clusterId());
if (playerData == null) {
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_player")).toComponent());
return;
}
try {
new RedisMessage(RedisMessage.MessageType.OPEN_ENDER_CHEST,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, viewer.getUniqueId(), null),
targetPlayerName, RedisMessage.serialize(playerData))
.send();
viewer.sendMessage(new MineDown(MessageManager.getMessage("viewing_ender_chest_of").replaceAll("%1%",
targetPlayerName)).toComponent());
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize inventory-see player data", e);
}
return;
}
viewer.sendMessage(new MineDown(MessageManager.getMessage("error_invalid_cluster")).toComponent());
});
}
/**
* Send information about the plugin
*
* @param player The player to send it to
*/
private void sendAboutInformation(ProxiedPlayer player) {
try {
new RedisMessage(RedisMessage.MessageType.SEND_PLUGIN_INFORMATION,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, player.getUniqueId(), null),
plugin.getProxy().getName(), plugin.getDescription().getVersion()).send();
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.WARNING, "Failed to serialize plugin information to send", e);
}
}
// Tab completion
@Override
public Iterable<String> onTabComplete(CommandSender sender, String[] args) {
if (sender instanceof ProxiedPlayer player) {
if (args.length == 1) {
final ArrayList<String> subCommands = new ArrayList<>();
for (SubCommand subCommand : SUB_COMMANDS) {
if (subCommand.permission() != null) {
if (!player.hasPermission(subCommand.permission())) {
continue;
}
}
subCommands.add(subCommand.command());
}
// Automatically filter the sub commands' order in tab completion by what the player has typed
return subCommands.stream().filter(val -> val.startsWith(args[0]))
.sorted().collect(Collectors.toList());
} else {
return Collections.emptyList();
}
}
return Collections.emptyList();
}
}

View File

@@ -1,84 +0,0 @@
package net.william278.husksync.bungeecord.config;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.Settings;
import net.william278.husksync.util.MessageManager;
import net.md_5.bungee.config.Configuration;
import java.util.HashMap;
public class ConfigLoader {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
private static Configuration copyDefaults(Configuration config) {
// Get the config version and update if needed
String configVersion = config.getString("config_file_version", "1.0");
if (configVersion.contains("-dev")) {
configVersion = configVersion.replaceAll("-dev", "");
}
if (!configVersion.equals(plugin.getDescription().getVersion())) {
if (configVersion.equalsIgnoreCase("1.0")) {
config.set("check_for_updates", true);
}
if (configVersion.equalsIgnoreCase("1.0") || configVersion.equalsIgnoreCase("1.0.1") || configVersion.equalsIgnoreCase("1.0.2") || configVersion.equalsIgnoreCase("1.0.3")) {
config.set("clusters.main.player_table", "husksync_players");
config.set("clusters.main.data_table", "husksync_data");
}
config.set("config_file_version", plugin.getDescription().getVersion());
}
// Save the config back
ConfigManager.saveConfig(config);
return config;
}
public static void loadSettings(Configuration loadedConfig) throws IllegalArgumentException {
Configuration config = copyDefaults(loadedConfig);
Settings.language = config.getString("language", "en-gb");
Settings.serverType = Settings.ServerType.PROXY;
Settings.automaticUpdateChecks = config.getBoolean("check_for_updates", true);
Settings.redisHost = config.getString("redis_settings.host", "localhost");
Settings.redisPort = config.getInt("redis_settings.port", 6379);
Settings.redisPassword = config.getString("redis_settings.password", "");
Settings.redisSSL = config.getBoolean("redis_settings.use_ssl", false);
Settings.dataStorageType = Settings.DataStorageType.valueOf(config.getString("data_storage_settings.database_type", "sqlite").toUpperCase());
if (Settings.dataStorageType == Settings.DataStorageType.MYSQL) {
Settings.mySQLHost = config.getString("data_storage_settings.mysql_settings.host", "localhost");
Settings.mySQLPort = config.getInt("data_storage_settings.mysql_settings.port", 3306);
Settings.mySQLDatabase = config.getString("data_storage_settings.mysql_settings.database", "HuskSync");
Settings.mySQLUsername = config.getString("data_storage_settings.mysql_settings.username", "root");
Settings.mySQLPassword = config.getString("data_storage_settings.mysql_settings.password", "pa55w0rd");
Settings.mySQLParams = config.getString("data_storage_settings.mysql_settings.params", "?autoReconnect=true&useSSL=false");
}
Settings.hikariMaximumPoolSize = config.getInt("data_storage_settings.hikari_pool_settings.maximum_pool_size", 10);
Settings.hikariMinimumIdle = config.getInt("data_storage_settings.hikari_pool_settings.minimum_idle", 10);
Settings.hikariMaximumLifetime = config.getLong("data_storage_settings.hikari_pool_settings.maximum_lifetime", 1800000);
Settings.hikariKeepAliveTime = config.getLong("data_storage_settings.hikari_pool_settings.keepalive_time", 0);
Settings.hikariConnectionTimeOut = config.getLong("data_storage_settings.hikari_pool_settings.connection_timeout", 5000);
Settings.bounceBackSynchronisation = config.getBoolean("bounce_back_synchronization", true);
// Read cluster data
Configuration section = config.getSection("clusters");
final String settingDatabaseName = Settings.mySQLDatabase != null ? Settings.mySQLDatabase : "HuskSync";
for (String clusterId : section.getKeys()) {
final String playerTableName = config.getString("clusters." + clusterId + ".player_table", "husksync_players");
final String dataTableName = config.getString("clusters." + clusterId + ".data_table", "husksync_data");
final String databaseName = config.getString("clusters." + clusterId + ".database", settingDatabaseName);
Settings.clusters.add(new Settings.SynchronisationCluster(clusterId, databaseName, playerTableName, dataTableName));
}
}
public static void loadMessageStrings(Configuration config) {
final HashMap<String,String> messages = new HashMap<>();
for (String messageId : config.getKeys()) {
messages.put(messageId, config.getString(messageId));
}
MessageManager.setMessages(messages);
}
}

View File

@@ -1,81 +0,0 @@
package net.william278.husksync.bungeecord.config;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.Settings;
import net.md_5.bungee.config.Configuration;
import net.md_5.bungee.config.ConfigurationProvider;
import net.md_5.bungee.config.YamlConfiguration;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.util.logging.Level;
public class ConfigManager {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
public static void loadConfig() {
try {
if (!plugin.getDataFolder().exists()) {
if (plugin.getDataFolder().mkdir()) {
plugin.getBungeeLogger().info("Created HuskSync data folder");
}
}
File configFile = new File(plugin.getDataFolder(), "config.yml");
if (!configFile.exists()) {
Files.copy(plugin.getResourceAsStream("proxy-config.yml"), configFile.toPath());
plugin.getBungeeLogger().info("Created HuskSync config file");
}
} catch (Exception e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e);
}
}
public static void saveConfig(Configuration config) {
try {
ConfigurationProvider.getProvider(YamlConfiguration.class).save(config, new File(plugin.getDataFolder(), "config.yml"));
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An exception occurred loading the configuration file", e);
}
}
public static void loadMessages() {
try {
if (!plugin.getDataFolder().exists()) {
if (plugin.getDataFolder().mkdir()) {
plugin.getBungeeLogger().info("Created HuskSync data folder");
}
}
File messagesFile = new File(plugin.getDataFolder(), "messages_" + Settings.language + ".yml");
if (!messagesFile.exists()) {
Files.copy(plugin.getResourceAsStream("languages/" + Settings.language + ".yml"), messagesFile.toPath());
plugin.getBungeeLogger().info("Created HuskSync messages file");
}
} catch (Exception e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An exception occurred loading the messages file", e);
}
}
public static Configuration getConfig() {
try {
File configFile = new File(plugin.getDataFolder(), "config.yml");
return ConfigurationProvider.getProvider(YamlConfiguration.class).load(configFile);
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An IOException occurred fetching the configuration file", e);
return null;
}
}
public static Configuration getMessages() {
try {
File configFile = new File(plugin.getDataFolder(), "messages_" + Settings.language + ".yml");
return ConfigurationProvider.getProvider(YamlConfiguration.class).load(configFile);
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.CONFIG, "An IOException occurred fetching the messages file", e);
return null;
}
}
}

View File

@@ -1,50 +0,0 @@
package net.william278.husksync.bungeecord.listener;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import net.md_5.bungee.api.event.PostLoginEvent;
import net.md_5.bungee.api.plugin.Listener;
import net.md_5.bungee.event.EventHandler;
import net.md_5.bungee.event.EventPriority;
import java.io.IOException;
import java.util.Map;
import java.util.logging.Level;
public class BungeeEventListener implements Listener {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
@EventHandler(priority = EventPriority.LOWEST)
public void onPostLogin(PostLoginEvent event) {
final ProxiedPlayer player = event.getPlayer();
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Ensure the player has data on SQL and that it is up-to-date
HuskSyncBungeeCord.dataManager.ensurePlayerExists(player.getUniqueId(), player.getName());
// Get the player's data from SQL
final Map<Settings.SynchronisationCluster, PlayerData> data = HuskSyncBungeeCord.dataManager.getPlayerData(player.getUniqueId());
// Update the player's data from SQL onto the cache
assert data != null;
for (Settings.SynchronisationCluster cluster : data.keySet()) {
HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).updatePlayer(data.get(cluster));
}
// Send a message asking the bukkit to request data on join
try {
new RedisMessage(RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, null),
RedisMessage.RequestOnJoinUpdateType.ADD_REQUESTER.toString(), player.getUniqueId().toString()).send();
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.SEVERE, "Failed to serialize request data on join message data");
e.printStackTrace();
}
});
}
}

View File

@@ -1,234 +0,0 @@
package net.william278.husksync.bungeecord.listener;
import de.themoep.minedown.MineDown;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.Server;
import net.william278.husksync.util.MessageManager;
import net.william278.husksync.PlayerData;
import net.william278.husksync.Settings;
import net.william278.husksync.migrator.MPDBMigrator;
import net.william278.husksync.redis.RedisListener;
import net.william278.husksync.redis.RedisMessage;
import net.md_5.bungee.api.ChatMessageType;
import net.md_5.bungee.api.ProxyServer;
import net.md_5.bungee.api.connection.ProxiedPlayer;
import java.io.IOException;
import java.util.Objects;
import java.util.UUID;
import java.util.logging.Level;
public class BungeeRedisListener extends RedisListener {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
// Initialize the listener on the bungee
public BungeeRedisListener() {
super();
listen();
}
private PlayerData getPlayerCachedData(UUID uuid, String clusterId) {
PlayerData data = null;
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (cluster.clusterId().equals(clusterId)) {
// Get the player data from the cache
PlayerData cachedData = HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).getPlayer(uuid);
if (cachedData != null) {
return cachedData;
}
data = Objects.requireNonNull(HuskSyncBungeeCord.dataManager.getPlayerData(uuid)).get(cluster); // Get their player data from MySQL
HuskSyncBungeeCord.dataManager.playerDataCache.get(cluster).updatePlayer(data); // Update the cache
break;
}
}
return data; // Return the data
}
/**
* Handle an incoming {@link RedisMessage}
*
* @param message The {@link RedisMessage} to handle
*/
@Override
public void handleMessage(RedisMessage message) {
// Ignore messages destined for Bukkit servers
if (message.getMessageTarget().targetServerType() != Settings.ServerType.PROXY) {
return;
}
// Only process redis messages when ready
if (!HuskSyncBungeeCord.readyForRedis) {
return;
}
switch (message.getMessageType()) {
case PLAYER_DATA_REQUEST -> ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Get the UUID of the requesting player
final UUID requestingPlayerUUID = UUID.fromString(message.getMessageData());
try {
// Send the reply, serializing the message data
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, requestingPlayerUUID, message.getMessageTarget().targetClusterId()),
RedisMessage.serialize(getPlayerCachedData(requestingPlayerUUID, message.getMessageTarget().targetClusterId())))
.send();
// Send an update to all bukkit servers removing the player from the requester cache
new RedisMessage(RedisMessage.MessageType.REQUEST_DATA_ON_JOIN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
RedisMessage.RequestOnJoinUpdateType.REMOVE_REQUESTER.toString(), requestingPlayerUUID.toString())
.send();
// Send synchronisation complete message
ProxiedPlayer player = ProxyServer.getInstance().getPlayer(requestingPlayerUUID);
if (player != null) {
player.sendMessage(ChatMessageType.ACTION_BAR, new MineDown(MessageManager.getMessage("synchronisation_complete")).toComponent());
}
} catch (IOException e) {
log(Level.SEVERE, "Failed to serialize data when replying to a data request");
e.printStackTrace();
}
});
case PLAYER_DATA_UPDATE -> ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
// Deserialize the PlayerData received
PlayerData playerData;
final String serializedPlayerData = message.getMessageDataElements()[0];
final boolean bounceBack = Boolean.parseBoolean(message.getMessageDataElements()[1]);
try {
playerData = (PlayerData) RedisMessage.deserialize(serializedPlayerData);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling a player update request");
e.printStackTrace();
return;
}
// Update the data in the cache and SQL
for (Settings.SynchronisationCluster cluster : Settings.clusters) {
if (cluster.clusterId().equals(message.getMessageTarget().targetClusterId())) {
HuskSyncBungeeCord.dataManager.updatePlayerData(playerData, cluster);
break;
}
}
// Reply with the player data if they are still online (switching server)
if (Settings.bounceBackSynchronisation && bounceBack) {
try {
ProxiedPlayer player = ProxyServer.getInstance().getPlayer(playerData.getPlayerUUID());
if (player != null) {
new RedisMessage(RedisMessage.MessageType.PLAYER_DATA_SET,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, playerData.getPlayerUUID(), message.getMessageTarget().targetClusterId()),
serializedPlayerData)
.send();
// Send synchronisation complete message
player.sendMessage(ChatMessageType.ACTION_BAR, new MineDown(MessageManager.getMessage("synchronisation_complete")).toComponent());
}
} catch (IOException e) {
log(Level.SEVERE, "Failed to re-serialize PlayerData when handling a player update request");
e.printStackTrace();
}
}
});
case CONNECTION_HANDSHAKE -> {
// Reply to a Bukkit server's connection handshake to complete the process
if (HuskSyncBungeeCord.isDisabling) return; // Return if the Proxy is disabling
final UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
final boolean hasMySqlPlayerDataBridge = Boolean.parseBoolean(message.getMessageDataElements()[1]);
final String bukkitBrand = message.getMessageDataElements()[2];
final String huskSyncVersion = message.getMessageDataElements()[3];
try {
new RedisMessage(RedisMessage.MessageType.CONNECTION_HANDSHAKE,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
serverUUID.toString(), plugin.getProxy().getName())
.send();
HuskSyncBungeeCord.synchronisedServers.add(
new Server(serverUUID, hasMySqlPlayerDataBridge,
huskSyncVersion, bukkitBrand, message.getMessageTarget().targetClusterId()));
log(Level.INFO, "Completed handshake with " + bukkitBrand + " server (" + serverUUID + ")");
} catch (IOException e) {
log(Level.SEVERE, "Failed to serialize handshake message data");
e.printStackTrace();
}
}
case TERMINATE_HANDSHAKE -> {
// Terminate the handshake with a Bukkit server
final UUID serverUUID = UUID.fromString(message.getMessageDataElements()[0]);
final String bukkitBrand = message.getMessageDataElements()[1];
// Remove a server from the synchronised server list
Server serverToRemove = null;
for (Server server : HuskSyncBungeeCord.synchronisedServers) {
if (server.serverUUID().equals(serverUUID)) {
serverToRemove = server;
break;
}
}
HuskSyncBungeeCord.synchronisedServers.remove(serverToRemove);
log(Level.INFO, "Terminated the handshake with " + bukkitBrand + " server (" + serverUUID + ")");
}
case DECODED_MPDB_DATA_SET -> {
// Deserialize the PlayerData received
PlayerData playerData;
final String serializedPlayerData = message.getMessageDataElements()[0];
final String playerName = message.getMessageDataElements()[1];
try {
playerData = (PlayerData) RedisMessage.deserialize(serializedPlayerData);
} catch (IOException | ClassNotFoundException e) {
log(Level.SEVERE, "Failed to deserialize PlayerData when handling incoming decoded MPDB data");
e.printStackTrace();
return;
}
// Get the migrator
MPDBMigrator migrator = HuskSyncBungeeCord.mpdbMigrator;
// Add the incoming data to the data to be saved
migrator.incomingPlayerData.put(playerData, playerName);
// Increment players migrated
migrator.playersMigrated++;
plugin.getBungeeLogger().log(Level.INFO, "Migrated " + migrator.playersMigrated + "/" + migrator.migratedDataSent + " players.");
// When all the data has been received, save it
if (migrator.migratedDataSent == migrator.playersMigrated) {
ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> migrator.loadIncomingData(migrator.incomingPlayerData,
HuskSyncBungeeCord.dataManager));
}
}
case API_DATA_REQUEST -> ProxyServer.getInstance().getScheduler().runAsync(plugin, () -> {
final UUID playerUUID = UUID.fromString(message.getMessageDataElements()[0]);
final UUID requestUUID = UUID.fromString(message.getMessageDataElements()[1]);
try {
final PlayerData data = getPlayerCachedData(playerUUID, message.getMessageTarget().targetClusterId());
if (data == null) {
new RedisMessage(RedisMessage.MessageType.API_DATA_CANCEL,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
requestUUID.toString())
.send();
} else {
// Send the reply alongside the request UUID, serializing the requested message data
new RedisMessage(RedisMessage.MessageType.API_DATA_RETURN,
new RedisMessage.MessageTarget(Settings.ServerType.BUKKIT, null, message.getMessageTarget().targetClusterId()),
requestUUID.toString(),
RedisMessage.serialize(data))
.send();
}
} catch (IOException e) {
plugin.getBungeeLogger().log(Level.SEVERE, "Failed to serialize PlayerData requested via the API");
}
});
}
}
/**
* Log to console
*
* @param level The {@link Level} to log
* @param message Message to log
*/
@Override
public void log(Level level, String message) {
plugin.getBungeeLogger().log(level, message);
}
}

View File

@@ -1,33 +0,0 @@
package net.william278.husksync.bungeecord.util;
import net.william278.husksync.util.Logger;
import java.util.logging.Level;
public record BungeeLogger(java.util.logging.Logger parent) implements Logger {
@Override
public void log(Level level, String message, Exception e) {
parent.log(level, message, e);
}
@Override
public void log(Level level, String message) {
parent.log(level, message);
}
@Override
public void info(String message) {
parent.info(message);
}
@Override
public void severe(String message) {
parent.severe(message);
}
@Override
public void config(String message) {
parent.config(message);
}
}

View File

@@ -1,20 +0,0 @@
package net.william278.husksync.bungeecord.util;
import net.william278.husksync.HuskSyncBungeeCord;
import net.william278.husksync.util.UpdateChecker;
import java.util.logging.Level;
public class BungeeUpdateChecker extends UpdateChecker {
private static final HuskSyncBungeeCord plugin = HuskSyncBungeeCord.getInstance();
public BungeeUpdateChecker(String versionToCheck) {
super(versionToCheck);
}
@Override
public void log(Level level, String message) {
plugin.getBungeeLogger().log(level, message);
}
}

View File

@@ -1,5 +0,0 @@
name: HuskSync
version: ${version}
main: net.william278.husksync.HuskSyncBungeeCord
author: William278
description: 'A modern, cross-server player data synchronization system'

View File

@@ -1,11 +1,33 @@
plugins {
id 'java'
id 'java-library'
}
dependencies {
compileOnly 'com.zaxxer:HikariCP:5.0.1'
}
api 'commons-io:commons-io:2.13.0'
api 'org.apache.commons:commons-text:1.10.0'
api 'de.themoep:minedown-adventure:1.7.2-SNAPSHOT'
api 'net.kyori:adventure-api:4.14.0'
api 'org.json:json:20230618'
api 'com.google.code.gson:gson:2.10.1'
api 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2'
api 'dev.dejvokep:boosted-yaml:1.3.1'
api 'net.william278:annotaml:2.0.7'
api 'net.william278:DesertWell:2.0.4'
api 'net.william278:PagineDown:1.1'
api('com.zaxxer:HikariCP:5.0.1') {
exclude module: 'slf4j-api'
}
shadowJar {
relocate 'com.zaxxer', 'net.william278.husksync.libraries'
compileOnly 'org.jetbrains:annotations:24.0.1'
compileOnly 'com.github.plan-player-analytics:Plan:5.5.2272'
compileOnly "redis.clients:jedis:$jedis_version"
compileOnly "com.mysql:mysql-connector-j:$mysql_driver_version"
compileOnly "org.mariadb.jdbc:mariadb-java-client:$mariadb_driver_version"
compileOnly "org.xerial.snappy:snappy-java:$snappy_version"
testImplementation 'com.github.plan-player-analytics:Plan:5.5.2272'
testImplementation "redis.clients:jedis:$jedis_version"
testImplementation "org.xerial.snappy:snappy-java:$snappy_version"
testCompileOnly 'dev.dejvokep:boosted-yaml:1.3.1'
testCompileOnly 'org.jetbrains:annotations:24.0.1'
}

View File

@@ -0,0 +1,341 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync;
import com.fatboyindustrial.gsonjavatime.Converters;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;
import net.william278.annotaml.Annotaml;
import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.desertwell.util.UpdateChecker;
import net.william278.desertwell.util.Version;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.config.Locales;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer;
import net.william278.husksync.database.Database;
import net.william278.husksync.event.EventDispatcher;
import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.redis.RedisManager;
import net.william278.husksync.user.ConsoleUser;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.util.LegacyConverter;
import net.william278.husksync.util.Task;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.reflect.InvocationTargetException;
import java.util.*;
import java.util.logging.Level;
/**
* Abstract implementation of the HuskSync plugin.
*/
public interface HuskSync extends Task.Supplier, EventDispatcher {
int SPIGOT_RESOURCE_ID = 97144;
/**
* Returns a set of online players.
*
* @return a set of online players as {@link OnlineUser}
*/
@NotNull
Set<OnlineUser> getOnlineUsers();
/**
* Returns an online user by UUID if they exist
*
* @param uuid the UUID of the user to get
* @return an online user as {@link OnlineUser}
*/
@NotNull
Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid);
/**
* Returns the database implementation
*
* @return the {@link Database} implementation
*/
@NotNull
Database getDatabase();
/**
* Returns the redis manager implementation
*
* @return the {@link RedisManager} implementation
*/
@NotNull
RedisManager getRedisManager();
@NotNull
DataAdapter getDataAdapter();
/**
* Returns the data serializer for the given {@link Identifier}
*/
@NotNull
<T extends Data> Map<Identifier, Serializer<T>> getSerializers();
/**
* Register a data serializer for the given {@link Identifier}
*
* @param identifier the {@link Identifier}
* @param serializer the {@link Serializer}
*/
default void registerSerializer(@NotNull Identifier identifier,
@NotNull Serializer<? extends Data> serializer) {
if (identifier.isCustom()) {
log(Level.INFO, String.format("Registered custom data type: %s", identifier));
}
getSerializers().put(identifier, (Serializer<Data>) serializer);
}
/**
* Get the {@link Identifier} for the given key
*/
default Optional<Identifier> getIdentifier(@NotNull String key) {
return getSerializers().keySet().stream().filter(identifier -> identifier.toString().equals(key)).findFirst();
}
/**
* Get the set of registered data types
*
* @return the set of registered data types
*/
@NotNull
default Set<Identifier> getRegisteredDataTypes() {
return getSerializers().keySet();
}
/**
* Returns a list of available data {@link Migrator}s
*
* @return a list of {@link Migrator}s
*/
@NotNull
List<Migrator> getAvailableMigrators();
@NotNull
Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user);
/**
* Initialize a faucet of the plugin.
*
* @param name the name of the faucet
* @param runner a runnable for initializing the faucet
*/
default void initialize(@NotNull String name, @NotNull ThrowingConsumer<HuskSync> runner) {
log(Level.INFO, "Initializing " + name + "...");
try {
runner.accept(this);
} catch (Throwable e) {
throw new FailedToLoadException("Failed to initialize " + name, e);
}
log(Level.INFO, "Successfully initialized " + name);
}
/**
* Returns the plugin {@link Settings}
*
* @return the {@link Settings}
*/
@NotNull
Settings getSettings();
void setSettings(@NotNull Settings settings);
/**
* Returns the plugin {@link Locales}
*
* @return the {@link Locales}
*/
@NotNull
Locales getLocales();
void setLocales(@NotNull Locales locales);
/**
* Returns if a dependency is loaded
*
* @param name the name of the dependency
* @return {@code true} if the dependency is loaded, {@code false} otherwise
*/
boolean isDependencyLoaded(@NotNull String name);
/**
* Get a resource as an {@link InputStream} from the plugin jar
*
* @param name the path to the resource
* @return the {@link InputStream} of the resource
*/
InputStream getResource(@NotNull String name);
/**
* Returns the plugin data folder
*
* @return the plugin data folder as a {@link File}
*/
@NotNull
File getDataFolder();
/**
* Log a message to the console
*
* @param level the level of the message
* @param message the message to log
* @param throwable a throwable to log
*/
void log(@NotNull Level level, @NotNull String message, @NotNull Throwable... throwable);
/**
* Send a debug message to the console, if debug logging is enabled
*
* @param message the message to log
* @param throwable a throwable to log
*/
default void debug(@NotNull String message, @NotNull Throwable... throwable) {
if (getSettings().doDebugLogging()) {
log(Level.INFO, String.format("[DEBUG] %s", message), throwable);
}
}
/**
* Get the console user
*
* @return the {@link ConsoleUser}
*/
@NotNull
ConsoleUser getConsole();
/**
* Returns the plugin version
*
* @return the plugin {@link Version}
*/
@NotNull
Version getPluginVersion();
/**
* Returns the Minecraft version implementation
*
* @return the Minecraft {@link Version}
*/
@NotNull
Version getMinecraftVersion();
/**
* Returns the platform type
*
* @return the platform type
*/
@NotNull
String getPlatformType();
/**
* Returns the legacy data converter, if it exists
*
* @return the {@link LegacyConverter}
*/
Optional<LegacyConverter> getLegacyConverter();
/**
* Reloads the {@link Settings} and {@link Locales} from their respective config files.
*/
default void loadConfigs() {
try {
// Load settings
setSettings(Annotaml.create(new File(getDataFolder(), "config.yml"), Settings.class).get());
// Load locales from language preset default
final Locales languagePresets = Annotaml.create(
Locales.class,
Objects.requireNonNull(getResource(String.format("locales/%s.yml", getSettings().getLanguage())))
).get();
setLocales(Annotaml.create(new File(
getDataFolder(),
String.format("messages_%s.yml", getSettings().getLanguage())
), languagePresets).get());
} catch (IOException | InvocationTargetException | InstantiationException | IllegalAccessException e) {
throw new FailedToLoadException("Failed to load config or message files", e);
}
}
@NotNull
default UpdateChecker getUpdateChecker() {
return UpdateChecker.builder()
.currentVersion(getPluginVersion())
.endpoint(UpdateChecker.Endpoint.SPIGOT)
.resource(Integer.toString(SPIGOT_RESOURCE_ID))
.build();
}
default void checkForUpdates() {
if (getSettings().doCheckForUpdates()) {
getUpdateChecker().check().thenAccept(checked -> {
if (!checked.isUpToDate()) {
log(Level.WARNING, String.format(
"A new version of HuskSync is available: v%s (running v%s)",
checked.getLatestVersion(), getPluginVersion())
);
}
});
}
}
@NotNull
Set<UUID> getLockedPlayers();
@NotNull
Gson getGson();
@NotNull
default Gson createGson() {
return Converters.registerOffsetDateTime(new GsonBuilder()).create();
}
/**
* An exception indicating the plugin has been accessed before it has been registered.
*/
final class FailedToLoadException extends IllegalStateException {
private static final String FORMAT = """
HuskSync has failed to load! The plugin will not be enabled and no data will be synchronized.
Please make sure the plugin has been setup correctly (https://william278.net/docs/husksync/setup):
1) Make sure you've entered your MySQL or MariaDB database details correctly in config.yml
2) Make sure your Redis server details are also correct in config.yml
3) Make sure your config is up-to-date (https://william278.net/docs/husksync/config-files)
4) Check the error below for more details
Caused by: %s""";
FailedToLoadException(@NotNull String message, @NotNull Throwable cause) {
super(String.format(FORMAT, message), cause);
}
}
}

View File

@@ -1,533 +0,0 @@
package net.william278.husksync;
import java.io.*;
import java.time.Instant;
import java.util.UUID;
/**
* Cross-platform class used to represent a player's data. Data from this can be deserialized using the DataSerializer class on Bukkit platforms.
*/
public class PlayerData implements Serializable {
/**
* The UUID of the player who this data belongs to
*/
private final UUID playerUUID;
/**
* The unique version UUID of this data
*/
private final UUID dataVersionUUID;
/**
* Epoch time identifying when the data was last updated or created
*/
private long timestamp;
/**
* A special flag that will be {@code true} if the player is new to the network and should not have their data set when joining the Bukkit
*/
public boolean useDefaultData = false;
/*
* Player data records
*/
private String serializedInventory;
private String serializedEnderChest;
private double health;
private double maxHealth;
private double healthScale;
private int hunger;
private float saturation;
private float saturationExhaustion;
private int selectedSlot;
private String serializedEffectData;
private int totalExperience;
private int expLevel;
private float expProgress;
private String gameMode;
private String serializedStatistics;
private boolean isFlying;
private String serializedAdvancements;
private String serializedLocation;
/**
* Constructor to create new PlayerData from a bukkit {@code Player}'s data
*
* @param playerUUID The Player's UUID
* @param serializedInventory Their serialized inventory
* @param serializedEnderChest Their serialized ender chest
* @param health Their health
* @param maxHealth Their max health
* @param healthScale Their health scale
* @param hunger Their hunger
* @param saturation Their saturation
* @param saturationExhaustion Their saturation exhaustion
* @param selectedSlot Their selected hot bar slot
* @param serializedStatusEffects Their serialized status effects
* @param totalExperience Their total experience points ("Score")
* @param expLevel Their exp level
* @param expProgress Their exp progress to the next level
* @param gameMode Their game mode ({@code SURVIVAL}, {@code CREATIVE}, etc.)
* @param serializedStatistics Their serialized statistics data (Displayed in Statistics menu in ESC menu)
*/
public PlayerData(UUID playerUUID, String serializedInventory, String serializedEnderChest, double health, double maxHealth,
double healthScale, int hunger, float saturation, float saturationExhaustion, int selectedSlot,
String serializedStatusEffects, int totalExperience, int expLevel, float expProgress, String gameMode,
String serializedStatistics, boolean isFlying, String serializedAdvancements, String serializedLocation) {
this.dataVersionUUID = UUID.randomUUID();
this.timestamp = Instant.now().getEpochSecond();
this.playerUUID = playerUUID;
this.serializedInventory = serializedInventory;
this.serializedEnderChest = serializedEnderChest;
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
this.selectedSlot = selectedSlot;
this.serializedEffectData = serializedStatusEffects;
this.totalExperience = totalExperience;
this.expLevel = expLevel;
this.expProgress = expProgress;
this.gameMode = gameMode;
this.serializedStatistics = serializedStatistics;
this.isFlying = isFlying;
this.serializedAdvancements = serializedAdvancements;
this.serializedLocation = serializedLocation;
}
/**
* Constructor for a PlayerData object from an existing object that was stored in SQL
*
* @param playerUUID The player whose data this is' UUID
* @param dataVersionUUID The PlayerData version UUID
* @param serializedInventory Their serialized inventory
* @param serializedEnderChest Their serialized ender chest
* @param health Their health
* @param maxHealth Their max health
* @param healthScale Their health scale
* @param hunger Their hunger
* @param saturation Their saturation
* @param saturationExhaustion Their saturation exhaustion
* @param selectedSlot Their selected hot bar slot
* @param serializedStatusEffects Their serialized status effects
* @param totalExperience Their total experience points ("Score")
* @param expLevel Their exp level
* @param expProgress Their exp progress to the next level
* @param gameMode Their game mode ({@code SURVIVAL}, {@code CREATIVE}, etc.)
* @param serializedStatistics Their serialized statistics data (Displayed in Statistics menu in ESC menu)
*/
public PlayerData(UUID playerUUID, UUID dataVersionUUID, long timestamp, String serializedInventory, String serializedEnderChest,
double health, double maxHealth, double healthScale, int hunger, float saturation, float saturationExhaustion,
int selectedSlot, String serializedStatusEffects, int totalExperience, int expLevel, float expProgress,
String gameMode, String serializedStatistics, boolean isFlying, String serializedAdvancements,
String serializedLocation) {
this.playerUUID = playerUUID;
this.dataVersionUUID = dataVersionUUID;
this.timestamp = timestamp;
this.serializedInventory = serializedInventory;
this.serializedEnderChest = serializedEnderChest;
this.health = health;
this.maxHealth = maxHealth;
this.healthScale = healthScale;
this.hunger = hunger;
this.saturation = saturation;
this.saturationExhaustion = saturationExhaustion;
this.selectedSlot = selectedSlot;
this.serializedEffectData = serializedStatusEffects;
this.totalExperience = totalExperience;
this.expLevel = expLevel;
this.expProgress = expProgress;
this.gameMode = gameMode;
this.serializedStatistics = serializedStatistics;
this.isFlying = isFlying;
this.serializedAdvancements = serializedAdvancements;
this.serializedLocation = serializedLocation;
}
/**
* Get default PlayerData for a new user
*
* @param playerUUID The bukkit Player's UUID
* @return Default {@link PlayerData}
*/
public static PlayerData DEFAULT_PLAYER_DATA(UUID playerUUID) {
PlayerData data = new PlayerData(playerUUID, "", "", 20,
20, 20, 20, 10, 1, 0,
"", 0, 0, 0, "SURVIVAL",
"", false, "", "");
data.useDefaultData = true;
return data;
}
/**
* Get the {@link UUID} of the player whose data this is
*
* @return the player's {@link UUID}
*/
public UUID getPlayerUUID() {
return playerUUID;
}
/**
* Get the unique version {@link UUID} of the PlayerData
*
* @return The unique data version
*/
public UUID getDataVersionUUID() {
return dataVersionUUID;
}
/**
* Get the timestamp when this data was created or last updated
*
* @return time since epoch of last data update or creation
*/
public long getDataTimestamp() {
return timestamp;
}
/**
* Returns the serialized player {@code ItemStack[]} inventory
*
* @return The player's serialized inventory
*/
public String getSerializedInventory() {
return serializedInventory;
}
/**
* Returns the serialized player {@code ItemStack[]} ender chest
*
* @return The player's serialized ender chest
*/
public String getSerializedEnderChest() {
return serializedEnderChest;
}
/**
* Returns the player's health value
*
* @return the player's health
*/
public double getHealth() {
return health;
}
/**
* Returns the player's max health value
*
* @return the player's max health
*/
public double getMaxHealth() {
return maxHealth;
}
/**
* Returns the player's health scale value {@see https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/entity/Player.html#getHealthScale()}
*
* @return the player's health scaling value
*/
public double getHealthScale() {
return healthScale;
}
/**
* Returns the player's hunger points
*
* @return the player's hunger level
*/
public int getHunger() {
return hunger;
}
/**
* Returns the player's saturation points
*
* @return the player's saturation level
*/
public float getSaturation() {
return saturation;
}
/**
* Returns the player's saturation exhaustion value {@see https://hub.spigotmc.org/javadocs/bukkit/org/bukkit/entity/HumanEntity.html#getExhaustion()}
*
* @return the player's saturation exhaustion
*/
public float getSaturationExhaustion() {
return saturationExhaustion;
}
/**
* Returns the number of the player's currently selected hotbar slot
*
* @return the player's selected hotbar slot
*/
public int getSelectedSlot() {
return selectedSlot;
}
/**
* Returns a serialized {@link String} of the player's current status effects
*
* @return the player's serialized status effect data
*/
public String getSerializedEffectData() {
return serializedEffectData;
}
/**
* Returns the player's total experience score (used for presenting the death screen score value)
*
* @return the player's total experience score
*/
public int getTotalExperience() {
return totalExperience;
}
/**
* Returns a serialized {@link String} of the player's statistics
*
* @return the player's serialized statistic records
*/
public String getSerializedStatistics() {
return serializedStatistics;
}
/**
* Returns the player's current experience level
*
* @return the player's exp level
*/
public int getExpLevel() {
return expLevel;
}
/**
* Returns the player's progress to the next experience level
*
* @return the player's exp progress
*/
public float getExpProgress() {
return expProgress;
}
/**
* Returns the player's current game mode as a string ({@code SURVIVAL}, {@code CREATIVE}, etc.)
*
* @return the player's game mode
*/
public String getGameMode() {
return gameMode;
}
/**
* Returns if the player is currently flying
*
* @return {@code true} if the player is in flight; {@code false} otherwise
*/
public boolean isFlying() {
return isFlying;
}
/**
* Returns a serialized {@link String} of the player's advancements
*
* @return the player's serialized advancement data
*/
public String getSerializedAdvancements() {
return serializedAdvancements;
}
/**
* Returns a serialized {@link String} of the player's current location
*
* @return the player's serialized location
*/
public String getSerializedLocation() {
return serializedLocation;
}
/**
* Update the player's inventory data
*
* @param serializedInventory A serialized {@code String}; new inventory data
*/
public void setSerializedInventory(String serializedInventory) {
this.serializedInventory = serializedInventory;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's ender chest data
*
* @param serializedEnderChest A serialized {@code String}; new ender chest inventory data
*/
public void setSerializedEnderChest(String serializedEnderChest) {
this.serializedEnderChest = serializedEnderChest;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's health
*
* @param health new health value
*/
public void setHealth(double health) {
this.health = health;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's max health
*
* @param maxHealth new maximum health value
*/
public void setMaxHealth(double maxHealth) {
this.maxHealth = maxHealth;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's health scale
*
* @param healthScale new health scaling value
*/
public void setHealthScale(double healthScale) {
this.healthScale = healthScale;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's hunger meter
*
* @param hunger new hunger value
*/
public void setHunger(int hunger) {
this.hunger = hunger;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's saturation level
*
* @param saturation new saturation value
*/
public void setSaturation(float saturation) {
this.saturation = saturation;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's saturation exhaustion value
*
* @param saturationExhaustion new exhaustion value
*/
public void setSaturationExhaustion(float saturationExhaustion) {
this.saturationExhaustion = saturationExhaustion;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's selected hotbar slot
*
* @param selectedSlot new hotbar slot number (0-9)
*/
public void setSelectedSlot(int selectedSlot) {
this.selectedSlot = selectedSlot;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's status effect data
*
* @param serializedEffectData A serialized {@code String} of the player's new status effect data
*/
public void setSerializedEffectData(String serializedEffectData) {
this.serializedEffectData = serializedEffectData;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's total experience points (used to display score on death screen)
*
* @param totalExperience the player's new total experience score
*/
public void setTotalExperience(int totalExperience) {
this.totalExperience = totalExperience;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's exp level
*
* @param expLevel the player's new exp level
*/
public void setExpLevel(int expLevel) {
this.expLevel = expLevel;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's progress to their next exp level
*
* @param expProgress the player's new experience progress
*/
public void setExpProgress(float expProgress) {
this.expProgress = expProgress;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set the player's game mode
*
* @param gameMode the player's new game mode ({@code SURVIVAL}, {@code CREATIVE}, etc.)
*/
public void setGameMode(String gameMode) {
this.gameMode = gameMode;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's statistics data
*
* @param serializedStatistics A serialized {@code String}; new statistic data
*/
public void setSerializedStatistics(String serializedStatistics) {
this.serializedStatistics = serializedStatistics;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Set if the player is flying
*
* @param flying whether the player is flying
*/
public void setFlying(boolean flying) {
isFlying = flying;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's advancement data
*
* @param serializedAdvancements A serialized {@code String}; new advancement data
*/
public void setSerializedAdvancements(String serializedAdvancements) {
this.serializedAdvancements = serializedAdvancements;
this.timestamp = Instant.now().getEpochSecond();
}
/**
* Update the player's location data
*
* @param serializedLocation A serialized {@code String}; new location data
*/
public void setSerializedLocation(String serializedLocation) {
this.serializedLocation = serializedLocation;
this.timestamp = Instant.now().getEpochSecond();
}
}

View File

@@ -1,10 +0,0 @@
package net.william278.husksync;
import java.util.UUID;
/**
* A record representing a server synchronised on the network and whether it has MySqlPlayerDataBridge installed
*/
public record Server(UUID serverUUID, boolean hasMySqlPlayerDataBridge, String huskSyncVersion, String serverBrand,
String clusterId) {
}

View File

@@ -1,99 +0,0 @@
package net.william278.husksync;
import java.util.ArrayList;
/**
* Settings class, holds values loaded from the plugin config (either Bukkit or Bungee)
*/
public class Settings {
/*
* General settings
*/
// Whether to do automatic update checks on startup
public static boolean automaticUpdateChecks;
// The type of THIS server (Bungee or Bukkit)
public static ServerType serverType;
// Redis settings
public static String redisHost;
public static int redisPort;
public static String redisPassword;
public static boolean redisSSL;
/*
* Bungee / Proxy server-only settings
*/
// Messages language
public static String language;
// Cluster IDs
public static ArrayList<SynchronisationCluster> clusters = new ArrayList<>();
// SQL settings
public static DataStorageType dataStorageType;
// Bounce-back synchronisation (default)
public static boolean bounceBackSynchronisation;
// MySQL specific settings
public static String mySQLHost;
public static String mySQLDatabase;
public static String mySQLUsername;
public static String mySQLPassword;
public static int mySQLPort;
public static String mySQLParams;
// Hikari connection pooling settings
public static int hikariMaximumPoolSize;
public static int hikariMinimumIdle;
public static long hikariMaximumLifetime;
public static long hikariKeepAliveTime;
public static long hikariConnectionTimeOut;
/*
* Bukkit server-only settings
*/
// Synchronisation options
public static boolean syncInventories;
public static boolean syncEnderChests;
public static boolean syncHealth;
public static boolean syncHunger;
public static boolean syncExperience;
public static boolean syncPotionEffects;
public static boolean syncStatistics;
public static boolean syncGameMode;
public static boolean syncAdvancements;
public static boolean syncLocation;
public static boolean syncFlight;
public static long synchronizationTimeoutRetryDelay;
public static boolean saveOnWorldSave;
public static boolean useNativeImplementation;
// This Cluster ID
public static String cluster;
/*
* Enum definitions
*/
public enum ServerType {
BUKKIT,
PROXY,
}
public enum DataStorageType {
MYSQL,
SQLITE
}
/**
* Defines information for a synchronisation cluster as listed on the proxy
*/
public record SynchronisationCluster(String clusterId, String databaseName, String playerTableName, String dataTableName) {
}
}

View File

@@ -0,0 +1,24 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.adapter;
public interface Adaptable {
}

View File

@@ -0,0 +1,108 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.adapter;
import org.jetbrains.annotations.NotNull;
import java.nio.charset.StandardCharsets;
/**
* An adapter that adapts data to and from a portable byte array.
*/
public interface DataAdapter {
/**
* Converts an {@link Adaptable} to a string.
*
* @param data The {@link Adaptable} to adapt
* @param <A> The type of the {@link Adaptable}
* @return The string
* @throws AdaptionException If an error occurred during adaptation.
*/
@NotNull
default <A extends Adaptable> String toString(@NotNull A data) throws AdaptionException {
return new String(this.toBytes(data), StandardCharsets.UTF_8);
}
/**
* Converts an {@link Adaptable} to a byte array.
*
* @param data The {@link Adaptable} to adapt
* @param <A> The type of the {@link Adaptable}
* @return The byte array
* @throws AdaptionException If an error occurred during adaptation.
*/
<A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException;
/**
* Converts a JSON string to an {@link Adaptable}.
*
* @param data The JSON string to adapt.
* @param type The class type of the {@link Adaptable} to adapt to.
* @param <A> The type of the {@link Adaptable}
* @return The {@link Adaptable}
* @throws AdaptionException If an error occurred during adaptation.
*/
@NotNull
<A extends Adaptable> A fromJson(@NotNull String data, @NotNull Class<A> type) throws AdaptionException;
/**
* Converts an {@link Adaptable} to a JSON string.
*
* @param data The {@link Adaptable} to adapt
* @param <A> The type of the {@link Adaptable}
* @return The JSON string
* @throws AdaptionException If an error occurred during adaptation.
*/
@NotNull
<A extends Adaptable> String toJson(@NotNull A data) throws AdaptionException;
/**
* Converts a byte array to an {@link Adaptable}.
*
* @param data The byte array to adapt.
* @param type The class type of the {@link Adaptable} to adapt to.
* @param <A> The type of the {@link Adaptable}
* @return The {@link Adaptable}
* @throws AdaptionException If an error occurred during adaptation.
*/
<A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException;
/**
* Converts a byte array to a string, including decompression if required.
*
* @param bytes The byte array to convert
* @return the string form of the bytes
*/
@NotNull
String bytesToString(byte[] bytes);
final class AdaptionException extends IllegalStateException {
static final String FORMAT = "An exception occurred when adapting serialized/deserialized data: %s";
public AdaptionException(@NotNull String message, @NotNull Throwable cause) {
super(String.format(FORMAT, message), cause);
}
public AdaptionException(@NotNull String message) {
super(String.format(FORMAT, message));
}
}
}

View File

@@ -0,0 +1,72 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.adapter;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.nio.charset.StandardCharsets;
public class GsonAdapter implements DataAdapter {
private final HuskSync plugin;
public GsonAdapter(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
@Override
public <A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException {
return this.toJson(data).getBytes(StandardCharsets.UTF_8);
}
@NotNull
@Override
public <A extends Adaptable> String toJson(@NotNull A data) throws AdaptionException {
try {
return plugin.getGson().toJson(data);
} catch (Throwable e) {
throw new AdaptionException("Failed to adapt data to JSON via Gson", e);
}
}
@Override
@NotNull
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException {
return this.fromJson(new String(data, StandardCharsets.UTF_8), type);
}
@NotNull
@Override
public String bytesToString(byte[] bytes) {
return new String(bytes, StandardCharsets.UTF_8);
}
@Override
@NotNull
public <A extends Adaptable> A fromJson(@NotNull String data, @NotNull Class<A> type) throws AdaptionException {
try {
return plugin.getGson().fromJson(data, type);
} catch (Throwable e) {
throw new AdaptionException("Failed to adapt data from JSON via Gson", e);
}
}
}

View File

@@ -0,0 +1,68 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.adapter;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import org.xerial.snappy.Snappy;
import java.io.IOException;
public class SnappyGsonAdapter extends GsonAdapter {
public SnappyGsonAdapter(@NotNull HuskSync plugin) {
super(plugin);
}
@NotNull
@Override
public <A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException {
try {
return Snappy.compress(super.toBytes(data));
} catch (IOException e) {
throw new AdaptionException("Failed to compress data through Snappy", e);
}
}
@NotNull
@Override
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException {
try {
return super.fromBytes(decompressBytes(data), type);
} catch (IOException e) {
throw new AdaptionException("Failed to decompress data through Snappy", e);
}
}
@Override
@NotNull
public String bytesToString(byte[] bytes) {
try {
return super.bytesToString(decompressBytes(bytes));
} catch (IOException e) {
throw new AdaptionException("Failed to decompress data through Snappy", e);
}
}
private byte[] decompressBytes(byte[] bytes) throws IOException {
return Snappy.uncompress(bytes);
}
}

View File

@@ -0,0 +1,458 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.api;
import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.data.Serializer;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
/**
* The base implementation of the HuskSync API, containing cross-platform API calls.
* </p>
* This class should not be used directly, but rather through platform-specific extending API classes.
*
* @since 2.0
*/
@SuppressWarnings("unused")
public abstract class HuskSyncAPI {
/**
* <b>(Internal use only)</b> - Instance of the implementing plugin.
*/
protected final HuskSync plugin;
/**
* <b>(Internal use only)</b> - Constructor, instantiating the base API class.
*/
@ApiStatus.Internal
protected HuskSyncAPI(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
/**
* Get a {@link User} by their UUID
*
* @param uuid The UUID of the user to get
* @return A future containing the user, or an empty optional if the user doesn't exist
* @since 3.0
*/
@NotNull
public CompletableFuture<Optional<User>> getUser(@NotNull UUID uuid) {
return plugin.supplyAsync(() -> plugin.getDatabase().getUser(uuid));
}
/**
* Get a {@link User} by their username
*
* @param username The username of the user to get
* @return A future containing the user, or an empty optional if the user doesn't exist
* @since 3.0
*/
@NotNull
public CompletableFuture<Optional<User>> getUser(@NotNull String username) {
return plugin.supplyAsync(() -> plugin.getDatabase().getUserByName(username));
}
/**
* Create a new data snapshot of an {@link OnlineUser}'s data.
*
* @param user The user to create the snapshot of
* @return The snapshot of the user's data
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed createSnapshot(@NotNull OnlineUser user) {
return snapshotBuilder().saveCause(DataSnapshot.SaveCause.API).buildAndPack();
}
/**
* Get a {@link User}'s current data, as a {@link DataSnapshot.Unpacked}
* <p>
* If the user is online, this will create a new snapshot of their data with the {@code API} data save cause.
* </p>
* If the user is offline, this will return the latest snapshot of their data if that exists
* (an empty optional will be returned otherwise).
*
* @param user The user to get the data of
* @return A future containing the user's current data, or an empty optional if the user has no data
* @since 3.0
*/
public CompletableFuture<Optional<DataSnapshot.Unpacked>> getCurrentData(@NotNull User user) {
return plugin.getRedisManager()
.getUserData(UUID.randomUUID(), user)
.thenApply(data -> data.or(() -> plugin.getDatabase().getLatestSnapshot(user)))
.thenApply(data -> data.map(snapshot -> snapshot.unpack(plugin)));
}
/**
* Set a user's current data.
* <p>
* This will update the user's data in the database (creating a new snapshot) and send a data update,
* updating the user if they are online.
*
* @param user The user to set the data of
* @param data The data to set
* @since 3.0
*/
public void setCurrentData(@NotNull User user, @NotNull DataSnapshot data) {
plugin.runAsync(() -> {
final DataSnapshot.Packed packed = data instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) data;
addSnapshot(user, packed);
plugin.getRedisManager().sendUserDataUpdate(user, packed);
});
}
/**
* Edit a user's current data.
* <p>
* This will update the user's data in the database (creating a new snapshot) and send a data update,
* updating the user if they are online.
*
* @param user The user to edit the data of
* @param editor The editor function
* @since 3.0
*/
public void editCurrentData(@NotNull User user, @NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
getCurrentData(user).thenAccept(optional -> optional.ifPresent(data -> {
editor.accept(data);
setCurrentData(user, data);
}));
}
/**
* Get a list of all saved data snapshots for a user
*
* @param user The user to get the data snapshots of
* @return The user's data snapshots
* @since 3.0
*/
public CompletableFuture<List<DataSnapshot.Unpacked>> getSnapshots(@NotNull User user) {
return plugin.supplyAsync(
() -> plugin.getDatabase().getAllSnapshots(user).stream()
.map(snapshot -> snapshot.unpack(plugin))
.toList()
);
}
/**
* Get a specific data snapshot for a user
*
* @param user The user to get the data snapshot of
* @param versionId The version ID of the snapshot to get
* @return The user's data snapshot, or an empty optional if the user has no data
* @see #getSnapshots(User)
* @since 3.0
*/
public CompletableFuture<List<DataSnapshot.Unpacked>> getSnapshot(@NotNull User user, @NotNull UUID versionId) {
return plugin.supplyAsync(
() -> plugin.getDatabase().getSnapshot(user, versionId).stream()
.map(snapshot -> snapshot.unpack(plugin))
.toList()
);
}
/**
* Edit a data snapshot for a user
*
* @param user The user to edit the snapshot of
* @param versionId The version ID of the snapshot to edit
* @param editor The editor function
* @since 3.0
*/
public void editSnapshot(@NotNull User user, @NotNull UUID versionId,
@NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
plugin.runAsync(() -> plugin.getDatabase().getSnapshot(user, versionId).ifPresent(snapshot -> {
final DataSnapshot.Unpacked unpacked = snapshot.unpack(plugin);
editor.accept(unpacked);
plugin.getDatabase().updateSnapshot(user, unpacked.pack(plugin));
}));
}
/**
* Get the latest data snapshot for a user that has been saved in the database.
* <p>
* Not to be confused with {@link #getCurrentData(User)}, which will return the current data of a user
* if they are online (this method will only return their latest <i>saved</i> snapshot).
* </p>
*
* @param user The user to get the latest data snapshot of
* @return The user's latest data snapshot, or an empty optional if the user has no data
* @since 3.0
*/
public CompletableFuture<Optional<DataSnapshot.Unpacked>> getLatestSnapshot(@NotNull User user) {
return plugin.supplyAsync(
() -> plugin.getDatabase().getLatestSnapshot(user).map(snapshot -> snapshot.unpack(plugin))
);
}
/**
* Edit the latest data snapshot for a user
*
* @param user The user to edit the latest snapshot of
* @param editor The editor function
* @since 3.0
*/
public void editLatestSnapshot(@NotNull User user, @NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
plugin.runAsync(() -> plugin.getDatabase().getLatestSnapshot(user).ifPresent(snapshot -> {
final DataSnapshot.Unpacked unpacked = snapshot.unpack(plugin);
editor.accept(unpacked);
plugin.getDatabase().updateSnapshot(user, unpacked.pack(plugin));
}));
}
/**
* Adds a data snapshot to the database
*
* @param user The user to save the data for
* @param snapshot The snapshot to save
* @since 3.0
*/
public void addSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) {
plugin.runAsync(() -> plugin.getDatabase().addSnapshot(
user, snapshot instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) snapshot
));
}
/**
* Update an <i>existing</i> data snapshot in the database.
* Not to be confused with {@link #addSnapshot(User, DataSnapshot)}, which will add a new snapshot if one
* snapshot doesn't exist.
*
* @param user The user to update the snapshot of
* @param snapshot The snapshot to update
* @since 3.0
*/
public void updateSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) {
plugin.runAsync(() -> plugin.getDatabase().updateSnapshot(
user, snapshot instanceof DataSnapshot.Unpacked unpacked
? unpacked.pack(plugin) : (DataSnapshot.Packed) snapshot
));
}
/**
* Pin a data snapshot, preventing it from being rotated
*
* @param user The user to pin the snapshot of
* @param snapshotVersion The version ID of the snapshot to pin
* @since 3.0
*/
public void pinSnapshot(@NotNull User user, @NotNull UUID snapshotVersion) {
plugin.runAsync(() -> plugin.getDatabase().pinSnapshot(user, snapshotVersion));
}
/**
* Unpin a data snapshot, allowing it to be rotated
*
* @param user The user to unpin the snapshot of
* @param snapshotVersion The version ID of the snapshot to unpin
* @since 3.0
*/
public void unpinSnapshot(@NotNull User user, @NotNull UUID snapshotVersion) {
plugin.runAsync(() -> plugin.getDatabase().unpinSnapshot(user, snapshotVersion));
}
/**
* Delete a data snapshot from the database
*
* @param user The user to delete the snapshot of
* @param versionId The version ID of the snapshot to delete
* @return A future which will complete with true if the snapshot was deleted, or false if it wasn't
* (e.g., if the snapshot didn't exist)
* @since 3.0
*/
public CompletableFuture<Boolean> deleteSnapshot(@NotNull User user, @NotNull UUID versionId) {
return plugin.supplyAsync(() -> plugin.getDatabase().deleteSnapshot(user, versionId));
}
/**
* Delete a data snapshot from the database
*
* @param user The user to delete the snapshot of
* @param snapshot The snapshot to delete
* @return A future which will complete with true if the snapshot was deleted, or false if it wasn't
* (e.g., if the snapshot hasn't been saved to the database yet)
* @since 3.0
*/
public CompletableFuture<Boolean> deleteSnapshot(@NotNull User user, @NotNull DataSnapshot snapshot) {
return deleteSnapshot(user, snapshot.getId());
}
/**
* Registers a new custom data type serializer.
* <p>
* This allows for custom {@link Data} types to be persisted in {@link DataSnapshot}s. To register
* a new data type, you must provide a {@link Serializer} for serializing and deserializing the data type
* and invoke this method.
* </p>
* You'll need to do this on every server you wish to sync data between. On servers where the registered
* data type is not present, the data will be ignored and snapshots created on that server will not
* contain the data.
*
* @param identifier The identifier of the data type to register.
* Create one using {@code Identifier.from(Key.of("your_plugin_name", "key"))}
* @param serializer An implementation of {@link Serializer} for serializing and deserializing the {@link Data}
* @param <T> A type extending {@link Data}; this will represent the data being held.
*/
public <T extends Data> void registerDataSerializer(@NotNull Identifier identifier,
@NotNull Serializer<T> serializer) {
plugin.registerSerializer(identifier, serializer);
}
/**
* Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed}
*
* @param unpacked The unpacked snapshot
* @return The packed snapshot
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed packSnapshot(@NotNull DataSnapshot.Unpacked unpacked) {
return unpacked.pack(plugin);
}
/**
* Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed}
*
* @param packed The packed snapshot
* @return The unpacked snapshot
* @since 3.0
*/
@NotNull
public DataSnapshot.Unpacked unpackSnapshot(@NotNull DataSnapshot.Packed packed) {
return packed.unpack(plugin);
}
/**
* Unpack, edit, and repack a data snapshot.
* </p>
* This won't save the snapshot to the database; it'll just edit the data snapshot in place.
*
* @param packed The packed snapshot
* @param editor An editor function for editing the unpacked snapshot
* @return The edited packed snapshot
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed editPackedSnapshot(@NotNull DataSnapshot.Packed packed,
@NotNull ThrowingConsumer<DataSnapshot.Unpacked> editor) {
final DataSnapshot.Unpacked unpacked = packed.unpack(plugin);
editor.accept(unpacked);
return unpacked.pack(plugin);
}
/**
* Get the estimated size of a {@link DataSnapshot} in bytes
*
* @param snapshot The snapshot to get the size of
* @return The size of the snapshot in bytes
* @since 3.0
*/
public int getSnapshotFileSize(@NotNull DataSnapshot snapshot) {
return (snapshot instanceof DataSnapshot.Packed packed)
? packed.getFileSize(plugin)
: ((DataSnapshot.Unpacked) snapshot).pack(plugin).getFileSize(plugin);
}
/**
* Get a builder for creating a new data snapshot
*
* @return The builder
* @since 3.0
*/
@NotNull
public DataSnapshot.Builder snapshotBuilder() {
return DataSnapshot.builder(plugin).saveCause(DataSnapshot.SaveCause.API);
}
/**
* Deserialize a JSON string to an {@link Adaptable}
*
* @param serialized The serialized JSON string
* @param type The type of the element
* @param <T> The type of the element
* @return The deserialized element
* @throws Serializer.DeserializationException If the element could not be deserialized
*/
@NotNull
public <T extends Adaptable> T deserializeData(@NotNull String serialized, Class<T> type)
throws Serializer.DeserializationException {
return plugin.getDataAdapter().fromJson(serialized, type);
}
/**
* Serialize an {@link Adaptable} to a JSON string
*
* @param element The element to serialize
* @param <T> The type of the element
* @return The serialized JSON string
* @throws Serializer.SerializationException If the element could not be serialized
*/
@NotNull
public <T extends Adaptable> String serializeData(@NotNull T element)
throws Serializer.SerializationException {
return plugin.getDataAdapter().toJson(element);
}
/**
* <b>(Internal use only)</b> - Get the plugin instance
*
* @return The plugin instance
*/
@ApiStatus.Internal
public HuskSync getPlugin() {
return plugin;
}
/**
* An exception indicating the plugin has been accessed before it has been registered.
*/
static final class NotRegisteredException extends IllegalStateException {
private static final String MESSAGE = """
Could not access the HuskSync API as it has not yet been registered. This could be because:
1) HuskSync has failed to enable successfully
2) Your plugin isn't set to load after HuskSync has
(Check if it set as a (soft)depend in plugin.yml or to load: BEFORE in paper-plugin.yml?)
3) You are attempting to access HuskSync on plugin construction/before your plugin has enabled.
4) You have shaded HuskSync into your plugin jar and need to fix your maven/gradle/build script
to only include HuskSync as a dependency and not as a shaded dependency.""";
NotRegisteredException() {
super(MESSAGE);
}
}
}

View File

@@ -0,0 +1,84 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.CommandUser;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
public abstract class Command extends Node {
private final String usage;
private final Map<String, Boolean> additionalPermissions;
protected Command(@NotNull String name, @NotNull List<String> aliases, @NotNull String usage,
@NotNull HuskSync plugin) {
super(name, aliases, plugin);
this.usage = usage;
this.additionalPermissions = new HashMap<>();
}
@Override
public final void onExecuted(@NotNull CommandUser executor, @NotNull String[] args) {
if (!executor.hasPermission(getPermission())) {
plugin.getLocales().getLocale("error_no_permission")
.ifPresent(executor::sendMessage);
return;
}
plugin.runAsync(() -> this.execute(executor, args));
}
public abstract void execute(@NotNull CommandUser executor, @NotNull String[] args);
@NotNull
public final String getRawUsage() {
return usage;
}
@NotNull
public final String getUsage() {
return "/" + getName() + " " + getRawUsage();
}
public final void addAdditionalPermissions(@NotNull Map<String, Boolean> permissions) {
permissions.forEach((permission, value) -> this.additionalPermissions.put(getPermission(permission), value));
}
@NotNull
public final Map<String, Boolean> getAdditionalPermissions() {
return additionalPermissions;
}
@NotNull
public String getDescription() {
return plugin.getLocales().getRawLocale(getName() + "_command_description")
.orElse(getUsage());
}
@NotNull
public final HuskSync getPlugin() {
return plugin;
}
}

View File

@@ -0,0 +1,92 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
public class EnderChestCommand extends ItemsCommand {
public EnderChestCommand(@NotNull HuskSync plugin) {
super(plugin, List.of("enderchest", "echest", "openechest"));
}
@Override
protected void showItems(@NotNull OnlineUser viewer, @NotNull DataSnapshot.Unpacked snapshot,
@NotNull User user, boolean allowEdit) {
final Optional<Data.Items.EnderChest> optionalEnderChest = snapshot.getEnderChest();
if (optionalEnderChest.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
// Display opening message
plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getUsername(),
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm")))
.ifPresent(viewer::sendMessage);
// Show GUI
final Data.Items.EnderChest enderChest = optionalEnderChest.get();
viewer.showGui(
enderChest,
plugin.getLocales().getLocale("ender_chest_viewer_menu_title", user.getUsername())
.orElse(new MineDown(String.format("%s's Ender Chest", user.getUsername()))),
allowEdit,
enderChest.getSlotCount(),
(itemsOnClose) -> {
if (allowEdit && !enderChest.equals(itemsOnClose)) {
plugin.runAsync(() -> this.updateItems(viewer, itemsOnClose, user));
}
}
);
}
// Creates a new snapshot with the updated enderChest
@SuppressWarnings("DuplicatedCode")
private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User user) {
final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(user);
if (latestData.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
// Create and pack the snapshot with the updated enderChest
final DataSnapshot.Packed snapshot = latestData.get().copy();
snapshot.edit(plugin, (data) -> {
data.setSaveCause(DataSnapshot.SaveCause.ENDERCHEST_COMMAND);
data.setPinned(plugin.getSettings().doAutoPin(DataSnapshot.SaveCause.ENDERCHEST_COMMAND));
data.getEnderChest().ifPresent(enderChest -> enderChest.setContents(items));
});
plugin.getDatabase().addSnapshot(user, snapshot);
plugin.getRedisManager().sendUserDataUpdate(user, snapshot);
}
}

View File

@@ -0,0 +1,29 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import net.william278.husksync.user.CommandUser;
import org.jetbrains.annotations.NotNull;
public interface Executable {
void onExecuted(@NotNull CommandUser executor, @NotNull String[] args);
}

View File

@@ -0,0 +1,184 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import de.themoep.minedown.adventure.MineDown;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.TextColor;
import net.william278.desertwell.about.AboutMenu;
import net.william278.desertwell.util.UpdateChecker;
import net.william278.husksync.HuskSync;
import net.william278.husksync.migrator.Migrator;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.logging.Level;
import java.util.stream.Collectors;
public class HuskSyncCommand extends Command implements TabProvider {
private static final Map<String, Boolean> SUB_COMMANDS = Map.of(
"about", false,
"reload", true,
"migrate", true,
"update", true
);
private final UpdateChecker updateChecker;
private final AboutMenu aboutMenu;
public HuskSyncCommand(@NotNull HuskSync plugin) {
super("husksync", List.of(), "[" + String.join("|", SUB_COMMANDS.keySet()) + "]", plugin);
addAdditionalPermissions(SUB_COMMANDS);
this.updateChecker = plugin.getUpdateChecker();
this.aboutMenu = AboutMenu.builder()
.title(Component.text("HuskSync"))
.description(Component.text("A modern, cross-server player data synchronization system"))
.version(plugin.getPluginVersion())
.credits("Author",
AboutMenu.Credit.of("William278").description("Click to visit website").url("https://william278.net"))
.credits("Contributors",
AboutMenu.Credit.of("HarvelsX").description("Code"),
AboutMenu.Credit.of("HookWoods").description("Code"))
.credits("Translators",
AboutMenu.Credit.of("Namiu").description("Japanese (ja-jp)"),
AboutMenu.Credit.of("anchelthe").description("Spanish (es-es)"),
AboutMenu.Credit.of("Melonzio").description("Spanish (es-es)"),
AboutMenu.Credit.of("Ceddix").description("German (de-de)"),
AboutMenu.Credit.of("Pukejoy_1").description("Bulgarian (bg-bg)"),
AboutMenu.Credit.of("mateusneresrb").description("Brazilian Portuguese (pt-br)"),
AboutMenu.Credit.of("小蔡").description("Traditional Chinese (zh-tw)"),
AboutMenu.Credit.of("Ghost-chu").description("Simplified Chinese (zh-cn)"),
AboutMenu.Credit.of("DJelly4K").description("Simplified Chinese (zh-cn)"),
AboutMenu.Credit.of("Thourgard").description("Ukrainian (uk-ua)"),
AboutMenu.Credit.of("xF3d3").description("Italian (it-it)"))
.buttons(
AboutMenu.Link.of("https://william278.net/docs/husksync").text("Documentation").icon(""),
AboutMenu.Link.of("https://github.com/WiIIiam278/HuskSync/issues").text("Issues").icon("").color(TextColor.color(0xff9f0f)),
AboutMenu.Link.of("https://discord.gg/tVYhJfyDWG").text("Discord").icon("").color(TextColor.color(0x6773f5)))
.build();
}
@Override
public void execute(@NotNull CommandUser executor, @NotNull String[] args) {
final String subCommand = parseStringArg(args, 0).orElse("about").toLowerCase(Locale.ENGLISH);
if (SUB_COMMANDS.containsKey(subCommand) && !executor.hasPermission(getPermission(subCommand))) {
plugin.getLocales().getLocale("error_no_permission")
.ifPresent(executor::sendMessage);
return;
}
switch (subCommand) {
case "about" -> executor.sendMessage(aboutMenu.toComponent());
case "reload" -> {
try {
plugin.loadConfigs();
plugin.getLocales().getLocale("reload_complete").ifPresent(executor::sendMessage);
} catch (Throwable e) {
executor.sendMessage(new MineDown(
"[Error:](#ff3300) [Failed to reload the plugin. Check console for errors.](#ff7e5e)"
));
plugin.log(Level.SEVERE, "Failed to reload the plugin", e);
}
}
case "migrate" -> {
if (executor instanceof OnlineUser) {
plugin.getLocales().getLocale("error_console_command_only")
.ifPresent(executor::sendMessage);
return;
}
this.handleMigrationCommand(args);
}
case "update" -> updateChecker.check().thenAccept(checked -> {
if (checked.isUpToDate()) {
plugin.getLocales().getLocale("up_to_date", plugin.getPluginVersion().toString())
.ifPresent(executor::sendMessage);
return;
}
plugin.getLocales().getLocale("update_available", checked.getLatestVersion().toString(),
plugin.getPluginVersion().toString()).ifPresent(executor::sendMessage);
});
default -> plugin.getLocales().getLocale("error_invalid_syntax", getUsage())
.ifPresent(executor::sendMessage);
}
}
// Handle a migration console command input
private void handleMigrationCommand(@NotNull String[] args) {
if (args.length < 2) {
plugin.log(Level.INFO,
"Please choose a migrator, then run \"husksync migrate <migrator>\"");
this.logMigratorList();
return;
}
final Optional<Migrator> selectedMigrator = plugin.getAvailableMigrators().stream()
.filter(available -> available.getIdentifier().equalsIgnoreCase(args[1]))
.findFirst();
selectedMigrator.ifPresentOrElse(migrator -> {
if (args.length < 3) {
plugin.log(Level.INFO, migrator.getHelpMenu());
return;
}
switch (args[2]) {
case "start" -> migrator.start().thenAccept(succeeded -> {
if (succeeded) {
plugin.log(Level.INFO, "Migration completed successfully!");
} else {
plugin.log(Level.WARNING, "Migration failed!");
}
});
case "set" -> migrator.handleConfigurationCommand(Arrays.copyOfRange(args, 3, args.length));
default -> plugin.log(Level.INFO, String.format(
"Invalid syntax. Console usage: \"husksync migrate %s <start/set>", args[1]
));
}
}, () -> {
plugin.log(Level.INFO,
"Please specify a valid migrator.\n" +
"If a migrator is not available, please verify that you meet the prerequisites to use it.");
this.logMigratorList();
});
}
// Log the list of available migrators
private void logMigratorList() {
plugin.log(Level.INFO, String.format(
"List of available migrators:\nMigrator ID / Migrator Name:\n%s",
plugin.getAvailableMigrators().stream()
.map(migrator -> String.format("%s - %s", migrator.getIdentifier(), migrator.getName()))
.collect(Collectors.joining("\n"))
));
}
@Nullable
@Override
public List<String> suggest(@NotNull CommandUser user, @NotNull String[] args) {
return switch (args.length) {
case 0, 1 -> SUB_COMMANDS.keySet().stream().sorted().toList();
default -> null;
};
}
}

View File

@@ -0,0 +1,92 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import de.themoep.minedown.adventure.MineDown;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.Data;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Optional;
public class InventoryCommand extends ItemsCommand {
public InventoryCommand(@NotNull HuskSync plugin) {
super(plugin, List.of("inventory", "invsee", "openinv"));
}
@Override
protected void showItems(@NotNull OnlineUser viewer, @NotNull DataSnapshot.Unpacked snapshot,
@NotNull User user, boolean allowEdit) {
final Optional<Data.Items.Inventory> optionalInventory = snapshot.getInventory();
if (optionalInventory.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
// Display opening message
plugin.getLocales().getLocale("inventory_viewer_opened", user.getUsername(),
snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("dd/MM/yyyy, HH:mm")))
.ifPresent(viewer::sendMessage);
// Show GUI
final Data.Items.Inventory inventory = optionalInventory.get();
viewer.showGui(
inventory,
plugin.getLocales().getLocale("inventory_viewer_menu_title", user.getUsername())
.orElse(new MineDown(String.format("%s's Inventory", user.getUsername()))),
allowEdit,
inventory.getSlotCount(),
(itemsOnClose) -> {
if (allowEdit && !inventory.equals(itemsOnClose)) {
plugin.runAsync(() -> this.updateItems(viewer, itemsOnClose, user));
}
}
);
}
// Creates a new snapshot with the updated inventory
@SuppressWarnings("DuplicatedCode")
private void updateItems(@NotNull OnlineUser viewer, @NotNull Data.Items.Items items, @NotNull User user) {
final Optional<DataSnapshot.Packed> latestData = plugin.getDatabase().getLatestSnapshot(user);
if (latestData.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage);
return;
}
// Create and pack the snapshot with the updated inventory
final DataSnapshot.Packed snapshot = latestData.get().copy();
snapshot.edit(plugin, (data) -> {
data.setSaveCause(DataSnapshot.SaveCause.INVENTORY_COMMAND);
data.setPinned(plugin.getSettings().doAutoPin(DataSnapshot.SaveCause.INVENTORY_COMMAND));
data.getInventory().ifPresent(inventory -> inventory.setContents(items));
});
plugin.getDatabase().addSnapshot(user, snapshot);
plugin.getRedisManager().sendUserDataUpdate(user, snapshot);
}
}

View File

@@ -0,0 +1,108 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.OnlineUser;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
public abstract class ItemsCommand extends Command implements TabProvider {
protected ItemsCommand(@NotNull HuskSync plugin, @NotNull List<String> aliases) {
super(aliases.get(0), aliases.subList(1, aliases.size()), "<player> [version_uuid]", plugin);
setOperatorCommand(true);
addAdditionalPermissions(Map.of("edit", true));
}
@Override
public void execute(@NotNull CommandUser executor, @NotNull String[] args) {
if (!(executor instanceof OnlineUser player)) {
plugin.getLocales().getLocale("error_in_game_command_only")
.ifPresent(executor::sendMessage);
return;
}
// Find the user to view the items for
final Optional<User> optionalUser = parseStringArg(args, 0)
.flatMap(name -> plugin.getDatabase().getUserByName(name));
if (optionalUser.isEmpty()) {
plugin.getLocales().getLocale(
args.length >= 1 ? "error_invalid_player" : "error_invalid_syntax", getUsage()
).ifPresent(player::sendMessage);
return;
}
// Show the user data
final User user = optionalUser.get();
parseUUIDArg(args, 1).ifPresentOrElse(
version -> this.showSnapshotItems(player, user, version),
() -> this.showLatestItems(player, user)
);
}
// View (and edit) the latest user data
private void showLatestItems(@NotNull OnlineUser viewer, @NotNull User user) {
plugin.getRedisManager().getUserData(user.getUuid(), user).thenAccept(data -> data
.or(() -> plugin.getDatabase().getLatestSnapshot(user))
.ifPresentOrElse(
snapshot -> this.showItems(
viewer, snapshot.unpack(plugin), user,
viewer.hasPermission(getPermission("edit"))
),
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(viewer::sendMessage)
));
}
// View a specific version of the user data
private void showSnapshotItems(@NotNull OnlineUser viewer, @NotNull User user, @NotNull UUID version) {
plugin.getDatabase().getSnapshot(user, version)
.ifPresentOrElse(
snapshot -> this.showItems(
viewer, snapshot.unpack(plugin), user, false
),
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(viewer::sendMessage)
);
}
// Show a GUI menu with the correct item data from the snapshot
protected abstract void showItems(@NotNull OnlineUser viewer, @NotNull DataSnapshot.Unpacked snapshot,
@NotNull User user, boolean allowEdit);
@Nullable
@Override
public List<String> suggest(@NotNull CommandUser executor, @NotNull String[] args) {
return switch (args.length) {
case 0, 1 -> plugin.getOnlineUsers().stream().map(User::getUsername).toList();
default -> null;
};
}
}

View File

@@ -0,0 +1,105 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.NotNull;
import java.util.List;
import java.util.Optional;
import java.util.StringJoiner;
import java.util.UUID;
public abstract class Node implements Executable {
protected static final String PERMISSION_PREFIX = "husksync.command";
protected final HuskSync plugin;
private final String name;
private final List<String> aliases;
private boolean operatorCommand = false;
protected Node(@NotNull String name, @NotNull List<String> aliases, @NotNull HuskSync plugin) {
if (name.isBlank()) {
throw new IllegalArgumentException("Command name cannot be blank");
}
this.name = name;
this.aliases = aliases;
this.plugin = plugin;
}
@NotNull
public String getName() {
return name;
}
@NotNull
public List<String> getAliases() {
return aliases;
}
@NotNull
public String getPermission(@NotNull String... child) {
final StringJoiner joiner = new StringJoiner(".")
.add(PERMISSION_PREFIX)
.add(getName());
for (final String node : child) {
joiner.add(node);
}
return joiner.toString().trim();
}
public boolean isOperatorCommand() {
return operatorCommand;
}
public void setOperatorCommand(boolean operatorCommand) {
this.operatorCommand = operatorCommand;
}
protected Optional<String> parseStringArg(@NotNull String[] args, int index) {
if (args.length > index) {
return Optional.of(args[index]);
}
return Optional.empty();
}
protected Optional<Integer> parseIntArg(@NotNull String[] args, int index) {
return parseStringArg(args, index).flatMap(arg -> {
try {
return Optional.of(Integer.parseInt(arg));
} catch (NumberFormatException e) {
return Optional.empty();
}
});
}
protected Optional<UUID> parseUUIDArg(@NotNull String[] args, int index) {
return parseStringArg(args, index).flatMap(arg -> {
try {
return Optional.of(UUID.fromString(arg));
} catch (IllegalArgumentException e) {
return Optional.empty();
}
});
}
}

View File

@@ -0,0 +1,50 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import net.william278.husksync.user.CommandUser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.List;
public interface TabProvider {
@Nullable
List<String> suggest(@NotNull CommandUser user, @NotNull String[] args);
@NotNull
default List<String> getSuggestions(@NotNull CommandUser user, @NotNull String[] args) {
List<String> suggestions = suggest(user, args);
if (suggestions == null) {
suggestions = List.of();
}
return filter(suggestions, args);
}
@NotNull
default List<String> filter(@NotNull List<String> suggestions, @NotNull String[] args) {
return suggestions.stream()
.filter(suggestion -> args.length == 0 || suggestion.toLowerCase()
.startsWith(args[args.length - 1].toLowerCase().trim()))
.toList();
}
}

View File

@@ -0,0 +1,235 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.command;
import net.william278.husksync.HuskSync;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.CommandUser;
import net.william278.husksync.user.User;
import net.william278.husksync.util.DataDumper;
import net.william278.husksync.util.DataSnapshotList;
import net.william278.husksync.util.DataSnapshotOverview;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.logging.Level;
public class UserDataCommand extends Command implements TabProvider {
private static final Map<String, Boolean> SUB_COMMANDS = Map.of(
"view", false,
"list", false,
"delete", true,
"restore", true,
"pin", true,
"dump", true
);
public UserDataCommand(@NotNull HuskSync plugin) {
super("userdata", List.of("playerdata"), String.format(
"<%s> [username] [version_uuid]", String.join("/", SUB_COMMANDS.keySet())
), plugin);
setOperatorCommand(true);
addAdditionalPermissions(SUB_COMMANDS);
}
@Override
public void execute(@NotNull CommandUser executor, @NotNull String[] args) {
final String subCommand = parseStringArg(args, 0).orElse("view").toLowerCase(Locale.ENGLISH);
final Optional<User> optionalUser = parseStringArg(args, 1)
.flatMap(name -> plugin.getDatabase().getUserByName(name))
.or(() -> parseStringArg(args, 0).flatMap(name -> plugin.getDatabase().getUserByName(name)))
.or(() -> args.length < 2 && executor instanceof User userExecutor
? Optional.of(userExecutor) : Optional.empty());
final Optional<UUID> optionalUuid = parseUUIDArg(args, 2).or(() -> parseUUIDArg(args, 1));
if (optionalUser.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_player")
.ifPresent(executor::sendMessage);
return;
}
final User user = optionalUser.get();
switch (subCommand) {
case "view" -> optionalUuid.ifPresentOrElse(
// Show the specified snapshot
version -> plugin.getDatabase().getSnapshot(user, version).ifPresentOrElse(
data -> DataSnapshotOverview.of(
data.unpack(plugin), data.getFileSize(plugin), user, plugin
).show(executor),
() -> plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage)),
// Show the latest snapshot
() -> plugin.getDatabase().getLatestSnapshot(user).ifPresentOrElse(
data -> DataSnapshotOverview.of(
data.unpack(plugin), data.getFileSize(plugin), user, plugin
).show(executor),
() -> plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(executor::sendMessage))
);
case "list" -> {
// Check if there is data to display
final List<DataSnapshot.Packed> dataList = plugin.getDatabase().getAllSnapshots(user);
if (dataList.isEmpty()) {
plugin.getLocales().getLocale("error_no_data_to_display")
.ifPresent(executor::sendMessage);
return;
}
// Show the list to the player
DataSnapshotList.create(dataList, user, plugin).displayPage(
executor,
parseIntArg(args, 2).or(() -> parseIntArg(args, 1)).orElse(1)
);
}
case "delete" -> {
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata delete <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// Delete user data by specified UUID
final UUID version = optionalUuid.get();
if (!plugin.getDatabase().deleteSnapshot(user, version)) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
plugin.getLocales().getLocale("data_deleted",
version.toString().split("-")[0],
version.toString(),
user.getUsername(),
user.getUuid().toString())
.ifPresent(executor::sendMessage);
}
case "restore" -> {
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata restore <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// Restore user data by specified UUID
final Optional<DataSnapshot.Packed> optionalData = plugin.getDatabase().getSnapshot(user, optionalUuid.get());
if (optionalData.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
// Restore users with a minimum of one health (prevent restoring players with <=0 health)
final DataSnapshot.Packed data = optionalData.get().copy();
data.edit(plugin, (unpacked -> {
unpacked.getHealth().ifPresent(status -> status.setHealth(Math.max(1, status.getHealth())));
unpacked.setSaveCause(DataSnapshot.SaveCause.BACKUP_RESTORE);
unpacked.setPinned(plugin.getSettings().doAutoPin(DataSnapshot.SaveCause.BACKUP_RESTORE));
}));
// Set the user's data and send a message
plugin.getDatabase().addSnapshot(user, data);
plugin.getRedisManager().sendUserDataUpdate(user, data);
plugin.getLocales().getLocale("data_restored", user.getUsername(), user.getUuid().toString(),
data.getShortId(), data.getId().toString()).ifPresent(executor::sendMessage);
}
case "pin" -> {
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata pin <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// Check that the data exists
final Optional<DataSnapshot.Packed> optionalData = plugin.getDatabase().getSnapshot(user, optionalUuid.get());
if (optionalData.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
// Pin or unpin the data
final DataSnapshot.Packed data = optionalData.get();
if (data.isPinned()) {
plugin.getDatabase().unpinSnapshot(user, data.getId());
} else {
plugin.getDatabase().pinSnapshot(user, data.getId());
}
plugin.getLocales().getLocale(data.isPinned() ? "data_unpinned" : "data_pinned", data.getShortId(),
data.getId().toString(), user.getUsername(), user.getUuid().toString())
.ifPresent(executor::sendMessage);
}
case "dump" -> {
if (optionalUuid.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_syntax",
"/userdata dump <username> <version_uuid>")
.ifPresent(executor::sendMessage);
return;
}
// Determine dump type
final boolean webDump = parseStringArg(args, 3)
.map(arg -> arg.equalsIgnoreCase("web"))
.orElse(false);
final Optional<DataSnapshot.Packed> data = plugin.getDatabase().getSnapshot(user, optionalUuid.get());
if (data.isEmpty()) {
plugin.getLocales().getLocale("error_invalid_version_uuid")
.ifPresent(executor::sendMessage);
return;
}
// Dump the data
final DataSnapshot.Packed userData = data.get();
final DataDumper dumper = DataDumper.create(userData, user, plugin);
try {
plugin.getLocales().getLocale("data_dumped", userData.getShortId(), user.getUsername(),
(webDump ? dumper.toWeb() : dumper.toFile())).ifPresent(executor::sendMessage);
} catch (Throwable e) {
plugin.log(Level.SEVERE, "Failed to dump user data", e);
}
}
default -> plugin.getLocales().getLocale("error_invalid_syntax", getUsage())
.ifPresent(executor::sendMessage);
}
}
@Nullable
@Override
public List<String> suggest(@NotNull CommandUser executor, @NotNull String[] args) {
return switch (args.length) {
case 0, 1 -> SUB_COMMANDS.keySet().stream().sorted().toList();
case 2 -> plugin.getOnlineUsers().stream().map(User::getUsername).toList();
case 4 -> parseStringArg(args, 0)
.map(arg -> arg.equalsIgnoreCase("dump") ? List.of("web", "file") : null)
.orElse(null);
default -> null;
};
}
}

View File

@@ -0,0 +1,213 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.config;
import de.themoep.minedown.adventure.MineDown;
import net.william278.annotaml.YamlFile;
import net.william278.paginedown.ListOptions;
import org.apache.commons.text.StringEscapeUtils;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* Loaded locales used by the plugin to display styled messages
*/
@YamlFile(rootedMap = true, header = """
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ HuskSync Locales ┃
┃ Developed by William278 ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ See plugin about menu for international locale credits
┣╸ Formatted in MineDown: https://github.com/Phoenix616/MineDown
┗╸ Translate HuskSync: https://william278.net/docs/husksync/Translations""")
public class Locales {
/**
* The raw set of locales loaded from yaml
*/
@NotNull
public Map<String, String> rawLocales = new HashMap<>();
/**
* Returns a raw, unformatted locale loaded from the Locales file
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @return An {@link Optional} containing the locale corresponding to the id, if it exists
*/
public Optional<String> getRawLocale(@NotNull String localeId) {
return Optional.ofNullable(rawLocales.get(localeId)).map(StringEscapeUtils::unescapeJava);
}
/**
* Returns a raw, unformatted locale loaded from the Locales file, with replacements applied
* <p>
* Note that replacements will not be MineDown-escaped; use {@link #escapeMineDown(String)} to escape replacements
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements An ordered array of replacement strings to fill in placeholders with
* @return An {@link Optional} containing the replacement-applied locale corresponding to the id, if it exists
*/
public Optional<String> getRawLocale(@NotNull String localeId, @NotNull String... replacements) {
return getRawLocale(localeId).map(locale -> applyReplacements(locale, replacements));
}
/**
* Returns a MineDown-formatted locale from the Locales file
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @return An {@link Optional} containing the formatted locale corresponding to the id, if it exists
*/
public Optional<MineDown> getLocale(@NotNull String localeId) {
return getRawLocale(localeId).map(MineDown::new);
}
/**
* Returns a MineDown-formatted locale from the Locales file, with replacements applied
* <p>
* Note that replacements will be MineDown-escaped before application
*
* @param localeId String identifier of the locale, corresponding to a key in the file
* @param replacements An ordered array of replacement strings to fill in placeholders with
* @return An {@link Optional} containing the replacement-applied, formatted locale corresponding to the id, if it exists
*/
public Optional<MineDown> getLocale(@NotNull String localeId, @NotNull String... replacements) {
return getRawLocale(localeId, Arrays.stream(replacements).map(Locales::escapeMineDown)
.toArray(String[]::new)).map(MineDown::new);
}
/**
* Apply placeholder replacements to a raw locale
*
* @param rawLocale The raw, unparsed locale
* @param replacements An ordered array of replacement strings to fill in placeholders with
* @return the raw locale, with inserted placeholders
*/
@NotNull
private String applyReplacements(@NotNull String rawLocale, @NotNull String... replacements) {
int replacementIndexer = 1;
for (String replacement : replacements) {
String replacementString = "%" + replacementIndexer + "%";
rawLocale = rawLocale.replace(replacementString, replacement);
replacementIndexer += 1;
}
return rawLocale;
}
/**
* Escape a string from {@link MineDown} formatting for use in a MineDown-formatted locale
* <p>
* Although MineDown provides {@link MineDown#escape(String)}, that method fails to escape events
* properly when using the escaped string in a replacement, so this is used instead
*
* @param string The string to escape
* @return The escaped string
*/
@NotNull
public static String escapeMineDown(@NotNull String string) {
final StringBuilder value = new StringBuilder();
for (int i = 0; i < string.length(); ++i) {
char c = string.charAt(i);
boolean isEscape = c == '\\';
boolean isColorCode = i + 1 < string.length() && (c == 167 || c == '&');
boolean isEvent = c == '[' || c == ']' || c == '(' || c == ')';
if (isEscape || isColorCode || isEvent) {
value.append('\\');
}
value.append(c);
}
return value.toString();
}
/**
* Truncates a String to a specified length, and appends an ellipsis if it is longer than the specified length
*
* @param string The string to truncate
* @param length The maximum length of the string
* @return The truncated string
*/
@NotNull
public static String truncate(@NotNull String string, int length) {
if (string.length() > length) {
return string.substring(0, length) + "";
}
return string;
}
/**
* Returns the base list options to use for a paginated chat list
*
* @param itemsPerPage The number of items to display per page
* @return The list options
*/
@NotNull
public ListOptions.Builder getBaseChatList(int itemsPerPage) {
return new ListOptions.Builder()
.setFooterFormat(getRawLocale("list_footer",
"%previous_page_button%", "%current_page%",
"%total_pages%", "%next_page_button%", "%page_jumpers%").orElse(""))
.setNextButtonFormat(getRawLocale("list_next_page_button",
"%next_page_index%", "%command%").orElse(""))
.setPreviousButtonFormat(getRawLocale("list_previous_page_button",
"%previous_page_index%", "%command%").orElse(""))
.setPageJumpersFormat(getRawLocale("list_page_jumpers",
"%page_jump_buttons%").orElse(""))
.setPageJumperPageFormat(getRawLocale("list_page_jumper_button",
"%target_page_index%", "%command%").orElse(""))
.setPageJumperCurrentPageFormat(getRawLocale("list_page_jumper_current_page",
"%current_page%").orElse(""))
.setPageJumperPageSeparator(getRawLocale("list_page_jumper_separator").orElse(""))
.setPageJumperGroupSeparator(getRawLocale("list_page_jumper_group_separator").orElse(""))
.setItemsPerPage(itemsPerPage)
.setEscapeItemsMineDown(false)
.setSpaceAfterHeader(false)
.setSpaceBeforeFooter(false);
}
@SuppressWarnings("unused")
public Locales() {
}
/**
* Determines the slot a system notification should be displayed in
*/
public enum NotificationSlot {
/**
* Displays the notification in the action bar
*/
ACTION_BAR,
/**
* Displays the notification in the chat
*/
CHAT,
/**
* Displays the notification in an Advancement Toast
*/
TOAST,
/**
* Does not display the notification
*/
NONE
}
}

View File

@@ -0,0 +1,417 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.config;
import net.william278.annotaml.YamlComment;
import net.william278.annotaml.YamlFile;
import net.william278.annotaml.YamlKey;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.Identifier;
import net.william278.husksync.database.Database;
import net.william278.husksync.listener.EventListener;
import org.jetbrains.annotations.NotNull;
import java.util.*;
/**
* Plugin settings, read from config.yml
*/
@YamlFile(header = """
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓
┃ HuskSync Config ┃
┃ Developed by William278 ┃
┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛
┣╸ Information: https://william278.net/project/husksync
┣╸ Config Help: https://william278.net/docs/husksync/config-file/
┗╸ Documentation: https://william278.net/docs/husksync""")
public class Settings {
// Top-level settings
@YamlComment("Locale of the default language file to use. Docs: https://william278.net/docs/huskhomes/translations")
@YamlKey("language")
private String language = "en-gb";
@YamlComment("Whether to automatically check for plugin updates on startup")
@YamlKey("check_for_updates")
private boolean checkForUpdates = true;
@YamlComment("Specify a common ID for grouping servers running HuskSync. "
+ "Don't modify this unless you know what you're doing!")
@YamlKey("cluster_id")
private String clusterId = "";
@YamlComment("Enable development debug logging")
@YamlKey("debug_logging")
private boolean debugLogging = false;
@YamlComment("Whether to provide modern, rich TAB suggestions for commands (if available)")
@YamlKey("brigadier_tab_completion")
private boolean brigadierTabCompletion = false;
@YamlComment("Whether to enable the Player Analytics hook. Docs: https://william278.net/docs/husksync/plan-hook")
@YamlKey("enable_plan_hook")
private boolean enablePlanHook = true;
// Database settings
@YamlComment("Type of database to use (MYSQL, MARIADB)")
@YamlKey("database.type")
private Database.Type databaseType = Database.Type.MYSQL;
@YamlComment("Specify credentials here for your MYSQL or MARIADB database")
@YamlKey("database.credentials.host")
private String mySqlHost = "localhost";
@YamlKey("database.credentials.port")
private int mySqlPort = 3306;
@YamlKey("database.credentials.database")
private String mySqlDatabase = "HuskSync";
@YamlKey("database.credentials.username")
private String mySqlUsername = "root";
@YamlKey("database.credentials.password")
private String mySqlPassword = "pa55w0rd";
@YamlKey("database.credentials.parameters")
private String mySqlConnectionParameters = "?autoReconnect=true"
+ "&useSSL=false"
+ "&useUnicode=true"
+ "&characterEncoding=UTF-8";
@YamlComment("MYSQL / MARIADB database Hikari connection pool properties. "
+ "Don't modify this unless you know what you're doing!")
@YamlKey("database.connection_pool.maximum_pool_size")
private int mySqlConnectionPoolSize = 10;
@YamlKey("database.connection_pool.minimum_idle")
private int mySqlConnectionPoolIdle = 10;
@YamlKey("database.connection_pool.maximum_lifetime")
private long mySqlConnectionPoolLifetime = 1800000;
@YamlKey("database.connection_pool.keepalive_time")
private long mySqlConnectionPoolKeepAlive = 0;
@YamlKey("database.connection_pool.connection_timeout")
private long mySqlConnectionPoolTimeout = 5000;
@YamlComment("Names of tables to use on your database. Don't modify this unless you know what you're doing!")
@YamlKey("database.table_names")
private Map<String, String> tableNames = TableName.getDefaults();
// Redis settings
@YamlComment("Specify the credentials of your Redis database here. Set \"password\" to '' if you don't have one")
@YamlKey("redis.credentials.host")
private String redisHost = "localhost";
@YamlKey("redis.credentials.port")
private int redisPort = 6379;
@YamlKey("redis.credentials.password")
private String redisPassword = "";
@YamlKey("redis.use_ssl")
private boolean redisUseSsl = false;
// Synchronization settings
@YamlComment("The number of data snapshot backups that should be kept at once per user")
@YamlKey("synchronization.max_user_data_snapshots")
private int maxUserDataSnapshots = 16;
@YamlComment("Number of hours between new snapshots being saved as backups (Use \"0\" to backup all snapshots)")
@YamlKey("synchronization.snapshot_backup_frequency")
private int snapshotBackupFrequency = 4;
@YamlComment("List of save cause IDs for which a snapshot will be automatically pinned (so it won't be rotated)."
+ " Docs: https://william278.net/docs/husksync/data-rotation#save-causes")
@YamlKey("synchronization.auto_pinned_save_causes")
private List<String> autoPinnedSaveCauses = List.of(
DataSnapshot.SaveCause.INVENTORY_COMMAND.name(),
DataSnapshot.SaveCause.ENDERCHEST_COMMAND.name(),
DataSnapshot.SaveCause.BACKUP_RESTORE.name(),
DataSnapshot.SaveCause.CONVERTED_FROM_V2.name(),
DataSnapshot.SaveCause.LEGACY_MIGRATION.name(),
DataSnapshot.SaveCause.MPDB_MIGRATION.name()
);
@YamlComment("Whether to create a snapshot for users on a world when the server saves that world")
@YamlKey("synchronization.save_on_world_save")
private boolean saveOnWorldSave = true;
@YamlComment("Whether to create a snapshot for users when they die (containing their death drops)")
@YamlKey("synchronization.save_on_death")
private boolean saveOnDeath = false;
@YamlComment("Whether to save empty death drops for users when they die")
@YamlKey("synchronization.save_empty_drops_on_death")
private boolean saveEmptyDropsOnDeath = true;
@YamlComment("Whether to use the snappy data compression algorithm. Keep on unless you know what you're doing")
@YamlKey("synchronization.compress_data")
private boolean compressData = true;
@YamlComment("Where to display sync notifications (ACTION_BAR, CHAT, TOAST or NONE)")
@YamlKey("synchronization.notification_display_slot")
private Locales.NotificationSlot notificationSlot = Locales.NotificationSlot.ACTION_BAR;
@YamlComment("(Experimental) Persist Cartography Table locked maps to let them be viewed on any server")
@YamlKey("synchronization.persist_locked_maps")
private boolean persistLockedMaps = true;
@YamlComment("Whether to synchronize player max health (requires health syncing to be enabled)")
@YamlKey("synchronization.synchronize_max_health")
private boolean synchronizeMaxHealth = true;
@YamlComment("Whether dead players who log out and log in to a different server should have their items saved. "
+ "You may need to modify this if you're using the keepInventory gamerule.")
@YamlKey("synchronization.synchronize_dead_players_changing_server")
private boolean synchronizeDeadPlayersChangingServer = true;
@YamlComment("How long, in milliseconds, this server should wait for a response from the redis server before "
+ "pulling data from the database instead (i.e., if the user did not change servers).")
@YamlKey("synchronization.network_latency_milliseconds")
private int networkLatencyMilliseconds = 500;
@YamlComment("Which data types to synchronize (Docs: https://william278.net/docs/husksync/sync-features)")
@YamlKey("synchronization.features")
private Map<String, Boolean> synchronizationFeatures = Identifier.getConfigMap();
@YamlComment("Commands which should be blocked before a player has finished syncing (Use * to block all commands)")
@YamlKey("synchronization.blacklisted_commands_while_locked")
private List<String> blacklistedCommandsWhileLocked = new ArrayList<>(List.of("*"));
@YamlComment("Event priorities for listeners (HIGHEST, NORMAL, LOWEST). Change if you encounter plugin conflicts")
@YamlKey("synchronization.event_priorities")
private Map<String, String> syncEventPriorities = EventListener.ListenerType.getDefaults();
// Zero-args constructor for instantiation via Annotaml
@SuppressWarnings("unused")
public Settings() {
}
@NotNull
public String getLanguage() {
return language;
}
public boolean doCheckForUpdates() {
return checkForUpdates;
}
@NotNull
public String getClusterId() {
return clusterId;
}
public boolean doDebugLogging() {
return debugLogging;
}
public boolean doBrigadierTabCompletion() {
return brigadierTabCompletion;
}
public boolean usePlanHook() {
return enablePlanHook;
}
@NotNull
public Database.Type getDatabaseType() {
return databaseType;
}
@NotNull
public String getMySqlHost() {
return mySqlHost;
}
public int getMySqlPort() {
return mySqlPort;
}
@NotNull
public String getMySqlDatabase() {
return mySqlDatabase;
}
@NotNull
public String getMySqlUsername() {
return mySqlUsername;
}
@NotNull
public String getMySqlPassword() {
return mySqlPassword;
}
@NotNull
public String getMySqlConnectionParameters() {
return mySqlConnectionParameters;
}
@NotNull
public String getTableName(@NotNull TableName tableName) {
return tableNames.getOrDefault(tableName.name().toLowerCase(Locale.ENGLISH), tableName.defaultName);
}
public int getMySqlConnectionPoolSize() {
return mySqlConnectionPoolSize;
}
public int getMySqlConnectionPoolIdle() {
return mySqlConnectionPoolIdle;
}
public long getMySqlConnectionPoolLifetime() {
return mySqlConnectionPoolLifetime;
}
public long getMySqlConnectionPoolKeepAlive() {
return mySqlConnectionPoolKeepAlive;
}
public long getMySqlConnectionPoolTimeout() {
return mySqlConnectionPoolTimeout;
}
@NotNull
public String getRedisHost() {
return redisHost;
}
public int getRedisPort() {
return redisPort;
}
@NotNull
public String getRedisPassword() {
return redisPassword;
}
public boolean redisUseSsl() {
return redisUseSsl;
}
public int getMaxUserDataSnapshots() {
return maxUserDataSnapshots;
}
public int getBackupFrequency() {
return snapshotBackupFrequency;
}
public boolean doSaveOnWorldSave() {
return saveOnWorldSave;
}
public boolean doSaveOnDeath() {
return saveOnDeath;
}
public boolean doSaveEmptyDropsOnDeath() {
return saveEmptyDropsOnDeath;
}
public boolean doCompressData() {
return compressData;
}
public boolean doAutoPin(@NotNull DataSnapshot.SaveCause cause) {
return autoPinnedSaveCauses.contains(cause.name());
}
@NotNull
public Locales.NotificationSlot getNotificationDisplaySlot() {
return notificationSlot;
}
public boolean doPersistLockedMaps() {
return persistLockedMaps;
}
public boolean doSynchronizeDeadPlayersChangingServer() {
return synchronizeDeadPlayersChangingServer;
}
public boolean doSynchronizeMaxHealth() {
return synchronizeMaxHealth;
}
public int getNetworkLatencyMilliseconds() {
return networkLatencyMilliseconds;
}
@NotNull
public Map<String, Boolean> getSynchronizationFeatures() {
return synchronizationFeatures;
}
public boolean isSyncFeatureEnabled(@NotNull Identifier id) {
return id.isCustom() || getSynchronizationFeatures().getOrDefault(id.getKeyValue(), id.isEnabledByDefault());
}
@NotNull
public List<String> getBlacklistedCommandsWhileLocked() {
return blacklistedCommandsWhileLocked;
}
@NotNull
public EventListener.Priority getEventPriority(@NotNull EventListener.ListenerType type) {
try {
return EventListener.Priority.valueOf(syncEventPriorities.get(type.name().toLowerCase(Locale.ENGLISH)));
} catch (IllegalArgumentException e) {
return EventListener.Priority.NORMAL;
}
}
/**
* Represents the names of tables in the database
*/
public enum TableName {
USERS("husksync_users"),
USER_DATA("husksync_user_data");
private final String defaultName;
TableName(@NotNull String defaultName) {
this.defaultName = defaultName;
}
@NotNull
private Map.Entry<String, String> toEntry() {
return Map.entry(name().toLowerCase(Locale.ENGLISH), defaultName);
}
@SuppressWarnings("unchecked")
@NotNull
private static Map<String, String> getDefaults() {
return Map.ofEntries(Arrays.stream(values())
.map(TableName::toEntry)
.toArray(Map.Entry[]::new));
}
}
}

View File

@@ -0,0 +1,368 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import com.google.gson.annotations.SerializedName;
import net.william278.husksync.HuskSync;
import net.william278.husksync.user.OnlineUser;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.*;
import java.util.stream.Collectors;
/**
* A piece of data, held by a {@link DataHolder}
*/
@SuppressWarnings("unused")
public interface Data {
/**
* Apply (set) this data container to the given {@link OnlineUser}
*
* @param user the user to apply this element to
* @param plugin the plugin instance
*/
void apply(@NotNull UserDataHolder user, @NotNull HuskSync plugin);
/**
* A data container holding data for:
* <ul>
* <li>Inventories</li>
* <li>Ender Chests</li>
* </ul>
*/
interface Items extends Data {
@NotNull
Stack[] getStack();
default int getSlotCount() {
return getStack().length;
}
record Stack(@NotNull String material, int amount, @Nullable String name,
@Nullable List<String> lore, @NotNull List<String> enchantments) {
}
default boolean isEmpty() {
return Arrays.stream(getStack()).allMatch(Objects::isNull) || getStack().length == 0;
}
void clear();
void setContents(@NotNull Items contents);
/**
* A data container holding data for inventories and selected hotbar slot
*/
interface Inventory extends Items {
int getHeldItemSlot();
void setHeldItemSlot(int heldItemSlot) throws IllegalArgumentException;
default Optional<Stack> getHelmet() {
return Optional.ofNullable(getStack()[39]);
}
default Optional<Stack> getChestplate() {
return Optional.ofNullable(getStack()[38]);
}
default Optional<Stack> getLeggings() {
return Optional.ofNullable(getStack()[37]);
}
default Optional<Stack> getBoots() {
return Optional.ofNullable(getStack()[36]);
}
default Optional<Stack> getOffHand() {
return Optional.ofNullable(getStack()[40]);
}
}
/**
* Data container holding data for ender chests
*/
interface EnderChest extends Items {
}
}
/**
* Data container holding data for potion effects
*/
interface PotionEffects extends Data {
@NotNull
List<Effect> getActiveEffects();
/**
* Represents a potion effect
*
* @param type the type of potion effect
* @param amplifier the amplifier of the potion effect
* @param duration the duration of the potion effect
* @param isAmbient whether the potion effect is ambient
* @param showParticles whether the potion effect shows particles
* @param hasIcon whether the potion effect displays a HUD icon
*/
record Effect(@SerializedName("type") @NotNull String type,
@SerializedName("amplifier") int amplifier,
@SerializedName("duration") int duration,
@SerializedName("is_ambient") boolean isAmbient,
@SerializedName("show_particles") boolean showParticles,
@SerializedName("has_icon") boolean hasIcon) {
}
}
/**
* Data container holding data for advancements
*/
interface Advancements extends Data {
@NotNull
List<Advancement> getCompleted();
@NotNull
default List<Advancement> getCompletedExcludingRecipes() {
return getCompleted().stream()
.filter(advancement -> !advancement.getKey().startsWith("minecraft:recipe"))
.collect(Collectors.toList());
}
void setCompleted(@NotNull List<Advancement> completed);
class Advancement {
@SerializedName("key")
private String key;
@SerializedName("completed_criteria")
private Map<String, Long> completedCriteria;
private Advancement(@NotNull String key, @NotNull Map<String, Date> completedCriteria) {
this.key = key;
this.completedCriteria = adaptDateMap(completedCriteria);
}
@SuppressWarnings("unused")
private Advancement() {
}
@NotNull
public static Advancement adapt(@NotNull String key, @NotNull Map<String, Date> completedCriteria) {
return new Advancement(key, completedCriteria);
}
@NotNull
private static Map<String, Long> adaptDateMap(@NotNull Map<String, Date> dateMap) {
return dateMap.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue().getTime()));
}
@NotNull
private static Map<String, Date> adaptLongMap(@NotNull Map<String, Long> dateMap) {
return dateMap.entrySet().stream()
.collect(Collectors.toMap(Map.Entry::getKey, e -> new Date(e.getValue())));
}
@NotNull
public String getKey() {
return key;
}
public void setKey(@NotNull String key) {
this.key = key;
}
public Map<String, Date> getCompletedCriteria() {
return adaptLongMap(completedCriteria);
}
public void setCompletedCriteria(Map<String, Date> completedCriteria) {
this.completedCriteria = adaptDateMap(completedCriteria);
}
}
}
/**
* Data container holding data for the player's location
*/
interface Location extends Data {
double getX();
void setX(double x);
double getY();
void setY(double y);
double getZ();
void setZ(double z);
float getYaw();
void setYaw(float yaw);
float getPitch();
void setPitch(float pitch);
@NotNull
World getWorld();
void setWorld(@NotNull World world);
record World(
@SerializedName("name") @NotNull String name,
@SerializedName("uuid") @NotNull UUID uuid,
@SerializedName("environment") @NotNull String environment
) {
}
}
/**
* Data container holding data for statistics
*/
interface Statistics extends Data {
@NotNull
Map<String, Integer> getGenericStatistics();
@NotNull
Map<String, Map<String, Integer>> getBlockStatistics();
@NotNull
Map<String, Map<String, Integer>> getItemStatistics();
@NotNull
Map<String, Map<String, Integer>> getEntityStatistics();
}
/**
* Data container holding data for persistent data containers
*/
interface PersistentData extends Data {
}
/**
* A data container holding data for:
* <ul>
* <li>Health</li>
* <li>Max Health</li>
* <li>Health Scale</li>
* </ul>
*/
interface Health extends Data {
double getHealth();
void setHealth(double health);
double getMaxHealth();
void setMaxHealth(double maxHealth);
double getHealthScale();
void setHealthScale(double healthScale);
}
/**
* A data container holding data for:
* <ul>
*
* <li>Food Level</li>
* <li>Saturation</li>
* <li>Exhaustion</li>
* </ul>
*/
interface Hunger extends Data {
int getFoodLevel();
void setFoodLevel(int foodLevel);
float getSaturation();
void setSaturation(float saturation);
float getExhaustion();
void setExhaustion(float exhaustion);
}
/**
* A data container holding data for:
* <ul>
* <li>Total experience</li>
* <li>Experience level</li>
* <li>Experience progress</li>
* </ul>
*/
interface Experience extends Data {
int getTotalExperience();
void setTotalExperience(int totalExperience);
int getExpLevel();
void setExpLevel(int expLevel);
float getExpProgress();
void setExpProgress(float expProgress);
}
/**
* A data container holding data for:
* <ul>
* <li>Game mode</li>
* <li>Allow flight</li>
* <li>Is flying</li>
* </ul>
*/
interface GameMode extends Data {
@NotNull
String getGameMode();
void setGameMode(@NotNull String gameMode);
boolean getAllowFlight();
void setAllowFlight(boolean allowFlight);
boolean getIsFlying();
void setIsFlying(boolean isFlying);
}
}

View File

@@ -0,0 +1,140 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.Optional;
@SuppressWarnings("unused")
public interface DataHolder {
@NotNull
Map<Identifier, Data> getData();
default Optional<? extends Data> getData(@NotNull Identifier identifier) {
return Optional.ofNullable(getData().get(identifier));
}
default void setData(@NotNull Identifier identifier, @NotNull Data data) {
getData().put(identifier, data);
}
@NotNull
default Optional<Data.Items.Inventory> getInventory() {
return getData(Identifier.INVENTORY).map(Data.Items.Inventory.class::cast);
}
default void setInventory(@NotNull Data.Items.Inventory inventory) {
setData(Identifier.INVENTORY, inventory);
}
@NotNull
default Optional<Data.Items.EnderChest> getEnderChest() {
return getData(Identifier.ENDER_CHEST).map(Data.Items.EnderChest.class::cast);
}
default void setEnderChest(@NotNull Data.Items.EnderChest enderChest) {
setData(Identifier.ENDER_CHEST, enderChest);
}
@NotNull
default Optional<Data.PotionEffects> getPotionEffects() {
return getData(Identifier.POTION_EFFECTS).map(Data.PotionEffects.class::cast);
}
default void setPotionEffects(@NotNull Data.PotionEffects potionEffects) {
setData(Identifier.POTION_EFFECTS, potionEffects);
}
@NotNull
default Optional<Data.Advancements> getAdvancements() {
return getData(Identifier.ADVANCEMENTS).map(Data.Advancements.class::cast);
}
default void setAdvancements(@NotNull Data.Advancements advancements) {
setData(Identifier.ADVANCEMENTS, advancements);
}
@NotNull
default Optional<Data.Location> getLocation() {
return Optional.ofNullable((Data.Location) getData().get(Identifier.LOCATION));
}
default void setLocation(@NotNull Data.Location location) {
getData().put(Identifier.LOCATION, location);
}
@NotNull
default Optional<Data.Statistics> getStatistics() {
return Optional.ofNullable((Data.Statistics) getData().get(Identifier.STATISTICS));
}
default void setStatistics(@NotNull Data.Statistics statistics) {
getData().put(Identifier.STATISTICS, statistics);
}
@NotNull
default Optional<Data.Health> getHealth() {
return Optional.ofNullable((Data.Health) getData().get(Identifier.HEALTH));
}
default void setHealth(@NotNull Data.Health health) {
getData().put(Identifier.HEALTH, health);
}
@NotNull
default Optional<Data.Hunger> getHunger() {
return Optional.ofNullable((Data.Hunger) getData().get(Identifier.HUNGER));
}
default void setHunger(@NotNull Data.Hunger hunger) {
getData().put(Identifier.HUNGER, hunger);
}
@NotNull
default Optional<Data.Experience> getExperience() {
return Optional.ofNullable((Data.Experience) getData().get(Identifier.EXPERIENCE));
}
default void setExperience(@NotNull Data.Experience experience) {
getData().put(Identifier.EXPERIENCE, experience);
}
@NotNull
default Optional<Data.GameMode> getGameMode() {
return Optional.ofNullable((Data.GameMode) getData().get(Identifier.GAME_MODE));
}
default void setGameMode(@NotNull Data.GameMode gameMode) {
getData().put(Identifier.GAME_MODE, gameMode);
}
@NotNull
default Optional<Data.PersistentData> getPersistentData() {
return Optional.ofNullable((Data.PersistentData) getData().get(Identifier.PERSISTENT_DATA));
}
default void setPersistentData(@NotNull Data.PersistentData persistentData) {
getData().put(Identifier.PERSISTENT_DATA, persistentData);
}
}

View File

@@ -0,0 +1,840 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import com.google.gson.annotations.Expose;
import com.google.gson.annotations.SerializedName;
import de.themoep.minedown.adventure.MineDown;
import net.william278.desertwell.util.Version;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.Adaptable;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.config.Locales;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.function.Consumer;
import java.util.stream.Collectors;
/**
* A snapshot of a {@link DataHolder} at a given time.
*
* @since 3.0
*/
public class DataSnapshot {
/*
* Current version of the snapshot data format.
* HuskSync v3.0 uses v4; HuskSync v2.0 uses v1-v3
*/
protected static final int CURRENT_FORMAT_VERSION = 4;
@SerializedName("id")
protected UUID id;
@SerializedName("pinned")
protected boolean pinned;
@SerializedName("timestamp")
protected OffsetDateTime timestamp;
@SerializedName("save_cause")
protected SaveCause saveCause;
@SerializedName("minecraft_version")
protected String minecraftVersion;
@SerializedName("platform_type")
protected String platformType;
@SerializedName("format_version")
protected int formatVersion;
@SerializedName("data")
protected Map<String, String> data;
private DataSnapshot(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
this.id = id;
this.pinned = pinned;
this.timestamp = timestamp;
this.saveCause = saveCause;
this.data = data;
this.minecraftVersion = minecraftVersion.toStringWithoutMetadata();
this.platformType = platformType;
this.formatVersion = formatVersion;
}
@SuppressWarnings("unused")
private DataSnapshot() {
}
@NotNull
@ApiStatus.Internal
public static DataSnapshot.Builder builder(@NotNull HuskSync plugin) {
return new Builder(plugin);
}
// Deserialize a DataSnapshot downloaded from the database (with an ID & Timestamp from the database)
@NotNull
@ApiStatus.Internal
public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data, @Nullable UUID id,
@Nullable OffsetDateTime timestamp) throws IllegalStateException {
final DataSnapshot.Packed snapshot = plugin.getDataAdapter().fromBytes(data, DataSnapshot.Packed.class);
if (snapshot.getMinecraftVersion().compareTo(plugin.getMinecraftVersion()) > 0) {
throw new IllegalStateException(String.format("Cannot set data for user because the Minecraft version of " +
"their user data (%s) is newer than the server's Minecraft version (%s)." +
"Please ensure each server is running the same version of Minecraft.",
snapshot.getMinecraftVersion(), plugin.getMinecraftVersion()));
}
if (snapshot.getFormatVersion() > CURRENT_FORMAT_VERSION) {
throw new IllegalStateException(String.format("Cannot set data for user because the format version of " +
"their user data (%s) is newer than the current format version (%s). " +
"Please ensure each server is running the latest version of HuskSync.",
snapshot.getFormatVersion(), CURRENT_FORMAT_VERSION));
}
if (snapshot.getFormatVersion() < CURRENT_FORMAT_VERSION) {
if (plugin.getLegacyConverter().isPresent()) {
return plugin.getLegacyConverter().get().convert(
data,
Objects.requireNonNull(id, "Attempted legacy conversion with null UUID!"),
Objects.requireNonNull(timestamp, "Attempted legacy conversion with null timestamp!")
);
}
throw new IllegalStateException(String.format(
"No legacy converter to convert format version: %s", snapshot.getFormatVersion()
));
}
if (!snapshot.getPlatformType().equalsIgnoreCase(plugin.getPlatformType())) {
throw new IllegalStateException(String.format("Cannot set data for user because the platform type of " +
"their user data (%s) is different to the server platform type (%s). " +
"Please ensure each server is running the same platform type.",
snapshot.getPlatformType(), plugin.getPlatformType()));
}
return snapshot;
}
// Deserialize a DataSnapshot from a network message payload (without an ID)
@NotNull
@ApiStatus.Internal
public static DataSnapshot.Packed deserialize(@NotNull HuskSync plugin, byte[] data) throws IllegalStateException {
return deserialize(plugin, data, null, null);
}
/**
* Return the ID of the snapshot
*
* @return The snapshot ID
* @since 3.0
*/
@NotNull
public UUID getId() {
return id;
}
/**
* Get the short display ID of the snapshot
*
* @return The short display ID
* @since 3.0
*/
@NotNull
public String getShortId() {
return id.toString().substring(0, 8);
}
/**
* Get whether the snapshot is pinned
*
* @return Whether the snapshot is pinned
* @since 3.0
*/
public boolean isPinned() {
return pinned;
}
/**
* Set whether the snapshot is pinned
*
* @param pinned Whether the snapshot is pinned
* @since 3.0
*/
public void setPinned(boolean pinned) {
this.pinned = pinned;
}
/**
* Get why the snapshot was created
*
* @return The {@link SaveCause data save cause} of the snapshot
* @since 3.0
*/
@NotNull
public SaveCause getSaveCause() {
return saveCause;
}
/**
* Set why the snapshot was created
*
* @param saveCause The {@link SaveCause data save cause} of the snapshot
* @since 3.0
*/
public void setSaveCause(SaveCause saveCause) {
this.saveCause = saveCause;
}
/**
* Get when the snapshot was created
*
* @return The {@link OffsetDateTime timestamp} of the snapshot
* @since 3.0
*/
@NotNull
public OffsetDateTime getTimestamp() {
return timestamp;
}
/**
* Get the Minecraft version of the server when the Snapshot was created
*
* @return The Minecraft version of the server when the Snapshot was created
* @since 3.0
*/
@NotNull
public Version getMinecraftVersion() {
return Version.fromString(minecraftVersion);
}
/**
* Get the platform type of the server when the Snapshot was created
*
* @return The platform type of the server when the Snapshot was created (e.g. {@code "bukkit"})
* @since 3.0
*/
@NotNull
public String getPlatformType() {
return platformType;
}
/**
* Get the format version of the snapshot (indicating the version of HuskSync that created it)
*
* @return The format version of the snapshot
* @since 3.0
*/
public int getFormatVersion() {
return formatVersion;
}
/**
* A packed {@link DataSnapshot} that has not been deserialized.
*
* @since 3.0
*/
public static class Packed extends DataSnapshot implements Adaptable {
protected Packed(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion);
}
@SuppressWarnings("unused")
private Packed() {
}
@ApiStatus.Internal
public void edit(@NotNull HuskSync plugin, @NotNull Consumer<Unpacked> editor) {
final Unpacked data = unpack(plugin);
editor.accept(data);
this.pinned = data.isPinned();
this.saveCause = data.getSaveCause();
this.data = data.serializeData(plugin);
}
/**
* Create a copy of this snapshot at the current system timestamp with a new ID
*
* @return The copied snapshot (with a new ID, with a timestamp of the current system time)
*/
@NotNull
public Packed copy() {
return new Packed(
UUID.randomUUID(), pinned, OffsetDateTime.now(), saveCause, data,
getMinecraftVersion(), platformType, formatVersion
);
}
@NotNull
@ApiStatus.Internal
public byte[] asBytes(@NotNull HuskSync plugin) throws DataAdapter.AdaptionException {
return plugin.getDataAdapter().toBytes(this);
}
@NotNull
@ApiStatus.Internal
public String asJson(@NotNull HuskSync plugin) throws DataAdapter.AdaptionException {
return plugin.getDataAdapter().toJson(this);
}
@ApiStatus.Internal
public int getFileSize(@NotNull HuskSync plugin) {
return asBytes(plugin).length;
}
@NotNull
public DataSnapshot.Unpacked unpack(@NotNull HuskSync plugin) {
return new Unpacked(
id, pinned, timestamp, saveCause, data,
getMinecraftVersion(), platformType, formatVersion, plugin
);
}
}
/**
* An unpacked {@link DataSnapshot}.
*
* @since 3.0
*/
public static class Unpacked extends DataSnapshot implements DataHolder {
@Expose(serialize = false, deserialize = false)
private final Map<Identifier, Data> deserialized;
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<String, String> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion,
@NotNull HuskSync plugin) {
super(id, pinned, timestamp, saveCause, data, minecraftVersion, platformType, formatVersion);
this.deserialized = deserializeData(plugin);
}
private Unpacked(@NotNull UUID id, boolean pinned, @NotNull OffsetDateTime timestamp,
@NotNull SaveCause saveCause, @NotNull Map<Identifier, Data> data,
@NotNull Version minecraftVersion, @NotNull String platformType, int formatVersion) {
super(id, pinned, timestamp, saveCause, Map.of(), minecraftVersion, platformType, formatVersion);
this.deserialized = data;
}
@NotNull
@ApiStatus.Internal
private Map<Identifier, Data> deserializeData(@NotNull HuskSync plugin) {
return data.entrySet().stream()
.map((entry) -> plugin.getIdentifier(entry.getKey()).map(id -> Map.entry(
id, plugin.getSerializers().get(id).deserialize(entry.getValue())
)).orElse(null))
.filter(Objects::nonNull)
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
@NotNull
@ApiStatus.Internal
private Map<String, String> serializeData(@NotNull HuskSync plugin) {
return deserialized.entrySet().stream()
.map((entry) -> Map.entry(entry.getKey().toString(),
Objects.requireNonNull(
plugin.getSerializers().get(entry.getKey()),
String.format("No serializer found for %s", entry.getKey())
).serialize(entry.getValue())))
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
}
/**
* Get the data the snapshot is holding
*
* @return The data map
* @since 3.0
*/
@NotNull
public Map<Identifier, Data> getData() {
return deserialized;
}
/**
* Pack the {@link DataSnapshot} into a {@link DataSnapshot.Packed packed} snapshot
*
* @param plugin The HuskSync plugin instance
* @return The packed snapshot
* @since 3.0
*/
@NotNull
@ApiStatus.Internal
public DataSnapshot.Packed pack(@NotNull HuskSync plugin) {
return new DataSnapshot.Packed(
id, pinned, timestamp, saveCause, serializeData(plugin),
getMinecraftVersion(), platformType, formatVersion
);
}
}
/**
* A builder for {@link DataSnapshot}s.
*
* @since 3.0
*/
@SuppressWarnings("unused")
public static class Builder {
private final HuskSync plugin;
private UUID id;
private SaveCause saveCause;
private boolean pinned;
private OffsetDateTime timestamp;
private final Map<Identifier, Data> data;
private Builder(@NotNull HuskSync plugin) {
this.plugin = plugin;
this.pinned = false;
this.data = new HashMap<>();
this.timestamp = OffsetDateTime.now();
this.id = UUID.randomUUID();
}
/**
* Set the {@link UUID unique ID} of the snapshot
*
* @param id The {@link UUID} of the snapshot
* @return The builder
*/
@NotNull
public Builder id(@NotNull UUID id) {
this.id = id;
return this;
}
/**
* Set the cause of the data save
*
* @param saveCause The cause of the data save
* @return The builder
* @apiNote If the {@link SaveCause data save cause} specified is configured to auto-pin, then the value of
* {@link #pinned(boolean)} will be ignored
* @since 3.0
*/
@NotNull
public Builder saveCause(@NotNull SaveCause saveCause) {
this.saveCause = saveCause;
return this;
}
/**
* Set whether the data should be pinned
*
* @param pinned Whether the data should be pinned
* @return The builder
* @apiNote If the {@link SaveCause data save cause} specified is configured to auto-pin, this will be ignored
* @since 3.0
*/
@NotNull
public Builder pinned(boolean pinned) {
this.pinned = pinned;
return this;
}
/**
* Set the timestamp of the snapshot.
* By default, this is the current server time.
* The timestamp passed to this method cannot be in the future.
* <p>
* Note that this will affect the rotation of data snapshots in the database if unpinned,
* as well as the order snapshots appear in the list.
*
* @param timestamp The timestamp
* @return The builder
* @throws IllegalArgumentException if the timestamp is in the future
* @since 3.0
*/
@NotNull
public Builder timestamp(@NotNull OffsetDateTime timestamp) {
if (timestamp.isAfter(OffsetDateTime.now())) {
throw new IllegalArgumentException("Data snapshots cannot have a timestamp set in the future");
}
this.timestamp = timestamp;
return this;
}
/**
* Set the data for a given identifier
*
* @param identifier The identifier
* @param data The data
* @return The builder
* @since 3.0
*/
@NotNull
public Builder data(@NotNull Identifier identifier, @NotNull Data data) {
this.data.put(identifier, data);
return this;
}
/**
* Set a map of data to the snapshot
*
* @param data The data
* @return The builder
* @since 3.0
*/
@NotNull
public Builder data(@NotNull Map<Identifier, Data> data) {
this.data.putAll(data);
return this;
}
/**
* Set the inventory contents of the snapshot
* <p>
* Equivalent to {@code data(Identifier.INVENTORY, inventory)}
* </p>
*
* @param inventory The inventory contents
* @return The builder
* @since 3.0
*/
@NotNull
public Builder inventory(@NotNull Data.Items.Inventory inventory) {
return data(Identifier.INVENTORY, inventory);
}
/**
* Set the Ender Chest contents of the snapshot
* <p>
* Equivalent to {@code data(Identifier.ENDER_CHEST, inventory)}
* </p>
*
* @param enderChest The Ender Chest contents
* @return The builder
* @since 3.0
*/
@NotNull
public Builder enderChest(@NotNull Data.Items.EnderChest enderChest) {
return data(Identifier.ENDER_CHEST, enderChest);
}
/**
* Set the potion effects of the snapshot
* <p>
* Equivalent to {@code data(Identifier.POTION_EFFECTS, potionEffects)}
* </p>
*
* @param potionEffects The potion effects
* @return The builder
* @since 3.0
*/
@NotNull
public Builder potionEffects(@NotNull Data.PotionEffects potionEffects) {
return data(Identifier.POTION_EFFECTS, potionEffects);
}
/**
* Set the advancements of the snapshot
* <p>
* Equivalent to {@code data(Identifier.ADVANCEMENTS, advancements)}
* </p>
*
* @param advancements The advancements
* @return The builder
* @since 3.0
*/
@NotNull
public Builder advancements(@NotNull Data.Advancements advancements) {
return data(Identifier.ADVANCEMENTS, advancements);
}
/**
* Set the location of the snapshot
* <p>
* Equivalent to {@code data(Identifier.LOCATION, location)}
* </p>
*
* @param location The location
* @return The builder
* @since 3.0
*/
@NotNull
public Builder location(@NotNull Data.Location location) {
return data(Identifier.LOCATION, location);
}
/**
* Set the statistics of the snapshot
* <p>
* Equivalent to {@code data(Identifier.STATISTICS, statistics)}
* </p>
*
* @param statistics The statistics
* @return The builder
* @since 3.0
*/
@NotNull
public Builder statistics(@NotNull Data.Statistics statistics) {
return data(Identifier.STATISTICS, statistics);
}
/**
* Set the health of the snapshot
* <p>
* Equivalent to {@code data(Identifier.HEALTH, health)}
* </p>
*
* @param health The health
* @return The builder
* @since 3.0
*/
@NotNull
public Builder health(@NotNull Data.Health health) {
return data(Identifier.HEALTH, health);
}
/**
* Set the hunger of the snapshot
* <p>
* Equivalent to {@code data(Identifier.HUNGER, hunger)}
* </p>
*
* @param hunger The hunger
* @return The builder
* @since 3.0
*/
@NotNull
public Builder hunger(@NotNull Data.Hunger hunger) {
return data(Identifier.HUNGER, hunger);
}
/**
* Set the experience of the snapshot
* <p>
* Equivalent to {@code data(Identifier.EXPERIENCE, experience)}
* </p>
*
* @param experience The experience
* @return The builder
* @since 3.0
*/
@NotNull
public Builder experience(@NotNull Data.Experience experience) {
return data(Identifier.EXPERIENCE, experience);
}
/**
* Set the game mode of the snapshot
* <p>
* Equivalent to {@code data(Identifier.GAME_MODE, gameMode)}
* </p>
*
* @param gameMode The game mode
* @return The builder
* @since 3.0
*/
@NotNull
public Builder gameMode(@NotNull Data.GameMode gameMode) {
return data(Identifier.GAME_MODE, gameMode);
}
/**
* Set the persistent data container of the snapshot
* <p>
* Equivalent to {@code data(Identifier.PERSISTENT_DATA, persistentData)}
* </p>
*
* @param persistentData The persistent data container data
* @return The builder
* @since 3.0
*/
@NotNull
public Builder persistentData(@NotNull Data.PersistentData persistentData) {
return data(Identifier.PERSISTENT_DATA, persistentData);
}
/**
* Build the {@link DataSnapshot}
*
* @return The {@link DataSnapshot.Unpacked snapshot}
* @throws IllegalStateException If no save cause is specified
* @since 3.0
*/
@NotNull
public DataSnapshot.Unpacked build() throws IllegalStateException {
if (saveCause == null) {
throw new IllegalStateException("Cannot build DataSnapshot without a save cause");
}
return new Unpacked(
id,
pinned || plugin.getSettings().doAutoPin(saveCause),
timestamp,
saveCause,
data,
plugin.getMinecraftVersion(),
plugin.getPlatformType(),
DataSnapshot.CURRENT_FORMAT_VERSION
);
}
/**
* Build and pack the {@link DataSnapshot}
*
* @return The {@link DataSnapshot.Packed snapshot}
* @throws IllegalStateException If no save cause is specified
* @since 3.0
*/
@NotNull
public DataSnapshot.Packed buildAndPack() throws IllegalStateException {
return build().pack(plugin);
}
}
/**
* Identifies the cause of a player data save.
*
* @implNote This enum is saved in the database.
* </p>
* Cause names have a max length of 32 characters.
*/
public enum SaveCause {
/**
* Indicates data saved when a player disconnected from the server (either to change servers, or to log off)
*
* @since 2.0
*/
DISCONNECT,
/**
* Indicates data saved when the world saved
*
* @since 2.0
*/
WORLD_SAVE,
/**
* Indicates data saved when the user died
*
* @since 2.1
*/
DEATH,
/**
* Indicates data saved when the server shut down
*
* @since 2.0
*/
SERVER_SHUTDOWN,
/**
* Indicates data was saved by editing inventory contents via the {@code /inventory} command
*
* @since 2.0
*/
INVENTORY_COMMAND,
/**
* Indicates data was saved by editing Ender Chest contents via the {@code /enderchest} command
*
* @since 2.0
*/
ENDERCHEST_COMMAND,
/**
* Indicates data was saved by restoring it from a previous version
*
* @since 2.0
*/
BACKUP_RESTORE,
/**
* Indicates data was saved by an API call
*
* @since 2.0
*/
API,
/**
* Indicates data was saved from being imported from MySQLPlayerDataBridge
*
* @since 2.0
*/
MPDB_MIGRATION,
/**
* Indicates data was saved from being imported from a legacy version (v1.x -> v2.x)
*
* @since 2.0
*/
LEGACY_MIGRATION,
/**
* Indicates data was saved from being imported from a legacy version (v2.x -> v3.x)
*
* @since 3.0
*/
CONVERTED_FROM_V2;
@NotNull
public String getDisplayName() {
return Locales.truncate(name().toLowerCase(Locale.ENGLISH)
.replaceAll("_", " "), 18);
}
}
/**
* Represents the cause of a player having their data updated.
*/
public enum UpdateCause {
/**
* Indicates the data was updated by a synchronization process
*
* @since 3.0
*/
SYNCHRONIZED("synchronization_complete", "synchronization_failed"),
/**
* Indicates the data was updated by a user joining the server
*
* @since 3.0
*/
NEW_USER("user_registration_complete", null),
/**
* Indicates the data was updated by a data update process (management command, API, etc.)
*
* @since 3.0
*/
UPDATED("data_update_complete", "data_update_failed");
private final String completedLocale;
private final String failureLocale;
UpdateCause(@Nullable String completedLocale, @Nullable String failureLocale) {
this.completedLocale = completedLocale;
this.failureLocale = failureLocale;
}
public Optional<MineDown> getCompletedLocale(@NotNull HuskSync plugin) {
if (completedLocale != null) {
return plugin.getLocales().getLocale(completedLocale);
}
return Optional.empty();
}
public Optional<MineDown> getFailedLocale(@NotNull HuskSync plugin) {
if (failureLocale != null) {
return plugin.getLocales().getLocale(failureLocale);
}
return Optional.empty();
}
}
}

View File

@@ -0,0 +1,177 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import net.kyori.adventure.key.InvalidKeyException;
import net.kyori.adventure.key.Key;
import org.intellij.lang.annotations.Subst;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.Map;
import java.util.stream.Stream;
/**
* Identifiers of different types of {@link Data}s
*/
public class Identifier {
public static Identifier INVENTORY = huskSync("inventory", true);
public static Identifier ENDER_CHEST = huskSync("ender_chest", true);
public static Identifier POTION_EFFECTS = huskSync("potion_effects", true);
public static Identifier ADVANCEMENTS = huskSync("advancements", true);
public static Identifier LOCATION = huskSync("location", false);
public static Identifier STATISTICS = huskSync("statistics", true);
public static Identifier HEALTH = huskSync("health", true);
public static Identifier HUNGER = huskSync("hunger", true);
public static Identifier EXPERIENCE = huskSync("experience", true);
public static Identifier GAME_MODE = huskSync("game_mode", true);
public static Identifier PERSISTENT_DATA = huskSync("persistent_data", true);
private final Key key;
private final boolean configDefault;
private Identifier(@NotNull Key key, boolean configDefault) {
this.key = key;
this.configDefault = configDefault;
}
/**
* Create an identifier from a {@link Key}
*
* @param key the key
* @return the identifier
* @since 3.0
*/
@NotNull
public static Identifier from(@NotNull Key key) {
if (key.namespace().equals("husksync")) {
throw new IllegalArgumentException("You cannot register a key with \"husksync\" as the namespace!");
}
return new Identifier(key, true);
}
/**
* Create an identifier from a namespace and value
*
* @param plugin the namespace
* @param name the value
* @return the identifier
* @since 3.0
*/
@NotNull
public static Identifier from(@Subst("plugin") @NotNull String plugin, @Subst("null") @NotNull String name) {
return from(Key.key(plugin, name));
}
@NotNull
private static Identifier huskSync(@Subst("null") @NotNull String name,
boolean configDefault) throws InvalidKeyException {
return new Identifier(Key.key("husksync", name), configDefault);
}
@NotNull
@SuppressWarnings("unused")
private static Identifier parse(@NotNull String key) throws InvalidKeyException {
return huskSync(key, true);
}
public boolean isEnabledByDefault() {
return configDefault;
}
@NotNull
private Map.Entry<String, Boolean> getConfigEntry() {
return Map.entry(getKeyValue(), configDefault);
}
/**
* <b>(Internal use only)</b> - Get a map of the default config entries for all HuskSync identifiers
*
* @return a map of all the config entries
* @since 3.0
*/
@NotNull
@ApiStatus.Internal
@SuppressWarnings("unchecked")
public static Map<String, Boolean> getConfigMap() {
return Map.ofEntries(Stream.of(
INVENTORY, ENDER_CHEST, POTION_EFFECTS, ADVANCEMENTS, LOCATION,
STATISTICS, HEALTH, HUNGER, EXPERIENCE, GAME_MODE, PERSISTENT_DATA
)
.map(Identifier::getConfigEntry)
.toArray(Map.Entry[]::new));
}
/**
* Get the namespace of the identifier
*
* @return the namespace
*/
@NotNull
public String getKeyNamespace() {
return key.namespace();
}
/**
* Get the value of the identifier
*
* @return the value
*/
@NotNull
public String getKeyValue() {
return key.value();
}
/**
* Returns {@code true} if the identifier is a custom (non-HuskSync) identifier
*
* @return {@code false} if {@link #getKeyNamespace()} returns "husksync"; {@code true} otherwise
*/
public boolean isCustom() {
return !getKeyNamespace().equals("husksync");
}
/**
* Returns the identifier as a string (the key)
*
* @return the identifier as a string
*/
@NotNull
@Override
public String toString() {
return key.asString();
}
/**
* Returns {@code true} if the given object is an identifier with the same key as this identifier
*
* @param obj the object to compare
* @return {@code true} if the given object is an identifier with the same key as this identifier
*/
@Override
public boolean equals(Object obj) {
if (obj instanceof Identifier other) {
return key.equals(other.key);
}
return false;
}
}

View File

@@ -0,0 +1,44 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import org.jetbrains.annotations.NotNull;
public interface Serializer<T extends Data> {
T deserialize(@NotNull String serialized) throws DeserializationException;
@NotNull
String serialize(@NotNull T element) throws SerializationException;
static final class DeserializationException extends IllegalStateException {
DeserializationException(@NotNull String message, @NotNull Throwable cause) {
super(message, cause);
}
}
static final class SerializationException extends IllegalStateException {
SerializationException(@NotNull String message, @NotNull Throwable cause) {
super(message, cause);
}
}
}

View File

@@ -0,0 +1,189 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.data;
import net.william278.desertwell.util.ThrowingConsumer;
import net.william278.husksync.HuskSync;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
import java.util.logging.Level;
/**
* A holder of data in the form of {@link Data}s, which can be synced
*/
public interface UserDataHolder extends DataHolder {
/**
* Get the data that is enabled for syncing in the config
*
* @return the data that is enabled for syncing
* @since 3.0
*/
@Override
@NotNull
default Map<Identifier, Data> getData() {
return getPlugin().getRegisteredDataTypes().stream()
.filter(type -> type.isCustom() || getPlugin().getSettings().isSyncFeatureEnabled(type))
.map(id -> Map.entry(id, getData(id)))
.filter(data -> data.getValue().isPresent())
.collect(HashMap::new, (map, data) -> map.put(data.getKey(), data.getValue().get()), HashMap::putAll);
}
/**
* Apply the data for the given {@link Identifier} to the holder.
* <p>
* This will be performed synchronously on the main server thread; it will not happen instantly.
*
* @param identifier the {@link Identifier} to set the data for
* @param data the {@link Data} to set
* @since 3.0
*/
@Override
default void setData(@NotNull Identifier identifier, @NotNull Data data) {
getPlugin().runSync(() -> data.apply(this, getPlugin()));
}
/**
* Create a serialized data snapshot of this data owner
*
* @param saveCause the cause of the snapshot
* @return the snapshot
* @since 3.0
*/
@NotNull
default DataSnapshot.Packed createSnapshot(@NotNull DataSnapshot.SaveCause saveCause) {
return DataSnapshot.builder(getPlugin()).data(this.getData()).saveCause(saveCause).buildAndPack();
}
/**
* Deserialize and apply a data snapshot to this data owner
* <p>
* This method will deserialize the data on the current thread, then synchronously apply it on
* the main server thread.
* </p>
* The {@code runAfter} callback function will be run after the snapshot has been applied.
*
* @param snapshot the snapshot to apply
* @param runAfter a consumer accepting a boolean value, indicating if the data was successfully applied,
* which will be run after the snapshot has been applied
* @since 3.0
*/
default void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull ThrowingConsumer<Boolean> runAfter) {
final HuskSync plugin = getPlugin();
// Unpack the snapshot
final DataSnapshot.Unpacked unpacked;
try {
unpacked = snapshot.unpack(plugin);
} catch (Throwable e) {
plugin.log(Level.SEVERE, String.format("Failed to unpack data snapshot for %s", getUsername()), e);
return;
}
// Synchronously attempt to apply the snapshot
plugin.runSync(() -> {
try {
for (Map.Entry<Identifier, Data> entry : unpacked.getData().entrySet()) {
final Identifier identifier = entry.getKey();
if (plugin.getSettings().isSyncFeatureEnabled(identifier)) {
if (identifier.isCustom()) {
getCustomDataStore().put(identifier, entry.getValue());
}
entry.getValue().apply(this, plugin);
}
}
} catch (Throwable e) {
plugin.log(Level.SEVERE, String.format("Failed to apply data snapshot to %s", getUsername()), e);
plugin.runAsync(() -> runAfter.accept(false));
return;
}
plugin.runAsync(() -> runAfter.accept(true));
});
}
@Override
default void setInventory(@NotNull Data.Items.Inventory inventory) {
this.setData(Identifier.INVENTORY, inventory);
}
@Override
default void setEnderChest(@NotNull Data.Items.EnderChest enderChest) {
this.setData(Identifier.ENDER_CHEST, enderChest);
}
@Override
default void setPotionEffects(@NotNull Data.PotionEffects potionEffects) {
this.setData(Identifier.POTION_EFFECTS, potionEffects);
}
@Override
default void setAdvancements(@NotNull Data.Advancements advancements) {
this.setData(Identifier.ADVANCEMENTS, advancements);
}
@Override
default void setLocation(@NotNull Data.Location location) {
this.setData(Identifier.LOCATION, location);
}
@Override
default void setStatistics(@NotNull Data.Statistics statistics) {
this.setData(Identifier.STATISTICS, statistics);
}
@Override
default void setHealth(@NotNull Data.Health health) {
this.setData(Identifier.HEALTH, health);
}
@Override
default void setHunger(@NotNull Data.Hunger hunger) {
this.setData(Identifier.HUNGER, hunger);
}
@Override
default void setExperience(@NotNull Data.Experience experience) {
this.setData(Identifier.EXPERIENCE, experience);
}
@Override
default void setGameMode(@NotNull Data.GameMode gameMode) {
this.setData(Identifier.GAME_MODE, gameMode);
}
@Override
default void setPersistentData(@NotNull Data.PersistentData persistentData) {
this.setData(Identifier.PERSISTENT_DATA, persistentData);
}
@NotNull
String getUsername();
@NotNull
Map<Identifier, Data> getCustomDataStore();
@NotNull
@ApiStatus.Internal
HuskSync getPlugin();
}

View File

@@ -0,0 +1,300 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.database;
import net.william278.husksync.HuskSync;
import net.william278.husksync.config.Settings;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.data.DataSnapshot.SaveCause;
import net.william278.husksync.data.UserDataHolder;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
/**
* An abstract representation of the plugin database, storing player data.
* <p>
* Implemented by different database platforms - MySQL, SQLite, etc. - as configured by the administrator.
*/
public abstract class Database {
protected final HuskSync plugin;
protected Database(@NotNull HuskSync plugin) {
this.plugin = plugin;
}
/**
* Loads SQL table creation schema statements from a resource file as a string array
*
* @param schemaFileName database script resource file to load from
* @return Array of string-formatted table creation schema statements
* @throws IOException if the resource could not be read
*/
@SuppressWarnings("SameParameterValue")
@NotNull
protected final String[] getSchemaStatements(@NotNull String schemaFileName) throws IOException {
return formatStatementTables(new String(Objects.requireNonNull(plugin.getResource(schemaFileName))
.readAllBytes(), StandardCharsets.UTF_8)).split(";");
}
/**
* Format all table name placeholder strings in an SQL statement
*
* @param sql the SQL statement with unformatted table name placeholders
* @return the formatted statement, with table placeholders replaced with the correct names
*/
@NotNull
protected final String formatStatementTables(@NotNull String sql) {
return sql.replaceAll("%users_table%", plugin.getSettings().getTableName(Settings.TableName.USERS))
.replaceAll("%user_data_table%", plugin.getSettings().getTableName(Settings.TableName.USER_DATA));
}
/**
* Initialize the database and ensure tables are present; create tables if they do not exist.
*
* @throws IllegalStateException if the database could not be initialized
*/
@Blocking
public abstract void initialize() throws IllegalStateException;
/**
* Ensure a {@link User} has an entry in the database and that their username is up-to-date
*
* @param user The {@link User} to ensure
*/
@Blocking
public abstract void ensureUser(@NotNull User user);
/**
* Get a player by their Minecraft account {@link UUID}
*
* @param uuid Minecraft account {@link UUID} of the {@link User} to get
* @return An optional with the {@link User} present if they exist
*/
@Blocking
public abstract Optional<User> getUser(@NotNull UUID uuid);
/**
* Get a user by their username (<i>case-insensitive</i>)
*
* @param username Username of the {@link User} to get (<i>case-insensitive</i>)
* @return An optional with the {@link User} present if they exist
*/
@Blocking
public abstract Optional<User> getUserByName(@NotNull String username);
/**
* Get the latest data snapshot for a user.
*
* @param user The user to get data for
* @return an optional containing the {@link DataSnapshot}, if it exists, or an empty optional if it does not
*/
@Blocking
public abstract Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user);
/**
* Get all {@link DataSnapshot} entries for a user from the database.
*
* @param user The user to get data for
* @return The list of a user's {@link DataSnapshot} entries
*/
@Blocking
@NotNull
public abstract List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user);
/**
* Gets a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
*
* @param user The user to get data for
* @param versionUuid The UUID of the {@link DataSnapshot} entry to get
* @return An optional containing the {@link DataSnapshot}, if it exists
*/
@Blocking
public abstract Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid);
/**
* <b>(Internal)</b> Prune user data for a given user to the maximum value as configured.
*
* @param user The user to prune data for
* @implNote Data snapshots marked as {@code pinned} are exempt from rotation
*/
@Blocking
protected abstract void rotateSnapshots(@NotNull User user);
/**
* Deletes a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
*
* @param user The user to get data for
* @param versionUuid The UUID of the {@link DataSnapshot} entry to delete
*/
@Blocking
public abstract boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid);
/**
* Save user data to the database
* </p>
* This will remove the oldest data for the user if the amount of data exceeds the limit as configured
*
* @param user The user to add data for
* @param snapshot The {@link DataSnapshot} to set.
* The implementation should version it with a random UUID and the current timestamp during insertion.
* @see UserDataHolder#createSnapshot(SaveCause)
*/
@Blocking
public void addSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) {
if (snapshot.getSaveCause() != SaveCause.SERVER_SHUTDOWN) {
plugin.fireEvent(
plugin.getDataSaveEvent(user, snapshot),
(event) -> this.addAndRotateSnapshot(user, snapshot)
);
return;
}
this.addAndRotateSnapshot(user, snapshot);
}
/**
* <b>Internal</b> - Save user data to the database. This will:
* <ol>
* <li>Delete their most recent snapshot, if it was created before the backup frequency time</li>
* <li>Create the snapshot</li>
* <li>Rotate snapshot backups</li>
* </ol>
*
* @param user The user to add data for
* @param snapshot The {@link DataSnapshot} to set.
*/
@Blocking
private void addAndRotateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot) {
final int backupFrequency = plugin.getSettings().getBackupFrequency();
if (!snapshot.isPinned() && backupFrequency > 0) {
this.rotateLatestSnapshot(user, snapshot.getTimestamp().minusHours(backupFrequency));
}
this.createSnapshot(user, snapshot);
this.rotateSnapshots(user);
}
/**
* Deletes the most recent data snapshot by the given {@link User user}
* The snapshot must have been created after {@link OffsetDateTime time} and NOT be pinned
* Facilities the backup frequency feature, reducing redundant snapshots from being saved longer than needed
*
* @param user The user to delete a snapshot for
* @param within The time to delete a snapshot after
*/
@Blocking
protected abstract void rotateLatestSnapshot(@NotNull User user, @NotNull OffsetDateTime within);
/**
* <b>Internal</b> - Create user data in the database
*
* @param user The user to add data for
* @param data The {@link DataSnapshot} to set.
*/
@Blocking
protected abstract void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data);
/**
* Update a saved {@link DataSnapshot} by given version UUID
*
* @param user The user whose data snapshot
* @param snapshot The {@link DataSnapshot} to update
*/
@Blocking
public abstract void updateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed snapshot);
/**
* Unpin a saved {@link DataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code false}.
*
* @param user The user to unpin the data for
* @param versionUuid The UUID of the user's {@link DataSnapshot} entry to unpin
* @see DataSnapshot#isPinned()
*/
@Blocking
public final void unpinSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
this.getSnapshot(user, versionUuid).ifPresent(data -> {
data.edit(plugin, (snapshot) -> snapshot.setPinned(false));
this.updateSnapshot(user, data);
});
}
/**
* Pin a saved {@link DataSnapshot} by given version UUID, setting it's {@code pinned} state to {@code true}.
*
* @param user The user to pin the data for
* @param versionUuid The UUID of the user's {@link DataSnapshot} entry to pin
*/
@Blocking
public final void pinSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
this.getSnapshot(user, versionUuid).ifPresent(data -> {
data.edit(plugin, (snapshot) -> snapshot.setPinned(true));
this.updateSnapshot(user, data);
});
}
/**
* Wipes <b>all</b> {@link User} entries from the database.
* <b>This should only be used when preparing tables for a data migration.</b>
*/
@Blocking
public abstract void wipeDatabase();
/**
* Close the database connection
*/
public abstract void terminate();
/**
* Identifies types of databases
*/
public enum Type {
MYSQL("MySQL", "mysql"),
MARIADB("MariaDB", "mariadb");
private final String displayName;
private final String protocol;
Type(@NotNull String displayName, @NotNull String protocol) {
this.displayName = displayName;
this.protocol = protocol;
}
@NotNull
public String getDisplayName() {
return displayName;
}
@NotNull
public String getProtocol() {
return protocol;
}
}
}

View File

@@ -0,0 +1,425 @@
/*
* This file is part of HuskSync, licensed under the Apache License 2.0.
*
* Copyright (c) William278 <will27528@gmail.com>
* Copyright (c) contributors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package net.william278.husksync.database;
import com.zaxxer.hikari.HikariDataSource;
import net.william278.husksync.HuskSync;
import net.william278.husksync.adapter.DataAdapter;
import net.william278.husksync.data.DataSnapshot;
import net.william278.husksync.user.User;
import org.jetbrains.annotations.Blocking;
import org.jetbrains.annotations.NotNull;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.sql.*;
import java.time.OffsetDateTime;
import java.util.*;
import java.util.logging.Level;
public class MySqlDatabase extends Database {
private static final String DATA_POOL_NAME = "HuskSyncHikariPool";
private final String flavor;
private final String driverClass;
private HikariDataSource dataSource;
public MySqlDatabase(@NotNull HuskSync plugin) {
super(plugin);
this.flavor = plugin.getSettings().getDatabaseType().getProtocol();
this.driverClass = plugin.getSettings().getDatabaseType() == Type.MARIADB
? "org.mariadb.jdbc.Driver" : "com.mysql.cj.jdbc.Driver";
}
/**
* Fetch the auto-closeable connection from the hikariDataSource
*
* @return The {@link Connection} to the MySQL database
* @throws SQLException if the connection fails for some reason
*/
@Blocking
@NotNull
private Connection getConnection() throws SQLException {
if (dataSource == null) {
throw new IllegalStateException("The database has not been initialized");
}
return dataSource.getConnection();
}
@Blocking
@Override
public void initialize() throws IllegalStateException {
// Initialize the Hikari pooled connection
dataSource = new HikariDataSource();
dataSource.setDriverClassName(driverClass);
dataSource.setJdbcUrl(String.format("jdbc:%s://%s:%s/%s%s",
flavor,
plugin.getSettings().getMySqlHost(),
plugin.getSettings().getMySqlPort(),
plugin.getSettings().getMySqlDatabase(),
plugin.getSettings().getMySqlConnectionParameters()
));
// Authenticate with the database
dataSource.setUsername(plugin.getSettings().getMySqlUsername());
dataSource.setPassword(plugin.getSettings().getMySqlPassword());
// Set connection pool options
dataSource.setMaximumPoolSize(plugin.getSettings().getMySqlConnectionPoolSize());
dataSource.setMinimumIdle(plugin.getSettings().getMySqlConnectionPoolIdle());
dataSource.setMaxLifetime(plugin.getSettings().getMySqlConnectionPoolLifetime());
dataSource.setKeepaliveTime(plugin.getSettings().getMySqlConnectionPoolKeepAlive());
dataSource.setConnectionTimeout(plugin.getSettings().getMySqlConnectionPoolTimeout());
dataSource.setPoolName(DATA_POOL_NAME);
// Set additional connection pool properties
final Properties properties = new Properties();
properties.putAll(
Map.of("cachePrepStmts", "true",
"prepStmtCacheSize", "250",
"prepStmtCacheSqlLimit", "2048",
"useServerPrepStmts", "true",
"useLocalSessionState", "true",
"useLocalTransactionState", "true"
));
properties.putAll(
Map.of(
"rewriteBatchedStatements", "true",
"cacheResultSetMetadata", "true",
"cacheServerConfiguration", "true",
"elideSetAutoCommits", "true",
"maintainTimeStats", "false")
);
dataSource.setDataSourceProperties(properties);
// Prepare database schema; make tables if they don't exist
try (Connection connection = dataSource.getConnection()) {
final String[] databaseSchema = getSchemaStatements(String.format("database/%s_schema.sql", flavor));
try (Statement statement = connection.createStatement()) {
for (String tableCreationStatement : databaseSchema) {
statement.execute(tableCreationStatement);
}
} catch (SQLException e) {
throw new IllegalStateException("Failed to create database tables. Please ensure you are running MySQL v8.0+ " +
"and that your connecting user account has privileges to create tables.", e);
}
} catch (SQLException | IOException e) {
throw new IllegalStateException("Failed to establish a connection to the MySQL database. " +
"Please check the supplied database credentials in the config file", e);
}
}
@Blocking
@Override
public void ensureUser(@NotNull User user) {
getUser(user.getUuid()).ifPresentOrElse(
existingUser -> {
if (!existingUser.getUsername().equals(user.getUsername())) {
// Update a user's name if it has changed in the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%users_table%`
SET `username`=?
WHERE `uuid`=?"""))) {
statement.setString(1, user.getUsername());
statement.setString(2, existingUser.getUuid().toString());
statement.executeUpdate();
}
plugin.log(Level.INFO, "Updated " + user.getUsername() + "'s name in the database (" + existingUser.getUsername() + " -> " + user.getUsername() + ")");
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e);
}
}
},
() -> {
// Insert new player data into the database
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%users_table%` (`uuid`,`username`)
VALUES (?,?);"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, user.getUsername());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
}
}
);
}
@Blocking
@Override
public Optional<User> getUser(@NotNull UUID uuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`
WHERE `uuid`=?"""))) {
statement.setString(1, uuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user from uuid from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public Optional<User> getUserByName(@NotNull String username) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `uuid`, `username`
FROM `%users_table%`
WHERE `username`=?"""))) {
statement.setString(1, username);
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
return Optional.of(new User(UUID.fromString(resultSet.getString("uuid")),
resultSet.getString("username")));
}
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user by name from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final UUID versionUuid = UUID.fromString(resultSet.getString("version_uuid"));
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
);
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
@NotNull
public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> retrievedData = new ArrayList<>();
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=?
ORDER BY `timestamp` DESC;"""))) {
statement.setString(1, user.getUuid().toString());
final ResultSet resultSet = statement.executeQuery();
while (resultSet.next()) {
final UUID versionUuid = UUID.fromString(resultSet.getString("version_uuid"));
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
);
final Blob blob = resultSet.getBlob("data");
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
retrievedData.add(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
return retrievedData;
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
}
return retrievedData;
}
@Blocking
@Override
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
SELECT `version_uuid`, `timestamp`, `data`
FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
ORDER BY `timestamp` DESC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, versionUuid.toString());
final ResultSet resultSet = statement.executeQuery();
if (resultSet.next()) {
final Blob blob = resultSet.getBlob("data");
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(
resultSet.getTimestamp("timestamp").toInstant(), TimeZone.getDefault().toZoneId()
);
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
blob.free();
return Optional.of(DataSnapshot.deserialize(plugin, dataByteArray, versionUuid, timestamp));
}
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to fetch specific user data by UUID from the database", e);
}
return Optional.empty();
}
@Blocking
@Override
protected void rotateSnapshots(@NotNull User user) {
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
if (unpinnedUserData.size() > plugin.getSettings().getMaxUserDataSnapshots()) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=?
AND `pinned` IS FALSE
ORDER BY `timestamp` ASC
LIMIT %entry_count%;""".replace("%entry_count%",
Integer.toString(unpinnedUserData.size() - plugin.getSettings().getMaxUserDataSnapshots()))))) {
statement.setString(1, user.getUuid().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to prune user data from the database", e);
}
}
}
@Blocking
@Override
public boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, versionUuid.toString());
return statement.executeUpdate() > 0;
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete specific user data from the database", e);
}
return false;
}
@Blocking
@Override
protected void rotateLatestSnapshot(@NotNull User user, @NotNull OffsetDateTime within) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
DELETE FROM `%user_data_table%`
WHERE `player_uuid`=? AND `timestamp`>? AND `pinned` IS FALSE
ORDER BY `timestamp` ASC
LIMIT 1;"""))) {
statement.setString(1, user.getUuid().toString());
statement.setTimestamp(2, Timestamp.from(within.toInstant()));
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to delete a user's data from the database", e);
}
}
@Blocking
@Override
protected void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
INSERT INTO `%user_data_table%`
(`player_uuid`,`version_uuid`,`timestamp`,`save_cause`,`pinned`,`data`)
VALUES (?,?,?,?,?,?);"""))) {
statement.setString(1, user.getUuid().toString());
statement.setString(2, data.getId().toString());
statement.setTimestamp(3, Timestamp.from(data.getTimestamp().toInstant()));
statement.setString(4, data.getSaveCause().name());
statement.setBoolean(5, data.isPinned());
statement.setBlob(6, new ByteArrayInputStream(data.asBytes(plugin)));
statement.executeUpdate();
}
} catch (SQLException | DataAdapter.AdaptionException e) {
plugin.log(Level.SEVERE, "Failed to set user data in the database", e);
}
}
@Blocking
@Override
public void updateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
try (Connection connection = getConnection()) {
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
UPDATE `%user_data_table%`
SET `save_cause`=?,`pinned`=?,`data`=?
WHERE `player_uuid`=? AND `version_uuid`=?
LIMIT 1;"""))) {
statement.setString(1, data.getSaveCause().name());
statement.setBoolean(2, data.isPinned());
statement.setBlob(3, new ByteArrayInputStream(data.asBytes(plugin)));
statement.setString(4, user.getUuid().toString());
statement.setString(5, data.getId().toString());
statement.executeUpdate();
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to pin user data in the database", e);
}
}
@Override
public void wipeDatabase() {
try (Connection connection = getConnection()) {
try (Statement statement = connection.createStatement()) {
statement.executeUpdate(formatStatementTables("DELETE FROM `%user_data_table%`;"));
}
} catch (SQLException e) {
plugin.log(Level.SEVERE, "Failed to wipe the database", e);
}
}
@Override
public void terminate() {
if (dataSource != null) {
if (!dataSource.isClosed()) {
dataSource.close();
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More