mirror of
https://github.com/WiIIiam278/HuskSync.git
synced 2025-12-23 08:39:19 +00:00
Compare commits
356 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0772f09e98 | ||
|
|
e916673454 | ||
|
|
ac163d5130 | ||
|
|
d656b67570 | ||
|
|
3c66b65ac6 | ||
|
|
c227933b3b | ||
|
|
5cf9cb8e50 | ||
|
|
693cd6120f | ||
|
|
6efd800481 | ||
|
|
a723a7cba3 | ||
|
|
b1a5eb5f44 | ||
|
|
8232282d13 | ||
|
|
404d359f89 | ||
|
|
62e84d92fc | ||
|
|
9b2246eac2 | ||
|
|
e6d3935246 | ||
|
|
5c4111b6a7 | ||
|
|
51a700600a | ||
|
|
23c3ee08e9 | ||
|
|
c1d08f9c23 | ||
|
|
4cabdbe952 | ||
|
|
abdebd960b | ||
|
|
a7aea51a45 | ||
|
|
c8a4376208 | ||
|
|
315cd4ba6b | ||
|
|
6607ac5a6e | ||
|
|
562939498a | ||
|
|
e686d43ca8 | ||
|
|
e9ac400215 | ||
|
|
234870537a | ||
|
|
b5f392a20f | ||
|
|
9ea8eb4101 | ||
|
|
dc7cde1c33 | ||
|
|
4e75b5ca1d | ||
|
|
fe0bdccf40 | ||
|
|
c615ab592b | ||
|
|
16d4a8fd9b | ||
|
|
96f34092f6 | ||
|
|
a31c3c48f7 | ||
|
|
0e96374a03 | ||
|
|
24545563fa | ||
|
|
a9ea4d34e5 | ||
|
|
11453393d4 | ||
|
|
1d850a9ddb | ||
|
|
5b90b3d006 | ||
|
|
d85ec65384 | ||
|
|
9d681db030 | ||
|
|
c5c2dde0bf | ||
|
|
807bffe9aa | ||
|
|
a1956c6822 | ||
|
|
321dccb0b5 | ||
|
|
1015c50802 | ||
|
|
c5e759390b | ||
|
|
883695b0b0 | ||
|
|
879aef471a | ||
|
|
64c81a9a5a | ||
|
|
b61a9a7bc3 | ||
|
|
3f725eb40c | ||
|
|
8f1e4a5198 | ||
|
|
3875447430 | ||
|
|
dce84f285d | ||
|
|
1314683eea | ||
|
|
6b1f89aab0 | ||
|
|
27e958a474 | ||
|
|
39ebd0dc4f | ||
|
|
2ada0497ec | ||
|
|
e9f2856040 | ||
|
|
6050c584c0 | ||
|
|
7ebf91bfae | ||
|
|
2d7799628a | ||
|
|
1627de732b | ||
|
|
fea882c642 | ||
|
|
8b749357f7 | ||
|
|
e4ff7e6d6c | ||
|
|
396630821f | ||
|
|
70f65d126b | ||
|
|
e8925a0d79 | ||
|
|
2a3cf9be7d | ||
|
|
971d3f5167 | ||
|
|
99da65a4d8 | ||
|
|
25744b4ef7 | ||
|
|
8f2d1c7298 | ||
|
|
a9aa93a682 | ||
|
|
ef340840ab | ||
|
|
cf08015961 | ||
|
|
cb09e0cfb2 | ||
|
|
554fac89c0 | ||
|
|
215bed9908 | ||
|
|
935aafa74a | ||
|
|
c51ba85f38 | ||
|
|
6a67d1bbe0 | ||
|
|
20bc76a768 | ||
|
|
6928f97dff | ||
|
|
06742fb848 | ||
|
|
759983b000 | ||
|
|
5556e3b6ce | ||
|
|
bcffcb1f64 | ||
|
|
fa77e6e418 | ||
|
|
c8aa29c82f | ||
|
|
51cf982359 | ||
|
|
f6d860335f | ||
|
|
5cea4665a1 | ||
|
|
34b183a35e | ||
|
|
61298c24bb | ||
|
|
af9d32895e | ||
|
|
52fa67432c | ||
|
|
404f18d81f | ||
|
|
9ee8ea1c84 | ||
|
|
64f845e293 | ||
|
|
30d1acc67e | ||
|
|
8d047d8892 | ||
|
|
cb49ab8d73 | ||
|
|
436e85dada | ||
|
|
223333882d | ||
|
|
06d8dda7dd | ||
|
|
805ffb19c2 | ||
|
|
cd3e4ef063 | ||
|
|
557b738511 | ||
|
|
8ee6b7a199 | ||
|
|
dc880bc37f | ||
|
|
c419587933 | ||
|
|
afb4fdd5d5 | ||
|
|
bf8474e02d | ||
|
|
937ea9bc8e | ||
|
|
ef7b3c4f32 | ||
|
|
370712c5b2 | ||
|
|
ae657acee3 | ||
|
|
34dc6a537d | ||
|
|
e99ba66271 | ||
|
|
0111f25865 | ||
|
|
02c8b899dc | ||
|
|
b725015318 | ||
|
|
11550e0ba3 | ||
|
|
33e20a0c0b | ||
|
|
0ae13d730d | ||
|
|
f5ad5c079f | ||
|
|
305f90f697 | ||
|
|
e56041eae2 | ||
|
|
904c65ba39 | ||
|
|
fbb8ec3048 | ||
|
|
2a59a0b3f5 | ||
|
|
b108d38598 | ||
|
|
8b7e891ab6 | ||
|
|
be6bebe361 | ||
|
|
1ff4cab88d | ||
|
|
e3c40a231b | ||
|
|
c8fd3f88fa | ||
|
|
f9ec1f3ebb | ||
|
|
033af3126c | ||
|
|
a15739fbb9 | ||
|
|
f4b9124636 | ||
|
|
fecda83fcb | ||
|
|
07228c3661 | ||
|
|
a0fb2e90b3 | ||
|
|
ae69c1c060 | ||
|
|
4992f4492c | ||
|
|
58bd3acdc3 | ||
|
|
af51c035a3 | ||
|
|
85ae2b5fb2 | ||
|
|
7ff10b33a0 | ||
|
|
431c9e13c9 | ||
|
|
c8579fb987 | ||
|
|
2f4eb46456 | ||
|
|
2d547507d5 | ||
|
|
8e4678468e | ||
|
|
c2a32cabc5 | ||
|
|
07f06aac68 | ||
|
|
7ae1001b1b | ||
|
|
e04c19acf5 | ||
|
|
1820a810f4 | ||
|
|
cedd12a048 | ||
|
|
7967d00208 | ||
|
|
00a68be2ad | ||
|
|
da5d991d2a | ||
|
|
c2f6d240ad | ||
|
|
4cde24c536 | ||
|
|
029617bc45 | ||
|
|
0627fb20e4 | ||
|
|
bc1f983684 | ||
|
|
31eb747c55 | ||
|
|
e8facf52ce | ||
|
|
5ee4bdd644 | ||
|
|
92c371e201 | ||
|
|
d27278454a | ||
|
|
16780c149c | ||
|
|
0445ba63bc | ||
|
|
b6aefd6f57 | ||
|
|
f803af0225 | ||
|
|
2675f4a377 | ||
|
|
03341c981f | ||
|
|
38cc654167 | ||
|
|
b347a8d060 | ||
|
|
8733b86b45 | ||
|
|
eda8e72633 | ||
|
|
c942a015d1 | ||
|
|
c00265f1f9 | ||
|
|
e303984dcf | ||
|
|
b449b5dee6 | ||
|
|
48f8c0c967 | ||
|
|
f88c4c3e2c | ||
|
|
e6273fa9a0 | ||
|
|
1ba5585d0d | ||
|
|
73547371ae | ||
|
|
fca6825394 | ||
|
|
53af114f44 | ||
|
|
311cc85c92 | ||
|
|
099a258cf8 | ||
|
|
480f59a166 | ||
|
|
45c2f5350f | ||
|
|
ed88d77852 | ||
|
|
e7fc9f015e | ||
|
|
cabde9e8d8 | ||
|
|
4df7d2def4 | ||
|
|
59ed77c169 | ||
|
|
53da3bd40c | ||
|
|
abdf8223fc | ||
|
|
a5efeecad3 | ||
|
|
4d26b24d13 | ||
|
|
29b3a60c64 | ||
|
|
da894f57c4 | ||
|
|
1bd703641b | ||
|
|
1b1d4c8e8d | ||
|
|
842ec0e28d | ||
|
|
2d5648408e | ||
|
|
41b3240741 | ||
|
|
bc03e8f3e3 | ||
|
|
86799f4c08 | ||
|
|
a3e004cf71 | ||
|
|
a7aeb1de21 | ||
|
|
1a703102c3 | ||
|
|
368c68f42b | ||
|
|
e191713bdc | ||
|
|
1604338498 | ||
|
|
c223797bf4 | ||
|
|
9b10adc8e4 | ||
|
|
5935f1ab5f | ||
|
|
3455b10a20 | ||
|
|
34e08b712d | ||
|
|
605d314a58 | ||
|
|
daaf5147a7 | ||
|
|
50eb9a7543 | ||
|
|
7d8ef7b6b3 | ||
|
|
347d2d0a8f | ||
|
|
bd560fcc99 | ||
|
|
b68aedc99a | ||
|
|
47373d8974 | ||
|
|
a57b8df994 | ||
|
|
17235637a5 | ||
|
|
cd5abd5a65 | ||
|
|
5c6631cdcf | ||
|
|
621afcd5c6 | ||
|
|
112a974a6c | ||
|
|
f9d46b4aff | ||
|
|
dfd828bca1 | ||
|
|
2df9fd897a | ||
|
|
ff2531539e | ||
|
|
52ec138273 | ||
|
|
0f7a866652 | ||
|
|
eeb52ac41e | ||
|
|
4c7ec9ec21 | ||
|
|
2f9064c4c6 | ||
|
|
5c234cdb1d | ||
|
|
7d8a74381b | ||
|
|
04a7793585 | ||
|
|
ea068529f6 | ||
|
|
fead3df0d8 | ||
|
|
0c5a42a344 | ||
|
|
75a2378ea8 | ||
|
|
662fc96ad5 | ||
|
|
f456443da0 | ||
|
|
07da1c04ce | ||
|
|
845abf370a | ||
|
|
83b5209a75 | ||
|
|
8e9850dd19 | ||
|
|
1d24209b68 | ||
|
|
da70a54d78 | ||
|
|
fc7330213a | ||
|
|
d8272ba52d | ||
|
|
315f0eeb2f | ||
|
|
8e83617ac4 | ||
|
|
212bb0beb8 | ||
|
|
c16231b12b | ||
|
|
93f7294859 | ||
|
|
32ac57e2a4 | ||
|
|
c949c976d6 | ||
|
|
ab736829f2 | ||
|
|
4433926ce7 | ||
|
|
f819fd4d5e | ||
|
|
e7659255fe | ||
|
|
0dee2e8319 | ||
|
|
7b35c47315 | ||
|
|
5056a794d8 | ||
|
|
5e6068431a | ||
|
|
8d69508689 | ||
|
|
efb6d8a7de | ||
|
|
79d9778378 | ||
|
|
6a6695e447 | ||
|
|
8862e6cd70 | ||
|
|
0b29de9efc | ||
|
|
962cdfce0b | ||
|
|
0c527202e5 | ||
|
|
d4e33aa9d2 | ||
|
|
2fcd58fc18 | ||
|
|
3d10b2324f | ||
|
|
31419f3b97 | ||
|
|
8105ac27fc | ||
|
|
44f251a948 | ||
|
|
463e707d27 | ||
|
|
2d85910744 | ||
|
|
268b279fdf | ||
|
|
a8ca3314d8 | ||
|
|
2bdd3dae37 | ||
|
|
e29564c4ad | ||
|
|
6b8bb23af9 | ||
|
|
91bbe05851 | ||
|
|
8ed6869aad | ||
|
|
7efdf0d329 | ||
|
|
49c32e3f98 | ||
|
|
f0574527b9 | ||
|
|
ad510a8fca | ||
|
|
303b287705 | ||
|
|
549508b9c1 | ||
|
|
6c8a577701 | ||
|
|
862177bec7 | ||
|
|
dbed4d83a2 | ||
|
|
aa2090d97a | ||
|
|
b168ede7c5 | ||
|
|
0e706d36c4 | ||
|
|
69d68de5c0 | ||
|
|
3d5395e5ae | ||
|
|
332c71f041 | ||
|
|
b9fbcd72dd | ||
|
|
68897e6265 | ||
|
|
04606a7c9a | ||
|
|
6286bbe2ad | ||
|
|
24ba209f8f | ||
|
|
05d588f681 | ||
|
|
9aa3606f54 | ||
|
|
fc05e4b17a | ||
|
|
7b2b47de83 | ||
|
|
be0b4e3397 | ||
|
|
dd1ba594de | ||
|
|
89368778f3 | ||
|
|
e3fb1762a1 | ||
|
|
516c243df8 | ||
|
|
b7aa75fcd5 | ||
|
|
549f013e0f | ||
|
|
14c56af465 | ||
|
|
bee01dd15a | ||
|
|
e97551e67e | ||
|
|
97023e8425 | ||
|
|
4fa7106a46 | ||
|
|
e0b81e4c76 | ||
|
|
c4adec3082 | ||
|
|
107238360c | ||
|
|
6141adbdb9 |
66
.github/workflows/ci.yml
vendored
66
.github/workflows/ci.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: CI Tests
|
||||
name: CI Tests & Publish
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -17,11 +17,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout for CI 🛎️'
|
||||
uses: actions/checkout@v4
|
||||
- name: 'Set up JDK 17 📦'
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Set up JDK 21 📦'
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
- name: 'Build with Gradle 🏗️'
|
||||
uses: gradle/gradle-build-action@v3
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
SNAPSHOTS_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||
SNAPSHOTS_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
- name: 'Publish Test Report 📊'
|
||||
uses: mikepenz/action-junit-report@v4
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
if: success() || failure() # Continue on failure
|
||||
with:
|
||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||
@@ -41,4 +41,56 @@ jobs:
|
||||
id: fetch-version
|
||||
- name: Get Version
|
||||
run: |
|
||||
echo "version_name=${{steps.fetch-version.outputs.VERSION_NAME}}" >> $GITHUB_ENV
|
||||
echo "version_name=${{steps.fetch-version.outputs.VERSION_NAME}}" >> $GITHUB_ENV
|
||||
- name: 'Publish to William278.net 🚀'
|
||||
uses: WiIIiam278/bones-publish-action@v1
|
||||
with:
|
||||
api-key: ${{ secrets.BONES_API_KEY }}
|
||||
project: 'husksync'
|
||||
channel: 'alpha'
|
||||
version: ${{ env.version_name }}
|
||||
changelog: ${{ github.event.head_commit.message }}
|
||||
distro-names: |
|
||||
paper-1.21.1
|
||||
paper-1.21.4
|
||||
paper-1.21.5
|
||||
paper-1.21.8
|
||||
paper-1.21.10
|
||||
paper-1.21.11
|
||||
fabric-1.21.1
|
||||
fabric-1.21.4
|
||||
fabric-1.21.5
|
||||
fabric-1.21.8
|
||||
distro-groups: |
|
||||
paper
|
||||
paper
|
||||
paper
|
||||
paper
|
||||
paper
|
||||
paper
|
||||
fabric
|
||||
fabric
|
||||
fabric
|
||||
fabric
|
||||
distro-descriptions: |
|
||||
Paper 1.21.1
|
||||
Paper 1.21.4
|
||||
Paper 1.21.5
|
||||
Paper 1.21.8
|
||||
Paper 1.21.10
|
||||
Paper 1.21.11
|
||||
Fabric 1.21.1
|
||||
Fabric 1.21.4
|
||||
Fabric 1.21.5
|
||||
Fabric 1.21.8
|
||||
files: |
|
||||
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.1.jar
|
||||
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.4.jar
|
||||
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.5.jar
|
||||
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.8.jar
|
||||
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.10.jar
|
||||
target/HuskSync-Bukkit-${{ env.version_name }}+mc.1.21.11.jar
|
||||
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.1.jar
|
||||
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.4.jar
|
||||
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.5.jar
|
||||
target/HuskSync-Fabric-${{ env.version_name }}+mc.1.21.8.jar
|
||||
10
.github/workflows/pr_tests.yml
vendored
10
.github/workflows/pr_tests.yml
vendored
@@ -13,18 +13,18 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout for CI 🛎'
|
||||
uses: actions/checkout@v4
|
||||
- name: 'Set up JDK 17 📦'
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Set up JDK 21 📦'
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
- name: 'Build with Gradle 🏗️'
|
||||
uses: gradle/gradle-build-action@v3
|
||||
with:
|
||||
arguments: test
|
||||
- name: 'Publish Test Report 📊'
|
||||
uses: mikepenz/action-junit-report@v4
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
if: success() || failure() # Continue on failure
|
||||
with:
|
||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||
62
.github/workflows/release.yml
vendored
62
.github/workflows/release.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: Release Tests
|
||||
name: Release Tests & Publish
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -13,11 +13,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout for CI 🛎️'
|
||||
uses: actions/checkout@v4
|
||||
- name: 'Set up JDK 17 📦'
|
||||
uses: actions/setup-java@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Set up JDK 21 📦'
|
||||
uses: actions/setup-java@v5
|
||||
with:
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
- name: 'Build with Gradle 🏗️'
|
||||
uses: gradle/gradle-build-action@v3
|
||||
@@ -27,7 +27,55 @@ jobs:
|
||||
RELEASES_MAVEN_USERNAME: ${{ secrets.MAVEN_USERNAME }}
|
||||
RELEASES_MAVEN_PASSWORD: ${{ secrets.MAVEN_PASSWORD }}
|
||||
- name: 'Publish Test Report 📊'
|
||||
uses: mikepenz/action-junit-report@v4
|
||||
uses: mikepenz/action-junit-report@v6
|
||||
if: success() || failure() # Continue on failure
|
||||
with:
|
||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||
report_paths: '**/build/test-results/test/TEST-*.xml'
|
||||
- name: 'Publish to William278.net 🚀'
|
||||
uses: WiIIiam278/bones-publish-action@v1
|
||||
with:
|
||||
api-key: ${{ secrets.BONES_API_KEY }}
|
||||
project: 'husksync'
|
||||
channel: 'release'
|
||||
version: ${{ github.event.release.tag_name }}
|
||||
changelog: ${{ github.event.release.body }}
|
||||
distro-names: |
|
||||
paper-1.21.1
|
||||
paper-1.21.4
|
||||
paper-1.21.5
|
||||
paper-1.21.8
|
||||
paper-1.21.10
|
||||
fabric-1.21.1
|
||||
fabric-1.21.4
|
||||
fabric-1.21.5
|
||||
fabric-1.21.8
|
||||
distro-groups: |
|
||||
paper
|
||||
paper
|
||||
paper
|
||||
paper
|
||||
paper
|
||||
fabric
|
||||
fabric
|
||||
fabric
|
||||
fabric
|
||||
distro-descriptions: |
|
||||
Paper 1.21.1
|
||||
Paper 1.21.4
|
||||
Paper 1.21.5
|
||||
Paper 1.21.8
|
||||
Paper 1.21.10
|
||||
Fabric 1.21.1
|
||||
Fabric 1.21.4
|
||||
Fabric 1.21.5
|
||||
Fabric 1.21.8
|
||||
files: |
|
||||
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.1.jar
|
||||
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.4.jar
|
||||
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.5.jar
|
||||
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.8.jar
|
||||
target/HuskSync-Bukkit-${{ github.event.release.tag_name }}+mc.1.21.10.jar
|
||||
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.1.jar
|
||||
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.4.jar
|
||||
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.5.jar
|
||||
target/HuskSync-Fabric-${{ github.event.release.tag_name }}+mc.1.21.8.jar
|
||||
7
.github/workflows/update_docs.yml
vendored
7
.github/workflows/update_docs.yml
vendored
@@ -13,12 +13,13 @@ permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
deploy-wiki:
|
||||
update-docs:
|
||||
name: 'Update Docs'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: 'Checkout for CI 🛎️'
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v6
|
||||
- name: 'Push Docs to Github Wiki 📄️'
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v4
|
||||
uses: Andrew-Chen-Wang/github-wiki-action@v5
|
||||
with:
|
||||
path: 'docs'
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -121,5 +121,4 @@ run/
|
||||
|
||||
# Don't include generated test suite files
|
||||
/test/servers/
|
||||
/test/HuskSync
|
||||
/test/config.yml
|
||||
/test/HuskSync
|
||||
43
README.md
43
README.md
@@ -5,7 +5,7 @@
|
||||
<img src="https://img.shields.io/github/actions/workflow/status/WiIIiam278/HuskSync/ci.yml?branch=master&logo=github"/>
|
||||
</a>
|
||||
<a href="https://repo.william278.net/#/releases/net/william278/husksync/">
|
||||
<img src="https://repo.william278.net/api/badge/latest/releases/net/william278/husksync?color=00fb9a&name=Maven&prefix=v" />
|
||||
<img src="https://repo.william278.net/api/badge/latest/releases/net/william278/husksync/husksync-common?color=00fb9a&name=Maven&prefix=v" />
|
||||
</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" />
|
||||
@@ -43,21 +43,48 @@
|
||||
|
||||
**Ready?** [It's syncing time!](https://william278.net/docs/husksync/setup)
|
||||
|
||||
## Setup
|
||||
Requires a MySQL/Mongo/PostgreSQL database, a Redis (v5.0+) server and any number of Spigot-based 1.17.1+ Minecraft servers, running Java 17+.
|
||||
## Compatibility
|
||||
HuskSync supports the following [compatible versions](https://william278.net/docs/husksync/compatibility) of Minecraft. Since v3.7, you must download the correct version of HuskSync for your server:
|
||||
|
||||
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.
|
||||
| Minecraft | Latest HuskSync | Java Version | Platforms | Support Status |
|
||||
|:---------------:|:---------------:|:------------:|:--------------|:------------------------------|
|
||||
| 1.21.10 | _latest_ | 21 | Paper | ✅ **Active Release** |
|
||||
| 1.21.7/8 | _latest_ | 21 | Paper, Fabric | ✅ **August 2026** |
|
||||
| 1.21.6 | 3.8.5 | 21 | Paper | 🗃️ Archived (July 2025) |
|
||||
| 1.21.5 | _latest_ | 21 | Paper | ✅ **February 2026** (Non-LTS) |
|
||||
| 1.21.4 | _latest_ | 21 | Paper, Fabric | ✅ **February 2026** (Non-LTS) |
|
||||
| 1.21.3 | 3.7.1 | 21 | Paper, Fabric | 🗃️ Archived (December 2024) |
|
||||
| 1.21.1 | _latest_ | 21 | Paper, Fabric | ✅ **May 2026** (LTS) |
|
||||
| 1.20.6 | 3.6.8 | 17 | Paper | 🗃️ Archived (October 2024) |
|
||||
| 1.20.4 | 3.6.8 | 17 | Paper | 🗃️ Archived (July 2024) |
|
||||
| 1.20.1 | 3.8.7 | 17 | Paper, Fabric | 🗃️ Archived (November 2024) |
|
||||
| 1.17.1 - 1.19.4 | 3.6.8 | 17 | Paper | 🗃️ Archived |
|
||||
| 1.16.5 | 3.2.1 | 16 | Paper | 🗃️ Archived |
|
||||
|
||||
HuskSync is primarily developed against the latest release. Old Minecraft versions are allocated a support channel based on popularity, mod support, etc:
|
||||
|
||||
* Long Term Support (LTS) – Supported for up to 12-18 months
|
||||
* Non-Long Term Support (Non-LTS) – Supported for 3-6 months
|
||||
|
||||
Verify your purchase on Discord and [Download HuskSync](https://william278.net/project/husksync/download) for your server.
|
||||
|
||||
## Setup
|
||||
Requires a [MySQL/MariaDB/Mongo/PostgreSQL database](https://william278.net/docs/husksync/database), a [Redis (v5.0+) server]((https://william278.net/docs/husksync/redis)) and a network of [compatible Spigot or Fabric Minecraft servers](https://william278.net/docs/husksync/compatibility).
|
||||
|
||||
1. Place the plugin jar file in the `/plugins` or `/mods` directory of each Spigot/Fabric 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 your database and Redis server credentials.
|
||||
3. Navigate to the HuskSync config file on each server and fill in both your database and Redis server credentials.
|
||||
4. Start every server again and synchronization will begin.
|
||||
|
||||
## Development
|
||||
To build HuskSync, simply run the following in the root of the repository:
|
||||
To build HuskSync, simply run the following in the root of the repository (building requires Java 21). Builds will be output in `/target`:
|
||||
|
||||
```bash
|
||||
./gradlew clean build
|
||||
```
|
||||
|
||||
HuskSync uses `essential-multi-version` (Fabric) and `preprocessor` (Bukkit) to target multiple versions of Minecraft in one codebase - [check here](https://github.com/WiIIiam278/PreProcessor?tab=readme-ov-file#code-example) for a preprocessor comment logic reference.
|
||||
|
||||
### License
|
||||
HuskSync is licensed under the Apache 2.0 license.
|
||||
|
||||
@@ -66,7 +93,7 @@ HuskSync is licensed under the Apache 2.0 license.
|
||||
Contributions to the project are welcome—feel free to open a pull request with new features, improvements and/or fixes!
|
||||
|
||||
### Support
|
||||
Due to its complexity, official binaries and customer 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, Craftaro, or BuiltByBit and have provided proof of purchase. Please join our Discord server if you have done so and need help!
|
||||
Due to its complexity, official binaries and customer 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 BuiltByBit 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.
|
||||
@@ -82,4 +109,4 @@ Translations of the plugin locales are welcome to help make the plugin more acce
|
||||
- [bStats](https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140) — View plugin metrics
|
||||
|
||||
---
|
||||
© [William278](https://william278.net/), 2023. Licensed under the Apache-2.0 License.
|
||||
© [William278](https://william278.net/), 2025. Licensed under the Apache-2.0 License.
|
||||
|
||||
86
build.gradle
86
build.gradle
@@ -1,9 +1,11 @@
|
||||
import org.apache.tools.ant.filters.ReplaceTokens
|
||||
|
||||
plugins {
|
||||
id 'com.github.johnrengelman.shadow' version '8.1.1'
|
||||
id 'com.gradleup.shadow' version '9.2.2'
|
||||
id 'org.cadixdev.licenser' version '0.6.1' apply false
|
||||
id 'org.ajoberstar.grgit' version '5.2.2'
|
||||
id 'dev.architectury.loom' version '1.9-SNAPSHOT' apply false
|
||||
id 'gg.essential.multi-version.root' apply false
|
||||
id 'org.ajoberstar.grgit' version '5.3.2'
|
||||
id 'maven-publish'
|
||||
id 'java'
|
||||
}
|
||||
@@ -57,7 +59,12 @@ publishing {
|
||||
}
|
||||
|
||||
allprojects {
|
||||
apply plugin: 'com.github.johnrengelman.shadow'
|
||||
// Ignore parent projects (no jars)
|
||||
if (project.name == 'fabric' || project.name == 'bukkit') {
|
||||
return
|
||||
}
|
||||
|
||||
apply plugin: 'com.gradleup.shadow'
|
||||
apply plugin: 'org.cadixdev.licenser'
|
||||
apply plugin: 'java'
|
||||
|
||||
@@ -69,25 +76,23 @@ allprojects {
|
||||
repositories {
|
||||
mavenLocal()
|
||||
mavenCentral()
|
||||
maven { url 'https://repo.william278.net/releases/' }
|
||||
maven { url 'https://oss.sonatype.org/content/repositories/snapshots' }
|
||||
maven { url 'https://hub.spigotmc.org/nexus/content/repositories/snapshots/' }
|
||||
maven { url "https://repo.dmulloy2.net/repository/public/" }
|
||||
maven { url 'https://repo.papermc.io/repository/maven-public/' }
|
||||
maven { url 'https://repo.codemc.io/repository/maven-public/' }
|
||||
maven { url 'https://repo.minebench.de/' }
|
||||
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 {
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.2'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-params:5.10.2'
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.2'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
testImplementation(platform("org.junit:junit-bom:6.0.1"))
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
testCompileOnly 'org.jetbrains:annotations:26.0.2-1'
|
||||
}
|
||||
|
||||
license {
|
||||
@@ -96,17 +101,45 @@ allprojects {
|
||||
newLine = true
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
processResources {
|
||||
def tokenMap = rootProject.ext.properties
|
||||
tokenMap.merge("grgit", '', (s, s2) -> s)
|
||||
filesMatching(['**/*.json', '**/*.yml']) {
|
||||
filter ReplaceTokens as Class, beginToken: '${', endToken: '}',
|
||||
tokens: rootProject.ext.properties
|
||||
tokens: tokenMap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
subprojects {
|
||||
// Ignore parent projects (no jars)
|
||||
if (['fabric', 'bukkit'].contains(project.name)) {
|
||||
return
|
||||
}
|
||||
|
||||
// Project naming
|
||||
version rootProject.version
|
||||
archivesBaseName = "${rootProject.name}-${project.name.capitalize()}"
|
||||
def name = "$rootProject.name"
|
||||
if (rootProject != project.parent) {
|
||||
name += "-${project.parent.name.capitalize()}"
|
||||
} else {
|
||||
name += "-${project.name.capitalize()}"
|
||||
}
|
||||
archivesBaseName = name
|
||||
|
||||
// Version-specific configuration
|
||||
if (['fabric', 'bukkit'].contains(project.parent?.name)) {
|
||||
compileJava.options.release.set 21
|
||||
version += "+mc.${project.name}"
|
||||
|
||||
if (project.parent?.name?.equals('fabric')) {
|
||||
apply plugin: 'dev.architectury.loom'
|
||||
}
|
||||
}
|
||||
|
||||
jar {
|
||||
from '../LICENSE'
|
||||
@@ -118,7 +151,7 @@ subprojects {
|
||||
}
|
||||
|
||||
// API publishing
|
||||
if (['common', 'bukkit'].contains(project.name)) {
|
||||
if (project.name == 'common' || ['fabric', 'bukkit'].contains(project.parent?.name)) {
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
@@ -145,22 +178,35 @@ subprojects {
|
||||
}
|
||||
}
|
||||
|
||||
if (['bukkit'].contains(project.name)) {
|
||||
if (project.parent?.name?.equals('bukkit')) {
|
||||
publications {
|
||||
mavenJavaBukkit(MavenPublication) {
|
||||
"mavenJavaBukkit_${project.name.replace('.', '_')}"(MavenPublication) {
|
||||
groupId = 'net.william278.husksync'
|
||||
artifactId = 'husksync-bukkit'
|
||||
version = "$rootProject.version"
|
||||
version = "$rootProject.version+$project.name"
|
||||
artifact shadowJar
|
||||
artifact sourcesJar
|
||||
artifact javadocJar
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (project.parent?.name?.equals('fabric')) {
|
||||
publications {
|
||||
"mavenJavaFabric_${project.name.replace('.', '_')}"(MavenPublication) {
|
||||
groupId = 'net.william278.husksync'
|
||||
artifactId = 'husksync-fabric'
|
||||
version = "$rootProject.version+$project.name"
|
||||
artifact remapJar
|
||||
artifact sourcesJar
|
||||
artifact javadocJar
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jar.dependsOn(shadowJar)
|
||||
jar.dependsOn shadowJar
|
||||
clean.delete "$rootDir/target"
|
||||
}
|
||||
|
||||
@@ -175,7 +221,7 @@ def versionMetadata() {
|
||||
|
||||
// If unclean, return the last commit hash with -indev
|
||||
if (!grgit.status().clean) {
|
||||
return '-' + grgit.head().abbreviatedId + '-indev'
|
||||
return '-' + grgit.head().abbreviatedId + '-indev'
|
||||
}
|
||||
|
||||
// Otherwise if this matches a tag, return nothing
|
||||
|
||||
4
bukkit/1.21.1/gradle.properties
Normal file
4
bukkit/1.21.1/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
minecraft_version_range=1.21.1
|
||||
minecraft_version_numeric=12101
|
||||
minecraft_api_version=1.21
|
||||
paper_api_version=1.21.1-R0.1-SNAPSHOT
|
||||
4
bukkit/1.21.10/gradle.properties
Normal file
4
bukkit/1.21.10/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
minecraft_version_range=>=1.21.9 <=1.21.10
|
||||
minecraft_version_numeric=12110
|
||||
minecraft_api_version=1.21
|
||||
paper_api_version=1.21.10-R0.1-SNAPSHOT
|
||||
4
bukkit/1.21.11/gradle.properties
Normal file
4
bukkit/1.21.11/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
minecraft_version_range=>=1.21.11 <=1.21.11
|
||||
minecraft_version_numeric=12111
|
||||
minecraft_api_version=1.21
|
||||
paper_api_version=1.21.11-R0.1-SNAPSHOT
|
||||
4
bukkit/1.21.4/gradle.properties
Normal file
4
bukkit/1.21.4/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
minecraft_version_range=1.21.4
|
||||
minecraft_version_numeric=12104
|
||||
minecraft_api_version=1.21
|
||||
paper_api_version=1.21.4-R0.1-SNAPSHOT
|
||||
4
bukkit/1.21.5/gradle.properties
Normal file
4
bukkit/1.21.5/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
minecraft_version_range=1.21.5
|
||||
minecraft_version_numeric=12105
|
||||
minecraft_api_version=1.21
|
||||
paper_api_version=1.21.5-R0.1-SNAPSHOT
|
||||
4
bukkit/1.21.8/gradle.properties
Normal file
4
bukkit/1.21.8/gradle.properties
Normal file
@@ -0,0 +1,4 @@
|
||||
minecraft_version_range=>=1.21.7 <=1.21.8
|
||||
minecraft_version_numeric=12108
|
||||
minecraft_api_version=1.21
|
||||
paper_api_version=1.21.8-R0.1-SNAPSHOT
|
||||
@@ -1,37 +1,68 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'net.william278.preprocessor' version '1.0'
|
||||
id 'xyz.jpenilla.run-paper' version '2.3.1'
|
||||
id 'maven-publish'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation project(path: ':common')
|
||||
|
||||
implementation 'org.bstats:bstats-bukkit:3.0.2'
|
||||
implementation 'net.william278.uniform:uniform-bukkit:1.3.9'
|
||||
implementation 'net.william278.uniform:uniform-paper:1.3.9'
|
||||
implementation 'net.william278.toilet:toilet-bukkit:1.0.16'
|
||||
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.2'
|
||||
implementation 'dev.triumphteam:triumph-gui:3.1.7'
|
||||
implementation 'net.william278:mapdataapi:2.0'
|
||||
implementation 'org.bstats:bstats-bukkit:3.1.0'
|
||||
implementation 'net.kyori:adventure-platform-bukkit:4.4.1'
|
||||
implementation 'dev.triumphteam:triumph-gui:3.1.12'
|
||||
implementation 'space.arim.morepaperlib:morepaperlib:0.4.4'
|
||||
implementation 'de.tr7zw:item-nbt-api:2.12.4'
|
||||
implementation 'de.tr7zw:item-nbt-api:2.15.5'
|
||||
|
||||
compileOnly 'org.spigotmc:spigot-api:1.17.1-R0.1-SNAPSHOT'
|
||||
compileOnly 'com.github.retrooper.packetevents:spigot:2.3.0'
|
||||
compileOnly 'com.comphenix.protocol:ProtocolLib:5.1.0'
|
||||
compileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
compileOnly 'commons-io:commons-io:2.16.1'
|
||||
compileOnly 'org.json:json:20240303'
|
||||
compileOnly "io.papermc.paper:paper-api:${paper_api_version}"
|
||||
compileOnly 'com.github.retrooper:packetevents-spigot:2.10.1'
|
||||
compileOnly 'com.github.dmulloy2:ProtocolLib:5.3.0'
|
||||
compileOnly 'org.projectlombok:lombok:1.18.42'
|
||||
compileOnly 'commons-io:commons-io:2.21.0'
|
||||
compileOnly 'org.json:json:20250517'
|
||||
compileOnly 'net.william278:minedown:1.8.2'
|
||||
compileOnly 'de.exlll:configlib-yaml:4.5.0'
|
||||
compileOnly 'com.zaxxer:HikariCP:5.1.0'
|
||||
compileOnly 'de.exlll:configlib-yaml:4.6.4'
|
||||
compileOnly 'com.zaxxer:HikariCP:7.0.2'
|
||||
compileOnly 'net.william278:DesertWell:2.0.4'
|
||||
compileOnly 'net.william278:AdvancementAPI:97a9583413'
|
||||
compileOnly "redis.clients:jedis:$jedis_version"
|
||||
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.42'
|
||||
}
|
||||
|
||||
processResources {
|
||||
filesMatching(['**/*.json', '**/*.yml']) {
|
||||
expand([
|
||||
version: version,
|
||||
paper_api_version: paper_api_version,
|
||||
minecraft_version: project.name,
|
||||
minecraft_version_range: minecraft_version_range,
|
||||
minecraft_api_version: minecraft_api_version
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets.main {
|
||||
java.srcDirs '../src/main/java'
|
||||
resources.srcDirs '../src/main/resources'
|
||||
}
|
||||
javadoc.setSource('./build/generated/preprocessed/main/java')
|
||||
|
||||
preprocess {
|
||||
vars.put('MC', minecraft_version_numeric)
|
||||
}
|
||||
|
||||
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'
|
||||
@@ -42,20 +73,30 @@ shadowJar {
|
||||
relocate 'org.intellij', 'net.william278.husksync.libraries'
|
||||
relocate 'com.zaxxer', 'net.william278.husksync.libraries'
|
||||
relocate 'de.exlll', 'net.william278.husksync.libraries'
|
||||
relocate 'net.william278.uniform', 'net.william278.husksync.libraries.uniform'
|
||||
relocate 'net.william278.toilet', 'net.william278.husksync.libraries.toilet'
|
||||
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.william278.mpdbconverter', 'net.william278.husksync.libraries.mpdbconverter'
|
||||
relocate 'net.william278.hslmigrator', 'net.william278.husksync.libraries.hslconverter'
|
||||
relocate 'org.json', 'net.william278.husksync.libraries.json'
|
||||
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 'org.bstats', 'net.william278.husksync.libraries.bstats'
|
||||
relocate 'dev.triumphteam.gui', 'net.william278.husksync.libraries.triumphgui'
|
||||
relocate 'space.arim.morepaperlib', 'net.william278.husksync.libraries.paperlib'
|
||||
relocate 'de.tr7zw.changeme.nbtapi', 'net.william278.husksync.libraries.nbtapi'
|
||||
|
||||
minimize()
|
||||
}
|
||||
|
||||
tasks {
|
||||
runServer {
|
||||
minecraftVersion(project.name)
|
||||
|
||||
downloadPlugins {
|
||||
github("plan-player-analytics", "Plan", "5.6.2965", "Plan-5.6-build-2965.jar")
|
||||
}
|
||||
}
|
||||
}
|
||||
5
bukkit/gradle.properties
Normal file
5
bukkit/gradle.properties
Normal file
@@ -0,0 +1,5 @@
|
||||
org.gradle.daemon=false
|
||||
org.gradle.parallel=true
|
||||
org.gradle.configureoncommand=true
|
||||
org.gradle.parallel.threads=4
|
||||
org.gradle.jvmargs=-Xmx8G
|
||||
0
bukkit/root.gradle
Normal file
0
bukkit/root.gradle
Normal file
@@ -23,6 +23,7 @@ import com.google.common.collect.Lists;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.common.collect.Sets;
|
||||
import com.google.gson.Gson;
|
||||
import de.tr7zw.changeme.nbtapi.NBT;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
@@ -34,7 +35,7 @@ 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.command.PluginCommand;
|
||||
import net.william278.husksync.config.Locales;
|
||||
import net.william278.husksync.config.Server;
|
||||
import net.william278.husksync.config.Settings;
|
||||
@@ -46,6 +47,8 @@ import net.william278.husksync.database.PostgresDatabase;
|
||||
import net.william278.husksync.event.BukkitEventDispatcher;
|
||||
import net.william278.husksync.hook.PlanHook;
|
||||
import net.william278.husksync.listener.BukkitEventListener;
|
||||
import net.william278.husksync.listener.LockedHandler;
|
||||
import net.william278.husksync.maps.BukkitMapHandler;
|
||||
import net.william278.husksync.migrator.LegacyMigrator;
|
||||
import net.william278.husksync.migrator.Migrator;
|
||||
import net.william278.husksync.migrator.MpdbMigrator;
|
||||
@@ -54,16 +57,19 @@ import net.william278.husksync.sync.DataSyncer;
|
||||
import net.william278.husksync.user.BukkitUser;
|
||||
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 net.william278.toilet.BukkitToilet;
|
||||
import net.william278.toilet.Toilet;
|
||||
import net.william278.uniform.Uniform;
|
||||
import net.william278.uniform.bukkit.BukkitUniform;
|
||||
import org.bstats.bukkit.Metrics;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.map.MapView;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
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.AttachedScheduler;
|
||||
import space.arim.morepaperlib.scheduling.GracefulScheduling;
|
||||
@@ -76,8 +82,9 @@ import java.util.stream.Collectors;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@SuppressWarnings("unchecked")
|
||||
public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.Supplier,
|
||||
BukkitEventDispatcher, BukkitMapPersister {
|
||||
BukkitEventDispatcher, BukkitMapHandler {
|
||||
|
||||
/**
|
||||
* Metrics ID for <a href="https://bstats.org/plugin/bukkit/HuskSync%20-%20Bukkit/13140">HuskSync on Bukkit</a>.
|
||||
@@ -85,15 +92,17 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
private static final int METRICS_ID = 13140;
|
||||
private static final String PLATFORM_TYPE_ID = "bukkit";
|
||||
|
||||
private final Map<Identifier, Serializer<? extends Data>> serializers = Maps.newLinkedHashMap();
|
||||
private final HashMap<Identifier, Serializer<? extends Data>> serializers = Maps.newHashMap();
|
||||
private final Map<UUID, Map<Identifier, Data>> playerCustomDataStore = Maps.newConcurrentMap();
|
||||
private final Map<Integer, MapView> mapViews = Maps.newConcurrentMap();
|
||||
private final List<Migrator> availableMigrators = Lists.newArrayList();
|
||||
private final Set<UUID> lockedPlayers = Sets.newConcurrentHashSet();
|
||||
private final Set<UUID> disconnectingPlayers = Sets.newConcurrentHashSet();
|
||||
|
||||
private boolean disabling;
|
||||
private Gson gson;
|
||||
private AudienceProvider audiences;
|
||||
private Toilet toilet;
|
||||
private MorePaperLib paperLib;
|
||||
private Database database;
|
||||
private RedisManager redisManager;
|
||||
@@ -123,6 +132,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
loadSettings();
|
||||
loadLocales();
|
||||
loadServer();
|
||||
validateConfigFiles();
|
||||
});
|
||||
|
||||
this.eventListener = createEventListener();
|
||||
@@ -132,6 +142,20 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
@Override
|
||||
public void onEnable() {
|
||||
this.audiences = BukkitAudiences.create(this);
|
||||
this.toilet = BukkitToilet.create(getDumpOptions());
|
||||
|
||||
// Check compatibility
|
||||
checkCompatibility();
|
||||
|
||||
// Preload NBT-API
|
||||
if (!NBT.preloadApi()) {
|
||||
log(Level.SEVERE, "Failed to load NBT API. HuskSync will not be initialized!");
|
||||
return;
|
||||
}
|
||||
|
||||
// Register commands
|
||||
initialize("commands", (plugin) -> getUniform().register(PluginCommand.Type.create(this)));
|
||||
|
||||
// Prepare data adapter
|
||||
initialize("data adapter", (plugin) -> {
|
||||
if (settings.getSynchronization().isCompressData()) {
|
||||
@@ -143,19 +167,20 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
|
||||
// Prepare serializers
|
||||
initialize("data serializers", (plugin) -> {
|
||||
registerSerializer(Identifier.PERSISTENT_DATA, new BukkitSerializer.PersistentData(this));
|
||||
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.Json<>(this, BukkitData.Location.class));
|
||||
registerSerializer(Identifier.HEALTH, new BukkitSerializer.Json<>(this, BukkitData.Health.class));
|
||||
registerSerializer(Identifier.HUNGER, new BukkitSerializer.Json<>(this, BukkitData.Hunger.class));
|
||||
registerSerializer(Identifier.ATTRIBUTES, new BukkitSerializer.Json<>(this, BukkitData.Attributes.class));
|
||||
registerSerializer(Identifier.GAME_MODE, new BukkitSerializer.Json<>(this, BukkitData.GameMode.class));
|
||||
registerSerializer(Identifier.FLIGHT_STATUS, new BukkitSerializer.Json<>(this, BukkitData.FlightStatus.class));
|
||||
registerSerializer(Identifier.STATISTICS, new Serializer.Json<>(this, BukkitData.Statistics.class));
|
||||
registerSerializer(Identifier.POTION_EFFECTS, new BukkitSerializer.PotionEffects(this));
|
||||
registerSerializer(Identifier.STATISTICS, new BukkitSerializer.Json<>(this, BukkitData.Statistics.class));
|
||||
registerSerializer(Identifier.EXPERIENCE, new BukkitSerializer.Json<>(this, BukkitData.Experience.class));
|
||||
registerSerializer(Identifier.PERSISTENT_DATA, new BukkitSerializer.PersistentData(this));
|
||||
registerSerializer(Identifier.GAME_MODE, new Serializer.Json<>(this, BukkitData.GameMode.class));
|
||||
registerSerializer(Identifier.FLIGHT_STATUS, new Serializer.Json<>(this, BukkitData.FlightStatus.class));
|
||||
registerSerializer(Identifier.ATTRIBUTES, new Serializer.Json<>(this, BukkitData.Attributes.class));
|
||||
registerSerializer(Identifier.HEALTH, new Serializer.Json<>(this, BukkitData.Health.class));
|
||||
registerSerializer(Identifier.HUNGER, new Serializer.Json<>(this, BukkitData.Hunger.class));
|
||||
registerSerializer(Identifier.EXPERIENCE, new Serializer.Json<>(this, BukkitData.Experience.class));
|
||||
registerSerializer(Identifier.LOCATION, new Serializer.Json<>(this, BukkitData.Location.class));
|
||||
validateDependencies();
|
||||
});
|
||||
|
||||
// Setup available migrators
|
||||
@@ -192,9 +217,6 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
// Register events
|
||||
initialize("events", (plugin) -> eventListener.onEnable());
|
||||
|
||||
// Register commands
|
||||
initialize("commands", (plugin) -> BukkitCommand.Type.registerCommands(this));
|
||||
|
||||
// Register plugin hooks
|
||||
initialize("hooks", (plugin) -> {
|
||||
if (isDependencyLoaded("Plan") && getSettings().isEnablePlanHook()) {
|
||||
@@ -260,6 +282,12 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
this.dataSyncer = dataSyncer;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Uniform getUniform() {
|
||||
return BukkitUniform.getInstance(this);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user) {
|
||||
@@ -277,7 +305,8 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
|
||||
@Override
|
||||
public boolean isDependencyLoaded(@NotNull String name) {
|
||||
return getServer().getPluginManager().getPlugin(name) != null;
|
||||
final Plugin plugin = getServer().getPluginManager().getPlugin(name);
|
||||
return plugin != null;
|
||||
}
|
||||
|
||||
// Register bStats metrics
|
||||
@@ -289,7 +318,7 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
try {
|
||||
new Metrics(this, metricsId);
|
||||
} catch (Throwable e) {
|
||||
log(Level.WARNING, "Failed to register bStats metrics (" + e.getMessage() + ")");
|
||||
log(Level.WARNING, "Failed to register bStats metrics (%s)".formatted(e.getMessage()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,11 +349,23 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
return PLATFORM_TYPE_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public String getServerVersion() {
|
||||
return String.format("%s/%s", getServer().getName(), getServer().getVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<LegacyConverter> getLegacyConverter() {
|
||||
return Optional.of(legacyConverter);
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public LockedHandler getLockedHandler() {
|
||||
return eventListener.getLockedHandler();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public GracefulScheduling getScheduler() {
|
||||
return paperLib.scheduling();
|
||||
@@ -347,11 +388,6 @@ public class BukkitHuskSync extends JavaPlugin implements HuskSync, BukkitTask.S
|
||||
return getScheduler().entitySpecificScheduler(((BukkitUser) user).getPlayer());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public CommandRegistration getCommandRegistrar() {
|
||||
return paperLib.commandRegistration();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Path getConfigDirectory() {
|
||||
|
||||
@@ -20,14 +20,17 @@
|
||||
package net.william278.husksync;
|
||||
|
||||
import net.kyori.adventure.audience.Audience;
|
||||
import net.william278.desertwell.util.Version;
|
||||
import net.william278.husksync.listener.BukkitEventListener;
|
||||
import net.william278.husksync.listener.PaperEventListener;
|
||||
import net.william278.uniform.Uniform;
|
||||
import net.william278.uniform.paper.PaperUniform;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@SuppressWarnings({"unused"})
|
||||
public class PaperHuskSync extends BukkitHuskSync {
|
||||
|
||||
@NotNull
|
||||
@@ -43,4 +46,15 @@ public class PaperHuskSync extends BukkitHuskSync {
|
||||
return player == null || !player.isOnline() ? Audience.empty() : player;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public Version getMinecraftVersion() {
|
||||
return Version.fromString(getServer().getMinecraftVersion());
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public Uniform getUniform() {
|
||||
return PaperUniform.getInstance(this);
|
||||
}
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.io.InputStream;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@NoArgsConstructor
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
@@ -46,13 +47,20 @@ public class PaperHuskSyncLoader implements PluginLoader {
|
||||
resolveLibraries(classpathBuilder).stream()
|
||||
.map(DefaultArtifact::new)
|
||||
.forEach(artifact -> resolver.addDependency(new Dependency(artifact, null)));
|
||||
resolver.addRepository(new RemoteRepository.Builder(
|
||||
"maven", "default", "https://repo.maven.apache.org/maven2/"
|
||||
).build());
|
||||
resolver.addRepository(new RemoteRepository.Builder("maven", "default", getMavenUrl()).build());
|
||||
|
||||
classpathBuilder.addLibrary(resolver);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static String getMavenUrl() {
|
||||
return Stream.of(
|
||||
System.getenv("PAPER_DEFAULT_CENTRAL_REPOSITORY"),
|
||||
System.getProperty("org.bukkit.plugin.java.LibraryLoader.centralURL"),
|
||||
"https://maven-central.storage-download.googleapis.com/maven2"
|
||||
).filter(Objects::nonNull).findFirst().orElseThrow(IllegalStateException::new);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static List<String> resolveLibraries(@NotNull PluginClasspathBuilder classpathBuilder) {
|
||||
try (InputStream input = getLibraryListFile()) {
|
||||
@@ -28,6 +28,7 @@ import net.william278.husksync.user.OnlineUser;
|
||||
import net.william278.husksync.user.User;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@@ -59,6 +60,10 @@ public class BukkitHuskSyncAPI extends HuskSyncAPI {
|
||||
*/
|
||||
@NotNull
|
||||
public static BukkitHuskSyncAPI getInstance() {
|
||||
if (!JavaPlugin.getProvidingPlugin(BukkitHuskSyncAPI.class).getName().equals("HuskSync")) {
|
||||
throw new NotRegisteredException("This is likely because you have shaded HuskSync into your plugin JAR " +
|
||||
"and need to fix your maven/gradle/build script so that it *compiles against* HuskSync instead.");
|
||||
}
|
||||
if (instance == null) {
|
||||
throw new NotRegisteredException();
|
||||
}
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
/*
|
||||
* 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().isBrigadierTabCompletion()) {
|
||||
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());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -29,24 +29,25 @@ import net.william278.desertwell.util.ThrowingConsumer;
|
||||
import net.william278.husksync.BukkitHuskSync;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.adapter.Adaptable;
|
||||
import net.william278.husksync.config.Settings.SynchronizationSettings.AttributeSettings;
|
||||
import net.william278.husksync.user.BukkitUser;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Registry;
|
||||
import org.bukkit.Statistic;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.advancement.AdvancementProgress;
|
||||
import org.bukkit.attribute.AttributeInstance;
|
||||
import org.bukkit.attribute.AttributeModifier;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.inventory.InventoryType;
|
||||
import org.bukkit.inventory.EquipmentSlot;
|
||||
import org.bukkit.inventory.EquipmentSlotGroup;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.persistence.PersistentDataContainer;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.bukkit.potion.PotionEffect;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import org.jetbrains.annotations.Range;
|
||||
import org.jetbrains.annotations.Unmodifiable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
@@ -69,7 +70,6 @@ public abstract class BukkitData implements Data {
|
||||
private final @Nullable ItemStack @NotNull [] contents;
|
||||
|
||||
private Items(@Nullable ItemStack @NotNull [] contents) {
|
||||
|
||||
this.contents = Arrays.stream(contents.clone())
|
||||
.map(i -> i == null || i.getType() == Material.AIR ? null : i)
|
||||
.toArray(ItemStack[]::new);
|
||||
@@ -127,8 +127,6 @@ public abstract class BukkitData implements Data {
|
||||
@Getter
|
||||
public static class Inventory extends BukkitData.Items implements Data.Items.Inventory {
|
||||
|
||||
public static final int INVENTORY_SLOT_COUNT = 41;
|
||||
|
||||
@Range(from = 0, to = 8)
|
||||
private int heldItemSlot;
|
||||
|
||||
@@ -158,8 +156,9 @@ public abstract class BukkitData implements Data {
|
||||
this.clearInventoryCraftingSlots(player);
|
||||
player.setItemOnCursor(null);
|
||||
player.getInventory().setContents(plugin.setMapViews(getContents()));
|
||||
player.updateInventory();
|
||||
player.getInventory().setHeldItemSlot(heldItemSlot);
|
||||
//noinspection UnstableApiUsage
|
||||
player.updateInventory();
|
||||
}
|
||||
|
||||
private void clearInventoryCraftingSlots(@NotNull Player player) {
|
||||
@@ -175,15 +174,18 @@ public abstract class BukkitData implements Data {
|
||||
|
||||
public static class EnderChest extends BukkitData.Items implements Data.Items.EnderChest {
|
||||
|
||||
public static final int ENDER_CHEST_SLOT_COUNT = 27;
|
||||
|
||||
private EnderChest(@NotNull ItemStack[] contents) {
|
||||
private EnderChest(@Nullable ItemStack @NotNull [] contents) {
|
||||
super(contents);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static BukkitData.Items.EnderChest adapt(@NotNull ItemStack[] items) {
|
||||
return new BukkitData.Items.EnderChest(items);
|
||||
public static BukkitData.Items.EnderChest adapt(@Nullable ItemStack @NotNull [] contents) {
|
||||
return new BukkitData.Items.EnderChest(contents);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static BukkitData.Items.EnderChest adapt(@NotNull Collection<ItemStack> items) {
|
||||
return adapt(items.toArray(ItemStack[]::new));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@@ -193,14 +195,16 @@ public abstract class BukkitData implements Data {
|
||||
|
||||
@Override
|
||||
public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException {
|
||||
user.getPlayer().getEnderChest().setContents(plugin.setMapViews(getContents()));
|
||||
ItemStack[] fullContents = plugin.setMapViews(getContents());
|
||||
ItemStack[] enderChestContents = Arrays.copyOf(fullContents, Math.min(fullContents.length, user.getPlayer().getEnderChest().getSize()));
|
||||
user.getPlayer().getEnderChest().setContents(enderChestContents);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public static class ItemArray extends BukkitData.Items implements Data.Items {
|
||||
|
||||
private ItemArray(@NotNull ItemStack[] contents) {
|
||||
private ItemArray(@Nullable ItemStack @NotNull [] contents) {
|
||||
super(contents);
|
||||
}
|
||||
|
||||
@@ -210,7 +214,7 @@ public abstract class BukkitData implements Data {
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static ItemArray adapt(@NotNull ItemStack[] drops) {
|
||||
public static ItemArray adapt(@Nullable ItemStack @NotNull [] drops) {
|
||||
return new ItemArray(drops);
|
||||
}
|
||||
|
||||
@@ -231,33 +235,33 @@ public abstract class BukkitData implements Data {
|
||||
private final Collection<PotionEffect> effects;
|
||||
|
||||
@NotNull
|
||||
public static BukkitData.PotionEffects from(@NotNull Collection<PotionEffect> effects) {
|
||||
return new BukkitData.PotionEffects(effects);
|
||||
public static BukkitData.PotionEffects from(@NotNull Collection<PotionEffect> sei) {
|
||||
return new BukkitData.PotionEffects(Lists.newArrayList(sei.stream().filter(e -> !e.isAmbient()).toList()));
|
||||
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static BukkitData.PotionEffects adapt(@NotNull Collection<Effect> effects) {
|
||||
return from(
|
||||
effects.stream()
|
||||
.map(effect -> new PotionEffect(
|
||||
Objects.requireNonNull(
|
||||
PotionEffectType.getByName(effect.type()),
|
||||
"Invalid potion effect type"
|
||||
),
|
||||
effect.duration(),
|
||||
effect.amplifier(),
|
||||
effect.isAmbient(),
|
||||
effect.showParticles(),
|
||||
effect.hasIcon()
|
||||
))
|
||||
.toList()
|
||||
);
|
||||
return from(effects.stream()
|
||||
.map(effect -> {
|
||||
final PotionEffectType type = matchEffectType(effect.type());
|
||||
return type != null ? new PotionEffect(
|
||||
type,
|
||||
effect.duration(),
|
||||
effect.amplifier(),
|
||||
effect.isAmbient(),
|
||||
effect.showParticles(),
|
||||
effect.hasIcon()
|
||||
) : null;
|
||||
})
|
||||
.filter(Objects::nonNull)
|
||||
.toList());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@SuppressWarnings("unused")
|
||||
public static BukkitData.PotionEffects empty() {
|
||||
return new BukkitData.PotionEffects(List.of());
|
||||
return new BukkitData.PotionEffects(Lists.newArrayList());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -273,10 +277,11 @@ public abstract class BukkitData implements Data {
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
@Unmodifiable
|
||||
public List<Effect> getActiveEffects() {
|
||||
return effects.stream()
|
||||
.map(potionEffect -> new Effect(
|
||||
potionEffect.getType().getName().toLowerCase(Locale.ENGLISH),
|
||||
potionEffect.getType().getKey().toString(),
|
||||
potionEffect.getAmplifier(),
|
||||
potionEffect.getDuration(),
|
||||
potionEffect.isAmbient(),
|
||||
@@ -342,9 +347,12 @@ public abstract class BukkitData implements Data {
|
||||
}));
|
||||
}
|
||||
|
||||
private void setAdvancement(@NotNull HuskSync plugin, @NotNull org.bukkit.advancement.Advancement advancement,
|
||||
@NotNull Player player, @NotNull BukkitUser user,
|
||||
@NotNull Collection<String> toAward, @NotNull Collection<String> toRevoke) {
|
||||
private void setAdvancement(@NotNull HuskSync plugin,
|
||||
@NotNull org.bukkit.advancement.Advancement advancement,
|
||||
@NotNull Player player,
|
||||
@NotNull BukkitUser user,
|
||||
@NotNull Collection<String> toAward,
|
||||
@NotNull Collection<String> toRevoke) {
|
||||
plugin.runSync(() -> {
|
||||
// Track player exp level & progress
|
||||
final int expLevel = player.getLevel();
|
||||
@@ -356,7 +364,8 @@ public abstract class BukkitData implements Data {
|
||||
toRevoke.forEach(progress::revokeCriteria);
|
||||
|
||||
// Set player experience and level (prevent advancement awards applying twice), reset game rule
|
||||
if (!toAward.isEmpty() && player.getLevel() != expLevel || player.getExp() != expProgress) {
|
||||
if (!toAward.isEmpty()
|
||||
&& (player.getLevel() != expLevel || player.getExp() != expProgress)) {
|
||||
player.setLevel(expLevel);
|
||||
player.setExp(expProgress);
|
||||
}
|
||||
@@ -447,9 +456,10 @@ public abstract class BukkitData implements Data {
|
||||
Registry.STATISTIC.forEach(id -> {
|
||||
switch (id.getType()) {
|
||||
case UNTYPED -> addStatistic(player, id, generic);
|
||||
case BLOCK -> addMaterialStatistic(player, id, blocks, true);
|
||||
case ITEM -> addMaterialStatistic(player, id, items, false);
|
||||
case ENTITY -> addEntityStatistic(player, id, entities);
|
||||
// Todo - Future - Use BLOCK and ITEM registries when API stabilizes
|
||||
case BLOCK -> addStatistic(player, id, Registry.MATERIAL, blocks);
|
||||
case ITEM -> addStatistic(player, id, Registry.MATERIAL, items);
|
||||
case ENTITY -> addStatistic(player, id, Registry.ENTITY_TYPE, entities);
|
||||
}
|
||||
});
|
||||
return new BukkitData.Statistics(generic, blocks, items, entities);
|
||||
@@ -470,43 +480,36 @@ public abstract class BukkitData implements Data {
|
||||
}
|
||||
}
|
||||
|
||||
private static void addMaterialStatistic(@NotNull Player p, @NotNull Statistic id,
|
||||
@NotNull Map<String, Map<String, Integer>> map, boolean isBlock) {
|
||||
Registry.MATERIAL.forEach(material -> {
|
||||
if ((material.isBlock() && !isBlock) || (material.isItem() && isBlock)) {
|
||||
return;
|
||||
}
|
||||
final int stat = p.getStatistic(id, material);
|
||||
if (stat != 0) {
|
||||
map.computeIfAbsent(id.getKey().getKey(), k -> Maps.newHashMap())
|
||||
.put(material.getKey().getKey(), stat);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private static void addEntityStatistic(@NotNull Player p, @NotNull Statistic id,
|
||||
@NotNull Map<String, Map<String, Integer>> map) {
|
||||
Registry.ENTITY_TYPE.forEach(entity -> {
|
||||
if (!entity.isAlive()) {
|
||||
return;
|
||||
}
|
||||
final int stat = p.getStatistic(id, entity);
|
||||
if (stat != 0) {
|
||||
map.computeIfAbsent(id.getKey().getKey(), k -> Maps.newHashMap())
|
||||
.put(entity.getKey().getKey(), stat);
|
||||
private static <R extends Keyed> void addStatistic(@NotNull Player p, @NotNull Statistic id,
|
||||
@NotNull Registry<R> registry,
|
||||
@NotNull Map<String, Map<String, Integer>> map) {
|
||||
registry.forEach(i -> {
|
||||
try {
|
||||
int stat = 0;
|
||||
if (i instanceof Material mat && ((id.getType() == Statistic.Type.BLOCK && mat.isBlock())
|
||||
|| (id.getType() == Statistic.Type.ITEM && mat.isItem()))) {
|
||||
stat = p.getStatistic(id, mat);
|
||||
} else if (i instanceof EntityType ent && id.getType() == Statistic.Type.ENTITY) {
|
||||
stat = p.getStatistic(id, ent);
|
||||
}
|
||||
if (stat != 0) {
|
||||
map.compute(id.getKey().getKey(), (k, v) -> v == null ? Maps.newHashMap() : v)
|
||||
.put(i.getKey().getKey(), stat);
|
||||
}
|
||||
} catch (IllegalStateException ignored) {
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) {
|
||||
genericStatistics.forEach((id, v) -> applyStat(user, id, Statistic.Type.UNTYPED, v));
|
||||
blockStatistics.forEach((id, m) -> m.forEach((b, v) -> applyStat(user, id, Statistic.Type.BLOCK, v, b)));
|
||||
itemStatistics.forEach((id, m) -> m.forEach((i, v) -> applyStat(user, id, Statistic.Type.ITEM, v, i)));
|
||||
entityStatistics.forEach((id, m) -> m.forEach((e, v) -> applyStat(user, id, Statistic.Type.ENTITY, v, e)));
|
||||
public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync p) {
|
||||
genericStatistics.forEach((k, v) -> applyStat(p, user, k, Statistic.Type.UNTYPED, v));
|
||||
blockStatistics.forEach((k, m) -> m.forEach((b, v) -> applyStat(p, user, k, Statistic.Type.BLOCK, v, b)));
|
||||
itemStatistics.forEach((k, m) -> m.forEach((i, v) -> applyStat(p, user, k, Statistic.Type.ITEM, v, i)));
|
||||
entityStatistics.forEach((k, m) -> m.forEach((e, v) -> applyStat(p, user, k, Statistic.Type.ENTITY, v, e)));
|
||||
}
|
||||
|
||||
private void applyStat(@NotNull UserDataHolder user, @NotNull String id,
|
||||
private void applyStat(@NotNull HuskSync plugin, @NotNull UserDataHolder user, @NotNull String id,
|
||||
@NotNull Statistic.Type type, int value, @NotNull String... key) {
|
||||
final Player player = ((BukkitUser) user).getPlayer();
|
||||
final Statistic stat = matchStatistic(id);
|
||||
@@ -517,10 +520,21 @@ public abstract class BukkitData implements Data {
|
||||
try {
|
||||
switch (type) {
|
||||
case UNTYPED -> player.setStatistic(stat, value);
|
||||
case BLOCK, ITEM -> player.setStatistic(stat, Objects.requireNonNull(matchMaterial(key[0])), value);
|
||||
case ENTITY -> player.setStatistic(stat, Objects.requireNonNull(matchEntityType(key[0])), value);
|
||||
case BLOCK, ITEM -> {
|
||||
Material material = matchMaterial(key.length > 0 ? key[0] : null);
|
||||
if (material != null) {
|
||||
player.setStatistic(stat, material, value);
|
||||
}
|
||||
}
|
||||
case ENTITY -> {
|
||||
EntityType entity = matchEntityType(key.length > 0 ? key[0] : null);
|
||||
if (entity != null) {
|
||||
player.setStatistic(stat, entity, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (Throwable ignored) {
|
||||
} catch (Throwable a) {
|
||||
plugin.log(Level.WARNING, "Failed to apply statistic " + id, a);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -555,21 +569,29 @@ public abstract class BukkitData implements Data {
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public static class Attributes extends BukkitData implements Data.Attributes, Adaptable {
|
||||
|
||||
private List<Attribute> attributes;
|
||||
|
||||
@NotNull
|
||||
public static BukkitData.Attributes adapt(@NotNull Player player, @NotNull HuskSync plugin) {
|
||||
if (!Bukkit.isPrimaryThread()) {
|
||||
try {
|
||||
return Bukkit.getScheduler().callSyncMethod((Plugin) plugin, () -> adapt(player, plugin)).get();
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to adapt attributes on main thread", e);
|
||||
}
|
||||
}
|
||||
|
||||
final List<Attribute> attributes = Lists.newArrayList();
|
||||
final AttributeSettings settings = plugin.getSettings().getSynchronization().getAttributes();
|
||||
Registry.ATTRIBUTE.forEach(id -> {
|
||||
final AttributeInstance instance = player.getAttribute(id);
|
||||
if (instance == null || instance.getValue() == instance.getDefaultValue() || plugin
|
||||
.getSettings().getSynchronization().isIgnoredAttribute(id.getKey().toString())) {
|
||||
// We don't sync unmodified or disabled attributes
|
||||
return;
|
||||
if (settings.isIgnoredAttribute(id.getKey().toString()) || instance == null) {
|
||||
return; // We don't sync attributes not marked as to be synced
|
||||
}
|
||||
attributes.add(adapt(instance));
|
||||
attributes.add(adapt(instance, settings));
|
||||
});
|
||||
return new BukkitData.Attributes(attributes);
|
||||
}
|
||||
@@ -588,47 +610,72 @@ public abstract class BukkitData implements Data {
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static Attribute adapt(@NotNull AttributeInstance instance) {
|
||||
private static Attribute adapt(@NotNull AttributeInstance instance, @NotNull AttributeSettings settings) {
|
||||
return new Attribute(
|
||||
instance.getAttribute().getKey().toString(),
|
||||
instance.getBaseValue(),
|
||||
instance.getModifiers().stream().map(BukkitData.Attributes::adapt).collect(Collectors.toSet())
|
||||
instance.getModifiers().stream()
|
||||
.filter(modifier -> !settings.isIgnoredModifier(modifier.getName()))
|
||||
.filter(modifier -> modifier.getSlotGroup() != EquipmentSlotGroup.ANY)
|
||||
.map(BukkitData.Attributes::adapt).collect(Collectors.toSet())
|
||||
);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static Modifier adapt(@NotNull AttributeModifier modifier) {
|
||||
return new Modifier(
|
||||
modifier.getUniqueId(),
|
||||
modifier.getName(),
|
||||
modifier.getKey().toString(),
|
||||
modifier.getAmount(),
|
||||
modifier.getOperation().ordinal(),
|
||||
modifier.getSlot() != null ? modifier.getSlot().ordinal() : -1
|
||||
modifier.getSlotGroup().toString()
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException {
|
||||
Registry.ATTRIBUTE.forEach(id -> applyAttribute(user.getPlayer().getAttribute(id), getAttribute(id).orElse(null)));
|
||||
}
|
||||
|
||||
private static void applyAttribute(@Nullable AttributeInstance instance, @Nullable Attribute attribute) {
|
||||
if (instance == null) {
|
||||
return;
|
||||
}
|
||||
instance.setBaseValue(attribute == null ? instance.getDefaultValue() : instance.getBaseValue());
|
||||
instance.getModifiers().forEach(instance::removeModifier);
|
||||
instance.setBaseValue(attribute == null ? instance.getValue() : attribute.baseValue());
|
||||
if (attribute != null) {
|
||||
attribute.modifiers().forEach(modifier -> instance.addModifier(new AttributeModifier(
|
||||
modifier.uuid(),
|
||||
modifier.name(),
|
||||
modifier.amount(),
|
||||
AttributeModifier.Operation.values()[modifier.operationType()],
|
||||
modifier.equipmentSlot() != -1 ? EquipmentSlot.values()[modifier.equipmentSlot()] : null
|
||||
)));
|
||||
attribute.modifiers().stream()
|
||||
.filter(mod -> instance.getModifiers().stream().map(AttributeModifier::getName)
|
||||
.noneMatch(n -> n.equals(mod.name())))
|
||||
.distinct().filter(mod -> !mod.hasUuid())
|
||||
.forEach(mod -> instance.addModifier(adapt(mod)));
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static AttributeModifier adapt(@NotNull Modifier modifier) {
|
||||
return new AttributeModifier(
|
||||
Objects.requireNonNull(NamespacedKey.fromString(modifier.name())),
|
||||
modifier.amount(),
|
||||
AttributeModifier.Operation.values()[modifier.operation()],
|
||||
Optional.ofNullable(EquipmentSlotGroup.getByName(modifier.slotGroup())).orElse(EquipmentSlotGroup.ANY)
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void apply(@NotNull BukkitUser user, @NotNull BukkitHuskSync plugin) throws IllegalStateException {
|
||||
if (!Bukkit.isPrimaryThread()) {
|
||||
try {
|
||||
Bukkit.getScheduler().callSyncMethod(plugin, () -> { this.apply(user, plugin); return null; }).get();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to apply attributes on main thread", e);
|
||||
}
|
||||
}
|
||||
|
||||
final AttributeSettings settings = plugin.getSettings().getSynchronization().getAttributes();
|
||||
Registry.ATTRIBUTE.forEach(id -> {
|
||||
if (settings.isIgnoredAttribute(id.getKey().toString())) {
|
||||
return;
|
||||
}
|
||||
applyAttribute(user.getPlayer().getAttribute(id), getAttribute(id).orElse(null));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Getter
|
||||
@@ -640,24 +687,38 @@ public abstract class BukkitData implements Data {
|
||||
private double health;
|
||||
@SerializedName("health_scale")
|
||||
private double healthScale;
|
||||
@SerializedName("is_health_scaled")
|
||||
private boolean isHealthScaled;
|
||||
|
||||
@NotNull
|
||||
public static BukkitData.Health from(double health, double healthScale) {
|
||||
return new BukkitData.Health(health, healthScale);
|
||||
public static BukkitData.Health from(double health, double scale, boolean isScaled) {
|
||||
return new BukkitData.Health(health, scale, isScaled);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link #from(double, double, boolean)} instead
|
||||
*/
|
||||
@NotNull
|
||||
@Deprecated(since = "3.5.4")
|
||||
public static BukkitData.Health from(double health, double scale) {
|
||||
return from(health, scale, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link #from(double, double, boolean)} instead
|
||||
*/
|
||||
@NotNull
|
||||
@Deprecated(forRemoval = true, since = "3.5")
|
||||
@SuppressWarnings("unused")
|
||||
public static BukkitData.Health from(double health, double maxHealth, double healthScale) {
|
||||
return from(health, healthScale);
|
||||
public static BukkitData.Health from(double health, @SuppressWarnings("unused") double max, double scale) {
|
||||
return from(health, scale, false);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static BukkitData.Health adapt(@NotNull Player player) {
|
||||
return from(
|
||||
player.getHealth(),
|
||||
player.isHealthScaled() ? player.getHealthScale() : 0d
|
||||
player.getHealthScale(),
|
||||
player.isHealthScaled()
|
||||
);
|
||||
}
|
||||
|
||||
@@ -674,16 +735,12 @@ public abstract class BukkitData implements Data {
|
||||
}
|
||||
|
||||
// Set health scale
|
||||
double scale = healthScale <= 0 ? player.getMaxHealth() : healthScale;
|
||||
try {
|
||||
if (healthScale != 0d) {
|
||||
player.setHealthScaled(true);
|
||||
player.setHealthScale(healthScale);
|
||||
} else {
|
||||
player.setHealthScaled(false);
|
||||
player.setHealthScale(player.getMaxHealth());
|
||||
}
|
||||
player.setHealthScale(scale);
|
||||
player.setHealthScaled(isHealthScaled);
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.WARNING, "Error setting %s's health scale to %s".formatted(player.getName(), healthScale), e);
|
||||
plugin.log(Level.WARNING, "Error setting %s's health scale to %s".formatted(player.getName(), scale), e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import de.tr7zw.changeme.nbtapi.utils.DataFixerUtil;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import net.william278.desertwell.util.Version;
|
||||
import net.william278.husksync.BukkitHuskSync;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.adapter.Adaptable;
|
||||
import net.william278.husksync.api.HuskSyncAPI;
|
||||
@@ -41,6 +42,8 @@ import org.jetbrains.annotations.Nullable;
|
||||
import java.util.List;
|
||||
|
||||
import static net.william278.husksync.data.BukkitData.Items.Inventory.INVENTORY_SLOT_COUNT;
|
||||
import static net.william278.husksync.data.Data.Items.Inventory.HELD_ITEM_SLOT_TAG;
|
||||
import static net.william278.husksync.data.Data.Items.Inventory.ITEMS_TAG;
|
||||
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class BukkitSerializer {
|
||||
@@ -60,8 +63,6 @@ public class BukkitSerializer {
|
||||
|
||||
public static class Inventory extends BukkitSerializer implements Serializer<BukkitData.Items.Inventory>,
|
||||
ItemDeserializer {
|
||||
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);
|
||||
@@ -74,7 +75,7 @@ public class BukkitSerializer {
|
||||
final ReadWriteNBT items = root.hasTag(ITEMS_TAG) ? root.getCompound(ITEMS_TAG) : null;
|
||||
return BukkitData.Items.Inventory.from(
|
||||
items != null ? getItems(items, dataMcVersion) : new ItemStack[INVENTORY_SLOT_COUNT],
|
||||
root.getInteger(HELD_ITEM_SLOT_TAG)
|
||||
root.hasTag(HELD_ITEM_SLOT_TAG) ? root.getInteger(HELD_ITEM_SLOT_TAG) : 0
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,15 +127,15 @@ public class BukkitSerializer {
|
||||
@Nullable
|
||||
default ItemStack[] getItems(@NotNull ReadWriteNBT tag, @NotNull Version mcVersion) {
|
||||
if (mcVersion.compareTo(getPlugin().getMinecraftVersion()) < 0) {
|
||||
return upgradeItemStack((NBTCompound) tag, mcVersion);
|
||||
return upgradeItemStacks((NBTCompound) tag, mcVersion);
|
||||
}
|
||||
return NBT.itemStackArrayFromNBT(tag);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private ItemStack @NotNull [] upgradeItemStack(@NotNull NBTCompound compound, @NotNull Version mcVersion) {
|
||||
final ReadWriteNBTCompoundList items = compound.getCompoundList("items");
|
||||
final ItemStack[] itemStacks = new ItemStack[compound.getInteger("size")];
|
||||
private ItemStack @NotNull [] upgradeItemStacks(@NotNull NBTCompound itemsNbt, @NotNull Version mcVersion) {
|
||||
final ReadWriteNBTCompoundList items = itemsNbt.getCompoundList("items");
|
||||
final ItemStack[] itemStacks = new ItemStack[itemsNbt.getInteger("size")];
|
||||
for (int i = 0; i < items.size(); i++) {
|
||||
if (items.get(i) == null) {
|
||||
itemStacks[i] = new ItemStack(Material.AIR);
|
||||
@@ -152,19 +153,11 @@ public class BukkitSerializer {
|
||||
@NotNull
|
||||
private ReadWriteNBT upgradeItemData(@NotNull ReadWriteNBT tag, @NotNull Version mcVersion)
|
||||
throws NoSuchFieldException, IllegalAccessException {
|
||||
return DataFixerUtil.fixUpItemData(tag, getDataVersion(mcVersion), DataFixerUtil.getCurrentVersion());
|
||||
}
|
||||
|
||||
private int getDataVersion(@NotNull Version mcVersion) {
|
||||
return switch (mcVersion.toStringWithoutMetadata()) {
|
||||
case "1.16", "1.16.1", "1.16.2", "1.16.3", "1.16.4", "1.16.5" -> DataFixerUtil.VERSION1_16_5;
|
||||
case "1.17", "1.17.1" -> DataFixerUtil.VERSION1_17_1;
|
||||
case "1.18", "1.18.1", "1.18.2" -> DataFixerUtil.VERSION1_18_2;
|
||||
case "1.19", "1.19.1", "1.19.2" -> DataFixerUtil.VERSION1_19_2;
|
||||
case "1.20", "1.20.1", "1.20.2" -> DataFixerUtil.VERSION1_20_2;
|
||||
case "1.20.3", "1.20.4" -> DataFixerUtil.VERSION1_20_4;
|
||||
default -> DataFixerUtil.getCurrentVersion();
|
||||
};
|
||||
return DataFixerUtil.fixUpItemData(
|
||||
tag,
|
||||
getPlugin().getDataVersion(mcVersion),
|
||||
DataFixerUtil.getCurrentVersion()
|
||||
);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@@ -226,7 +219,7 @@ public class BukkitSerializer {
|
||||
|
||||
@Override
|
||||
public BukkitData.PersistentData deserialize(@NotNull String serialized) throws DeserializationException {
|
||||
return BukkitData.PersistentData.from(new NBTContainer(serialized));
|
||||
return BukkitData.PersistentData.from((NBTContainer) NBT.parseNBT(serialized));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@@ -237,24 +230,19 @@ public class BukkitSerializer {
|
||||
|
||||
}
|
||||
|
||||
public static class Json<T extends Data & Adaptable> extends BukkitSerializer implements Serializer<T> {
|
||||
/**
|
||||
* @deprecated Use {@link Serializer.Json} in the common module instead
|
||||
*/
|
||||
@Deprecated(since = "2.6")
|
||||
public class Json<T extends Data & Adaptable> extends Serializer.Json<T> {
|
||||
|
||||
private final Class<T> type;
|
||||
|
||||
public 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);
|
||||
public Json(@NotNull HuskSync plugin, @NotNull Class<T> type) {
|
||||
super(plugin, type);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public String serialize(@NotNull T element) throws SerializationException {
|
||||
return plugin.getDataAdapter().toJson(element);
|
||||
public BukkitHuskSync getPlugin() {
|
||||
return (BukkitHuskSync) plugin;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
package net.william278.husksync.data;
|
||||
|
||||
import net.william278.husksync.BukkitHuskSync;
|
||||
import net.william278.husksync.util.BukkitMapPersister;
|
||||
import net.william278.husksync.maps.BukkitMapHandler;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.PlayerInventory;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
@@ -31,7 +31,11 @@ public interface BukkitUserDataHolder extends UserDataHolder {
|
||||
|
||||
@Override
|
||||
default Optional<? extends Data> getData(@NotNull Identifier id) {
|
||||
if (!id.isCustom()) {
|
||||
if (id.isCustom()) {
|
||||
return Optional.ofNullable(getCustomDataStore().get(id));
|
||||
}
|
||||
|
||||
try {
|
||||
return switch (id.getKeyValue()) {
|
||||
case "inventory" -> getInventory();
|
||||
case "ender_chest" -> getEnderChest();
|
||||
@@ -48,8 +52,10 @@ public interface BukkitUserDataHolder extends UserDataHolder {
|
||||
case "persistent_data" -> getPersistentData();
|
||||
default -> throw new IllegalStateException(String.format("Unexpected data type: %s", id));
|
||||
};
|
||||
} catch (Throwable e) {
|
||||
getPlugin().debug("Failed to get data for key: " + id.asMinimalString(), e);
|
||||
return Optional.empty();
|
||||
}
|
||||
return Optional.ofNullable(getCustomDataStore().get(id));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -67,9 +73,9 @@ public interface BukkitUserDataHolder extends UserDataHolder {
|
||||
.isSyncDeadPlayersChangingServer())) {
|
||||
return Optional.of(BukkitData.Items.Inventory.empty());
|
||||
}
|
||||
final PlayerInventory inventory = getBukkitPlayer().getInventory();
|
||||
final PlayerInventory inventory = getPlayer().getInventory();
|
||||
return Optional.of(BukkitData.Items.Inventory.from(
|
||||
getMapPersister().persistLockedMaps(inventory.getContents(), getBukkitPlayer()),
|
||||
getMapPersister().persistLockedMaps(inventory.getContents(), getPlayer()),
|
||||
inventory.getHeldItemSlot()
|
||||
));
|
||||
}
|
||||
@@ -78,83 +84,92 @@ public interface BukkitUserDataHolder extends UserDataHolder {
|
||||
@Override
|
||||
default Optional<Data.Items.EnderChest> getEnderChest() {
|
||||
return Optional.of(BukkitData.Items.EnderChest.adapt(
|
||||
getMapPersister().persistLockedMaps(getBukkitPlayer().getEnderChest().getContents(), getBukkitPlayer())
|
||||
getMapPersister().persistLockedMaps(getPlayer().getEnderChest().getContents(), getPlayer())
|
||||
));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.PotionEffects> getPotionEffects() {
|
||||
return Optional.of(BukkitData.PotionEffects.from(getBukkitPlayer().getActivePotionEffects()));
|
||||
return Optional.of(BukkitData.PotionEffects.from(getPlayer().getActivePotionEffects()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.Advancements> getAdvancements() {
|
||||
return Optional.of(BukkitData.Advancements.adapt(getBukkitPlayer()));
|
||||
return Optional.of(BukkitData.Advancements.adapt(getPlayer()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.Location> getLocation() {
|
||||
return Optional.of(BukkitData.Location.adapt(getBukkitPlayer().getLocation()));
|
||||
return Optional.of(BukkitData.Location.adapt(getPlayer().getLocation()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.Statistics> getStatistics() {
|
||||
return Optional.of(BukkitData.Statistics.adapt(getBukkitPlayer()));
|
||||
return Optional.of(BukkitData.Statistics.adapt(getPlayer()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.Health> getHealth() {
|
||||
return Optional.of(BukkitData.Health.adapt(getBukkitPlayer()));
|
||||
return Optional.of(BukkitData.Health.adapt(getPlayer()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.Hunger> getHunger() {
|
||||
return Optional.of(BukkitData.Hunger.adapt(getBukkitPlayer()));
|
||||
return Optional.of(BukkitData.Hunger.adapt(getPlayer()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.Attributes> getAttributes() {
|
||||
return Optional.of(BukkitData.Attributes.adapt(getBukkitPlayer(), getPlugin()));
|
||||
return Optional.of(BukkitData.Attributes.adapt(getPlayer(), getPlugin()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.Experience> getExperience() {
|
||||
return Optional.of(BukkitData.Experience.adapt(getBukkitPlayer()));
|
||||
return Optional.of(BukkitData.Experience.adapt(getPlayer()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.GameMode> getGameMode() {
|
||||
return Optional.of(BukkitData.GameMode.adapt(getBukkitPlayer()));
|
||||
return Optional.of(BukkitData.GameMode.adapt(getPlayer()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.FlightStatus> getFlightStatus() {
|
||||
return Optional.of(BukkitData.FlightStatus.adapt(getBukkitPlayer()));
|
||||
return Optional.of(BukkitData.FlightStatus.adapt(getPlayer()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
default Optional<Data.PersistentData> getPersistentData() {
|
||||
return Optional.of(BukkitData.PersistentData.adapt(getBukkitPlayer().getPersistentDataContainer()));
|
||||
return Optional.of(BukkitData.PersistentData.adapt(getPlayer().getPersistentDataContainer()));
|
||||
}
|
||||
|
||||
boolean isDead();
|
||||
|
||||
@NotNull
|
||||
Player getBukkitPlayer();
|
||||
Player getPlayer();
|
||||
|
||||
/**
|
||||
* @deprecated Use {@link #getPlayer()} instead
|
||||
*/
|
||||
@Deprecated(since = "3.6")
|
||||
@NotNull
|
||||
default Player getBukkitPlayer() {
|
||||
return getPlayer();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
default BukkitMapPersister getMapPersister() {
|
||||
default BukkitMapHandler getMapPersister() {
|
||||
return (BukkitHuskSync) getPlugin();
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
|
||||
package net.william278.husksync.listener;
|
||||
|
||||
import lombok.Getter;
|
||||
import net.william278.husksync.BukkitHuskSync;
|
||||
import net.william278.husksync.data.BukkitData;
|
||||
import net.william278.husksync.user.BukkitUser;
|
||||
@@ -36,6 +37,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Getter
|
||||
public class BukkitEventListener extends EventListener implements BukkitJoinEventListener, BukkitQuitEventListener,
|
||||
BukkitDeathEventListener, Listener {
|
||||
|
||||
@@ -133,7 +135,7 @@ public class BukkitEventListener extends EventListener implements BukkitJoinEven
|
||||
@EventHandler(ignoreCancelled = true)
|
||||
public void onMapInitialize(@NotNull MapInitializeEvent event) {
|
||||
if (plugin.getSettings().getSynchronization().isPersistLockedMaps() && event.getMap().isLocked()) {
|
||||
getPlugin().runAsync(() -> ((BukkitHuskSync) plugin).renderMapFromFile(event.getMap()));
|
||||
getPlugin().runAsync(() -> ((BukkitHuskSync) plugin).renderInitializingLockedMap(event.getMap()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -40,7 +40,6 @@ import org.bukkit.event.player.PlayerInteractEntityEvent;
|
||||
import org.bukkit.event.player.PlayerInteractEvent;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
|
||||
@Getter
|
||||
@@ -124,7 +123,6 @@ public class BukkitLockedEventListener implements LockedHandler, Listener {
|
||||
private void cancelPlayerEvent(@NotNull UUID uuid, @NotNull Cancellable event) {
|
||||
if (cancelPlayerEvent(uuid)) {
|
||||
event.setCancelled(true);
|
||||
plugin.debug("Cancelled event " + event.getClass().getSimpleName() + " from " + Objects.requireNonNull(plugin.getServer().getPlayer(uuid)).getName());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ import com.github.retrooper.packetevents.PacketEvents;
|
||||
import com.github.retrooper.packetevents.event.PacketListenerAbstract;
|
||||
import com.github.retrooper.packetevents.event.PacketListenerPriority;
|
||||
import com.github.retrooper.packetevents.event.PacketReceiveEvent;
|
||||
import com.github.retrooper.packetevents.event.PacketSendEvent;
|
||||
import com.github.retrooper.packetevents.protocol.packettype.PacketType;
|
||||
import com.google.common.collect.Sets;
|
||||
import io.github.retrooper.packetevents.factory.spigot.SpigotPacketEventsBuilder;
|
||||
@@ -39,12 +40,11 @@ public class BukkitPacketEventsLockedPacketListener extends BukkitLockedEventLis
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("UnstableApiUsage")
|
||||
public void onLoad() {
|
||||
super.onLoad();
|
||||
PacketEvents.setAPI(SpigotPacketEventsBuilder.build(getPlugin()));
|
||||
PacketEvents.getAPI().getSettings().reEncodeByDefault(false)
|
||||
.checkForUpdates(false)
|
||||
.bStats(true);
|
||||
PacketEvents.getAPI().getSettings().reEncodeByDefault(false).checkForUpdates(false);
|
||||
PacketEvents.getAPI().load();
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ public class BukkitPacketEventsLockedPacketListener extends BukkitLockedEventLis
|
||||
|
||||
private static final Set<PacketType.Play.Client> ALLOWED_PACKETS = Set.of(
|
||||
PacketType.Play.Client.KEEP_ALIVE, PacketType.Play.Client.PONG, PacketType.Play.Client.PLUGIN_MESSAGE, // Connection packets
|
||||
PacketType.Play.Client.PLAYER_LOADED, PacketType.Play.Client.CLIENT_TICK_END, // Connection packets
|
||||
PacketType.Play.Client.CHAT_MESSAGE, PacketType.Play.Client.CHAT_COMMAND, PacketType.Play.Client.CHAT_SESSION_UPDATE, // Chat / command packets
|
||||
PacketType.Play.Client.PLAYER_POSITION, PacketType.Play.Client.PLAYER_POSITION_AND_ROTATION, PacketType.Play.Client.PLAYER_ROTATION, // Movement packets
|
||||
PacketType.Play.Client.HELD_ITEM_CHANGE, PacketType.Play.Client.ANIMATION, PacketType.Play.Client.TELEPORT_CONFIRM, // Animation packets
|
||||
@@ -78,7 +79,20 @@ public class BukkitPacketEventsLockedPacketListener extends BukkitLockedEventLis
|
||||
|
||||
@Override
|
||||
public void onPacketReceive(PacketReceiveEvent event) {
|
||||
if(!(event.getPacketType() instanceof PacketType.Play.Client client)) {
|
||||
if (!(event.getPacketType() instanceof PacketType.Play.Client client)) {
|
||||
return;
|
||||
}
|
||||
if (!CANCEL_PACKETS.contains(client)) {
|
||||
return;
|
||||
}
|
||||
if (listener.cancelPlayerEvent(event.getUser().getUUID())) {
|
||||
event.setCancelled(true);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPacketSend(PacketSendEvent event) {
|
||||
if (!(event.getPacketType() instanceof PacketType.Play.Client client)) {
|
||||
return;
|
||||
}
|
||||
if (!CANCEL_PACKETS.contains(client)) {
|
||||
|
||||
@@ -49,7 +49,7 @@ public class BukkitProtocolLibLockedPacketListener extends BukkitLockedEventList
|
||||
|
||||
private static class PlayerPacketAdapter extends PacketAdapter {
|
||||
|
||||
// Packets we want the player to still be able to send/receiver to/from the server
|
||||
// Packets we want the player to still be able to send/receiver to/from the server - //todo update 1.21.4
|
||||
private static final Set<PacketType> ALLOWED_PACKETS = Set.of(
|
||||
Client.KEEP_ALIVE, Client.PONG, Client.CUSTOM_PAYLOAD, // Connection packets
|
||||
Client.CHAT_COMMAND, Client.CLIENT_COMMAND, Client.CHAT, Client.CHAT_SESSION_UPDATE, // Chat / command packets
|
||||
|
||||
@@ -43,6 +43,13 @@ public class PaperEventListener extends BukkitEventListener {
|
||||
super(plugin);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("RedundantMethodOverride")
|
||||
public void onEnable() {
|
||||
getPlugin().getServer().getPluginManager().registerEvents(this, getPlugin());
|
||||
lockedHandler.onEnable();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handlePlayerDeath(@NotNull PlayerDeathEvent event) {
|
||||
// If the player is locked or the plugin disabling, clear their drops
|
||||
@@ -0,0 +1,620 @@
|
||||
/*
|
||||
* 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.maps;
|
||||
|
||||
import com.google.common.collect.Lists;
|
||||
import de.tr7zw.changeme.nbtapi.NBT;
|
||||
import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT;
|
||||
import de.tr7zw.changeme.nbtapi.iface.ReadableItemNBT;
|
||||
import de.tr7zw.changeme.nbtapi.iface.ReadableNBT;
|
||||
import net.william278.husksync.BukkitHuskSync;
|
||||
import net.william278.husksync.redis.RedisManager;
|
||||
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.block.Container;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.bukkit.inventory.meta.BlockStateMeta;
|
||||
import org.bukkit.inventory.meta.BundleMeta;
|
||||
import org.bukkit.inventory.meta.MapMeta;
|
||||
import org.bukkit.map.*;
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.function.Function;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public interface BukkitMapHandler {
|
||||
|
||||
// The map used to store HuskSync data in ItemStack NBT
|
||||
String MAP_DATA_KEY = "husksync:persisted_locked_map";
|
||||
// The legacy map key used to store pixel data (3.7.3 and below)
|
||||
String MAP_LEGACY_PIXEL_DATA_KEY = "husksync:canvas_data";
|
||||
// Name of server the map originates from
|
||||
String MAP_ORIGIN_KEY = "origin";
|
||||
// Original map id
|
||||
String MAP_ID_KEY = "id";
|
||||
|
||||
/**
|
||||
* 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().getSynchronization().isPersistLockedMaps()) {
|
||||
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
|
||||
*/
|
||||
@Nullable
|
||||
default ItemStack @NotNull [] setMapViews(@Nullable ItemStack @NotNull [] items) {
|
||||
if (!getPlugin().getSettings().getSynchronization().isPersistLockedMaps()) {
|
||||
return items;
|
||||
}
|
||||
return forEachMap(items, this::applyMapView);
|
||||
}
|
||||
|
||||
// Perform an operation on each map in an array of ItemStacks
|
||||
@NotNull
|
||||
private ItemStack[] forEachMap(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);
|
||||
} else if (item.getItemMeta() instanceof BlockStateMeta b && b.getBlockState() instanceof Container box
|
||||
&& !box.getInventory().isEmpty()) {
|
||||
forEachMap(box.getInventory().getContents(), function);
|
||||
b.setBlockState(box);
|
||||
item.setItemMeta(b);
|
||||
} else if (item.getItemMeta() instanceof BundleMeta bundle && bundle.hasItems()) {
|
||||
bundle.setItems(List.of(forEachMap(bundle.getItems().toArray(ItemStack[]::new), function)));
|
||||
item.setItemMeta(bundle);
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
private void writeMapData(@NotNull String serverName, int mapId, MapData data) {
|
||||
final byte[] dataBytes = getPlugin().getDataAdapter().toBytes(new AdaptableMapData(data));
|
||||
getRedisManager().setMapData(serverName, mapId, dataBytes);
|
||||
getPlugin().getDatabase().saveMapData(serverName, mapId, dataBytes);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Blocking
|
||||
private MapData readMapData(@NotNull String serverName, int mapId) {
|
||||
final byte[] readData = fetchMapData(serverName, mapId);
|
||||
if (readData == null) {
|
||||
return null;
|
||||
}
|
||||
return deserializeMapData(readData);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Blocking
|
||||
private byte[] fetchMapData(@NotNull String serverName, int mapId) {
|
||||
return fetchMapData(serverName, mapId, true);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Blocking
|
||||
private byte[] fetchMapData(@NotNull String serverName, int mapId, boolean doReverseLookup) {
|
||||
// Read from Redis cache
|
||||
final byte[] redisData = getRedisManager().getMapData(serverName, mapId);
|
||||
if (redisData != null) {
|
||||
return redisData;
|
||||
}
|
||||
|
||||
// Read from database and set to Redis
|
||||
final byte[] databaseData = getPlugin().getDatabase().getMapData(serverName, mapId);
|
||||
if (databaseData != null) {
|
||||
getRedisManager().setMapData(serverName, mapId, databaseData);
|
||||
return databaseData;
|
||||
}
|
||||
|
||||
// Otherwise, lookup a reverse map binding
|
||||
if (doReverseLookup) {
|
||||
return fetchReversedMapData(serverName, mapId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private byte[] fetchReversedMapData(@NotNull String serverName, int mapId) {
|
||||
// Lookup binding from Redis cache, then fetch data if found
|
||||
Map.Entry<String, Integer> binding = getRedisManager().getReversedMapBound(serverName, mapId);
|
||||
if (binding != null) {
|
||||
return fetchMapData(binding.getKey(), binding.getValue(), false);
|
||||
}
|
||||
|
||||
// Lookup binding from database, then set to Redis & fetch data if found
|
||||
binding = getPlugin().getDatabase().getMapBinding(serverName, mapId);
|
||||
if (binding != null) {
|
||||
getRedisManager().bindMapIds(binding.getKey(), binding.getValue(), serverName, mapId);
|
||||
return fetchMapData(binding.getKey(), binding.getValue(), false);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Nullable
|
||||
private MapData deserializeMapData(byte @NotNull [] data) {
|
||||
try {
|
||||
return getPlugin().getDataAdapter().fromBytes(data, AdaptableMapData.class)
|
||||
.getData(getPlugin().getDataVersion(getPlugin().getMinecraftVersion()));
|
||||
} catch (IOException e) {
|
||||
getPlugin().log(Level.WARNING, "Failed to deserialize map data", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the bound map ID
|
||||
private int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) {
|
||||
// Get the map ID from Redis, if set
|
||||
final Optional<Integer> redisId = getRedisManager().getBoundMapId(fromServerName, fromMapId, toServerName);
|
||||
if (redisId.isPresent()) {
|
||||
return redisId.get();
|
||||
}
|
||||
|
||||
// Get from the database; if found, set to Redis
|
||||
final int result = getPlugin().getDatabase().getBoundMapId(fromServerName, fromMapId, toServerName);
|
||||
if (result != -1) {
|
||||
getPlugin().getRedisManager().bindMapIds(fromServerName, fromMapId, toServerName, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@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 int dataVersion = getPlugin().getDataVersion(getPlugin().getMinecraftVersion());
|
||||
final PersistentMapCanvas canvas = new PersistentMapCanvas(view, dataVersion);
|
||||
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 serverName = getPlugin().getServerName();
|
||||
mapData.setString(MAP_ORIGIN_KEY, serverName);
|
||||
mapData.setInteger(MAP_ID_KEY, meta.getMapId());
|
||||
if (readMapData(serverName, meta.getMapId()) == null) {
|
||||
writeMapData(serverName, meta.getMapId(), canvas.extractMapData());
|
||||
}
|
||||
getPlugin().debug(String.format("Saved data for locked map (#%s, server: %s)", view.getId(), serverName));
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
@SuppressWarnings("deprecation")
|
||||
@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);
|
||||
if (mapData == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Server the map was originally created on, and the current server. If they match, isOrigin is true.
|
||||
final String originServer = mapData.getString(MAP_ORIGIN_KEY);
|
||||
final String currentServer = getPlugin().getServerName();
|
||||
final boolean isOrigin = currentServer.equals(originServer);
|
||||
|
||||
// Determine the map's ID on its origin server, and the new ID it should be bound to here.
|
||||
// Then, update the map item / data accordingly (re-rendering and caching the map if needed)
|
||||
final int originalId = mapData.getInteger(MAP_ID_KEY);
|
||||
int newId = isOrigin ? originalId : getBoundMapId(originServer, originalId, currentServer);
|
||||
if (newId != -1) {
|
||||
handleBoundMap(meta, nbt, originServer, originalId, newId, isOrigin);
|
||||
} else {
|
||||
handleUnboundMap(meta, nbt, originServer, originalId, currentServer);
|
||||
}
|
||||
|
||||
map.setItemMeta(meta);
|
||||
});
|
||||
return map;
|
||||
}
|
||||
|
||||
private void handleBoundMap(@NotNull MapMeta meta, @NotNull ReadableItemNBT nbt, @NotNull String originServer,
|
||||
int originalId, int newId, boolean isOrigin) {
|
||||
MapView view = Bukkit.getMap(newId);
|
||||
if (isOrigin && view != null) {
|
||||
meta.setMapView(view);
|
||||
getPlugin().debug("Map ID set to original ID #%s".formatted(newId));
|
||||
return;
|
||||
}
|
||||
|
||||
Optional<MapView> optionalView = getMapView(newId);
|
||||
if (optionalView.isPresent()) {
|
||||
meta.setMapView(optionalView.get());
|
||||
getPlugin().debug("Map ID set to #%s".formatted(newId));
|
||||
return;
|
||||
}
|
||||
|
||||
getPlugin().debug("Deserializing map data from NBT and generating view...");
|
||||
MapData mapData = readMapData(originServer, originalId);
|
||||
if (mapData == null && nbt.hasTag(MAP_LEGACY_PIXEL_DATA_KEY)) {
|
||||
mapData = readLegacyMapItemData(nbt);
|
||||
}
|
||||
|
||||
if (mapData == null) {
|
||||
getPlugin().debug("Read pixel data was not found in database, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
MapView newView = view != null ? view : Bukkit.createMap(getDefaultMapWorld());
|
||||
generateRenderedMap(mapData, newView);
|
||||
meta.setMapView(newView);
|
||||
}
|
||||
|
||||
private void handleUnboundMap(@NotNull MapMeta meta, @NotNull ReadableItemNBT nbt, @NotNull String originServer,
|
||||
int originalId, @NotNull String currentServer) {
|
||||
getPlugin().debug("Deserializing map data from NBT and generating view...");
|
||||
MapData mapData = readMapData(originServer, originalId);
|
||||
if (mapData == null && nbt.hasTag(MAP_LEGACY_PIXEL_DATA_KEY)) {
|
||||
mapData = readLegacyMapItemData(nbt);
|
||||
}
|
||||
|
||||
if (mapData == null) {
|
||||
getPlugin().debug("Read pixel data was not found in database, skipping...");
|
||||
return;
|
||||
}
|
||||
|
||||
final MapView view = generateRenderedMap(Objects.requireNonNull(mapData, "Pixel data null!"));
|
||||
meta.setMapView(view);
|
||||
|
||||
final int id = view.getId();
|
||||
getRedisManager().bindMapIds(originServer, originalId, currentServer, id);
|
||||
getPlugin().getDatabase().setMapBinding(originServer, originalId, currentServer, id);
|
||||
|
||||
getPlugin().debug("Bound map to view (#%s) on server %s".formatted(id, currentServer));
|
||||
}
|
||||
|
||||
// Render a persisted locked map that is initializing (i.e. in an item frame)
|
||||
default void renderInitializingLockedMap(@NotNull MapView view) {
|
||||
if (view.isVirtual()) {
|
||||
return;
|
||||
}
|
||||
final Optional<MapView> optionalView = getMapView(view.getId());
|
||||
if (optionalView.isPresent()) {
|
||||
view.getRenderers().clear();
|
||||
view.getRenderers().addAll(optionalView.get().getRenderers());
|
||||
view.setLocked(true);
|
||||
view.setScale(MapView.Scale.CLOSEST);
|
||||
view.setTrackingPosition(false);
|
||||
view.setUnlimitedTracking(false);
|
||||
return;
|
||||
}
|
||||
|
||||
MapData data = readMapData(getPlugin().getServerName(), view.getId());
|
||||
if (data == null) {
|
||||
data = readLegacyMapFileData(view.getId());
|
||||
}
|
||||
|
||||
if (data == null) {
|
||||
World world = view.getWorld() == null ? getDefaultMapWorld() : view.getWorld();
|
||||
getPlugin().debug("Not rendering map: no data in DB for world %s, map #%s."
|
||||
.formatted(world.getName(), view.getId()));
|
||||
return;
|
||||
}
|
||||
renderMapView(view, data);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private MapView generateRenderedMap(@NotNull MapData canvasData) {
|
||||
return generateRenderedMap(canvasData, Bukkit.createMap(getDefaultMapWorld()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private MapView generateRenderedMap(@NotNull MapData canvasData, @NotNull MapView view) {
|
||||
renderMapView(view, canvasData);
|
||||
return view;
|
||||
}
|
||||
|
||||
private void renderMapView(@NotNull MapView view, @NotNull MapData canvasData) {
|
||||
view.getRenderers().clear();
|
||||
view.addRenderer(new PersistentMapRenderer(canvasData));
|
||||
view.setLocked(true);
|
||||
view.setScale(MapView.Scale.CLOSEST);
|
||||
view.setTrackingPosition(false);
|
||||
view.setUnlimitedTracking(false);
|
||||
setMapView(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}
|
||||
*/
|
||||
@SuppressWarnings("deprecation")
|
||||
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();
|
||||
while (cursors.size() > 0) {
|
||||
cursors.removeCursor(cursors.getCursor(0));
|
||||
}
|
||||
|
||||
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) 8, // Always rotate banners upright
|
||||
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()
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy - read maps from item stacks
|
||||
@Nullable
|
||||
@Blocking
|
||||
private MapData readLegacyMapItemData(@NotNull ReadableItemNBT nbt) {
|
||||
final int dataVer = getPlugin().getDataVersion(getPlugin().getMinecraftVersion());
|
||||
try {
|
||||
return MapData.fromByteArray(dataVer,
|
||||
Objects.requireNonNull(nbt.getByteArray(MAP_LEGACY_PIXEL_DATA_KEY)));
|
||||
} catch (IOException e) {
|
||||
getPlugin().log(Level.WARNING, "Failed to read legacy map data", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy - read maps from files
|
||||
@Nullable
|
||||
private MapData readLegacyMapFileData(int mapId) {
|
||||
final Path path = getPlugin().getDataFolder().toPath().resolve("maps").resolve(mapId + ".dat");
|
||||
final File file = path.toFile();
|
||||
if (!file.exists()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return MapData.fromNbt(file);
|
||||
} catch (IOException e) {
|
||||
getPlugin().log(Level.WARNING, "Failed to read legacy map file", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A {@link MapCanvas} implementation used for pre-rendering maps to be converted into {@link MapData}
|
||||
*/
|
||||
@SuppressWarnings({"deprecation", "removal"})
|
||||
class PersistentMapCanvas implements MapCanvas {
|
||||
|
||||
private static final String BANNER_PREFIX = "banner_";
|
||||
|
||||
private final int mapDataVersion;
|
||||
private final MapView mapView;
|
||||
private final int[][] pixels = new int[128][128];
|
||||
private MapCursorCollection cursors;
|
||||
|
||||
private PersistentMapCanvas(@NotNull MapView mapView, int mapDataVersion) {
|
||||
this.mapDataVersion = mapDataVersion;
|
||||
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
|
||||
@Deprecated
|
||||
public void setPixel(int x, int y, byte color) {
|
||||
pixels[x][y] = color;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public byte getPixel(int x, int y) {
|
||||
return (byte) pixels[x][y];
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated
|
||||
public byte getBasePixel(int x, int y) {
|
||||
return (byte) pixels[x][y];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setPixelColor(int x, int y, @Nullable Color color) {
|
||||
pixels[x][y] = color == null ? -1 : MapPalette.matchColor(color);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@Override
|
||||
public Color getPixelColor(int x, int y) {
|
||||
return MapPalette.getColor((byte) pixels[x][y]);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public Color getBasePixelColor(int x, int y) {
|
||||
return MapPalette.getColor((byte) pixels[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 = Lists.newArrayList();
|
||||
for (int i = 0; i < getCursors().size(); i++) {
|
||||
final MapCursor cursor = getCursors().getCursor(i);
|
||||
final String type = cursor.getType().getKey().getKey();
|
||||
if (type.startsWith(BANNER_PREFIX)) {
|
||||
banners.add(new MapBanner(
|
||||
type.replaceAll(BANNER_PREFIX, ""),
|
||||
cursor.getCaption() == null ? "" : cursor.getCaption(),
|
||||
cursor.getX(),
|
||||
mapView.getWorld() != null ? mapView.getWorld().getSeaLevel() : 128,
|
||||
cursor.getY()
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
return MapData.fromPixels(mapDataVersion, pixels, getDimension(), (byte) 0, banners, List.of());
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
Map<Integer, MapView> getMapViews();
|
||||
|
||||
@ApiStatus.Internal
|
||||
RedisManager getRedisManager();
|
||||
|
||||
@ApiStatus.Internal
|
||||
@NotNull
|
||||
BukkitHuskSync getPlugin();
|
||||
|
||||
}
|
||||
@@ -146,7 +146,7 @@ public class LegacyMigrator extends Migrator {
|
||||
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());
|
||||
plugin.log(Level.SEVERE, "Failed to migrate legacy data for " + data.user().getName() + ": " + e.getMessage());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -204,10 +204,10 @@ public class LegacyMigrator extends Migrator {
|
||||
}) {
|
||||
plugin.log(Level.INFO, getHelpMenu());
|
||||
plugin.log(Level.INFO, "Successfully set " + args[0] + " to " +
|
||||
obfuscateDataString(args[1]));
|
||||
obfuscateDataString(args[1]));
|
||||
} else {
|
||||
plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " +
|
||||
obfuscateDataString(args[1]) + " (is it a valid option?)");
|
||||
obfuscateDataString(args[1]) + " (is it a valid option?)");
|
||||
}
|
||||
} else {
|
||||
plugin.log(Level.INFO, getHelpMenu());
|
||||
@@ -330,7 +330,7 @@ public class LegacyMigrator extends Migrator {
|
||||
))
|
||||
|
||||
// Health, hunger, experience & game mode
|
||||
.health(BukkitData.Health.from(health, healthScale))
|
||||
.health(BukkitData.Health.from(health, healthScale, false))
|
||||
.hunger(BukkitData.Hunger.from(hunger, saturation, saturationExhaustion))
|
||||
.experience(BukkitData.Experience.from(totalExp, expLevel, expProgress))
|
||||
.gameMode(BukkitData.GameMode.from(gameMode))
|
||||
|
||||
@@ -201,10 +201,10 @@ public class MpdbMigrator extends Migrator {
|
||||
}) {
|
||||
plugin.log(Level.INFO, getHelpMenu());
|
||||
plugin.log(Level.INFO, "Successfully set " + args[0] + " to " +
|
||||
obfuscateDataString(args[1]));
|
||||
obfuscateDataString(args[1]));
|
||||
} else {
|
||||
plugin.log(Level.INFO, "Invalid operation, could not set " + args[0] + " to " +
|
||||
obfuscateDataString(args[1]) + " (is it a valid option?)");
|
||||
obfuscateDataString(args[1]) + " (is it a valid option?)");
|
||||
}
|
||||
} else {
|
||||
plugin.log(Level.INFO, getHelpMenu());
|
||||
@@ -255,7 +255,7 @@ public class MpdbMigrator extends Migrator {
|
||||
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")
|
||||
(e.g.: "husksync migrate set mpdb host 1.2.3.4")
|
||||
|
||||
STEP 3] HuskSync will migrate data into the database
|
||||
tables configures in the config.yml file of this
|
||||
@@ -263,7 +263,7 @@ public class MpdbMigrator extends Migrator {
|
||||
before proceeding.
|
||||
|
||||
STEP 4] To start the migration, please run:
|
||||
"husksync migrate mpdb start"
|
||||
"husksync migrate start mpdb"
|
||||
|
||||
NOTE: This migrator currently WORKS WITH MPDB version
|
||||
v4.9.2 and below!
|
||||
|
||||
@@ -23,14 +23,10 @@ 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.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;
|
||||
@@ -40,8 +36,6 @@ import java.util.Arrays;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static net.william278.husksync.util.BukkitKeyedAdapter.matchMaterial;
|
||||
|
||||
/**
|
||||
* Bukkit platform implementation of an {@link OnlineUser}
|
||||
*/
|
||||
@@ -62,37 +56,19 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
|
||||
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();
|
||||
@Override
|
||||
public boolean hasDisconnected() {
|
||||
return getPlugin().getDisconnectingPlayers().contains(getUuid())
|
||||
|| player == null || !player.isOnline();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Deprecated(since = "3.6.7")
|
||||
public void sendToast(@NotNull MineDown title, @NotNull MineDown description,
|
||||
@NotNull String iconMaterial, @NotNull String backgroundType) {
|
||||
try {
|
||||
final 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);
|
||||
}
|
||||
plugin.log(Level.WARNING, "Toast notifications are deprecated. " +
|
||||
"Please change your notification display slot to CHAT, ACTION_BAR or NONE.");
|
||||
this.sendActionBar(title);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -103,7 +79,7 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
|
||||
if (!editable) {
|
||||
builder.disableAllInteractions();
|
||||
}
|
||||
final StorageGui gui = builder.enableOtherActions()
|
||||
final StorageGui gui = builder
|
||||
.apply(a -> a.getInventory().setContents(contents))
|
||||
.title(title.toComponent()).create();
|
||||
gui.setCloseGuiAction((close) -> onClose.accept(BukkitData.Items.ItemArray.adapt(
|
||||
@@ -132,9 +108,14 @@ public class BukkitUser extends OnlineUser implements BukkitUserDataHolder {
|
||||
return player.hasMetadata("NPC");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the Bukkit {@link Player} instance of this user
|
||||
*
|
||||
* @return the {@link Player} instance
|
||||
* @since 3.6
|
||||
*/
|
||||
@NotNull
|
||||
@Override
|
||||
public Player getBukkitPlayer() {
|
||||
public Player getPlayer() {
|
||||
return player;
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ package net.william278.husksync.util;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.attribute.Attribute;
|
||||
import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.potion.PotionEffectType;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
@@ -48,6 +49,11 @@ public final class BukkitKeyedAdapter {
|
||||
return getRegistryValue(Registry.ATTRIBUTE, key);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
public static PotionEffectType matchEffectType(@NotNull String key) {
|
||||
return getRegistryValue(Registry.EFFECT, key);
|
||||
}
|
||||
|
||||
private static <T extends Keyed> T getRegistryValue(@NotNull Registry<T> registry, @NotNull String keyString) {
|
||||
final NamespacedKey key = NamespacedKey.fromString(keyString);
|
||||
return key != null ? registry.get(key) : null;
|
||||
|
||||
@@ -82,32 +82,33 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
||||
|
||||
final JSONObject status = object.getJSONObject("status_data");
|
||||
final HashMap<Identifier, Data> containers = Maps.newHashMap();
|
||||
if (shouldImport(Identifier.HEALTH)) {
|
||||
if (Identifier.HEALTH.isEnabled()) {
|
||||
containers.put(Identifier.HEALTH, BukkitData.Health.from(
|
||||
status.getDouble("health"),
|
||||
status.getDouble("health_scale")
|
||||
status.getDouble("health_scale"),
|
||||
false
|
||||
));
|
||||
}
|
||||
if (shouldImport(Identifier.HUNGER)) {
|
||||
if (Identifier.HUNGER.isEnabled()) {
|
||||
containers.put(Identifier.HUNGER, BukkitData.Hunger.from(
|
||||
status.getInt("hunger"),
|
||||
status.getFloat("saturation"),
|
||||
status.getFloat("saturation_exhaustion")
|
||||
));
|
||||
}
|
||||
if (shouldImport(Identifier.EXPERIENCE)) {
|
||||
if (Identifier.EXPERIENCE.isEnabled()) {
|
||||
containers.put(Identifier.EXPERIENCE, BukkitData.Experience.from(
|
||||
status.getInt("total_experience"),
|
||||
status.getInt("experience_level"),
|
||||
status.getFloat("experience_progress")
|
||||
));
|
||||
}
|
||||
if (shouldImport(Identifier.GAME_MODE)) {
|
||||
if (Identifier.GAME_MODE.isEnabled()) {
|
||||
containers.put(Identifier.GAME_MODE, BukkitData.GameMode.from(
|
||||
status.getString("game_mode")
|
||||
));
|
||||
}
|
||||
if (shouldImport(Identifier.FLIGHT_STATUS)) {
|
||||
if (Identifier.FLIGHT_STATUS.isEnabled()) {
|
||||
containers.put(Identifier.FLIGHT_STATUS, BukkitData.FlightStatus.from(
|
||||
status.getBoolean("is_flying"),
|
||||
status.getBoolean("is_flying")
|
||||
@@ -118,7 +119,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
||||
|
||||
@NotNull
|
||||
private Optional<Data.Items.Inventory> readInventory(@NotNull JSONObject object) {
|
||||
if (!object.has("inventory") || !shouldImport(Identifier.INVENTORY)) {
|
||||
if (!object.has("inventory") || !Identifier.INVENTORY.isEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -130,7 +131,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
||||
|
||||
@NotNull
|
||||
private Optional<Data.Items.EnderChest> readEnderChest(@NotNull JSONObject object) {
|
||||
if (!object.has("ender_chest") || !shouldImport(Identifier.ENDER_CHEST)) {
|
||||
if (!object.has("ender_chest") || !Identifier.ENDER_CHEST.isEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -142,7 +143,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
||||
|
||||
@NotNull
|
||||
private Optional<Data.Location> readLocation(@NotNull JSONObject object) {
|
||||
if (!object.has("location") || !shouldImport(Identifier.LOCATION)) {
|
||||
if (!object.has("location") || !Identifier.LOCATION.isEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -163,7 +164,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
||||
|
||||
@NotNull
|
||||
private Optional<Data.Advancements> readAdvancements(@NotNull JSONObject object) {
|
||||
if (!object.has("advancements") || !shouldImport(Identifier.ADVANCEMENTS)) {
|
||||
if (!object.has("advancements") || !Identifier.ADVANCEMENTS.isEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -186,7 +187,7 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
||||
|
||||
@NotNull
|
||||
private Optional<Data.Statistics> readStatistics(@NotNull JSONObject object) {
|
||||
if (!object.has("statistics") || !shouldImport(Identifier.STATISTICS)) {
|
||||
if (!object.has("statistics") || !Identifier.STATISTICS.isEnabled()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@@ -280,11 +281,6 @@ public class BukkitLegacyConverter extends LegacyConverter {
|
||||
return serializedItemStack != null ? ItemStack.deserialize((Map<String, Object>) serializedItemStack) : null;
|
||||
}
|
||||
|
||||
|
||||
private boolean shouldImport(@NotNull Identifier type) {
|
||||
return plugin.getSettings().getSynchronization().isFeatureEnabled(type);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private Date parseDate(@NotNull String dateString) {
|
||||
try {
|
||||
|
||||
@@ -1,447 +0,0 @@
|
||||
/*
|
||||
* 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 com.google.common.collect.Lists;
|
||||
import de.tr7zw.changeme.nbtapi.NBT;
|
||||
import de.tr7zw.changeme.nbtapi.iface.ReadWriteNBT;
|
||||
import de.tr7zw.changeme.nbtapi.iface.ReadableNBT;
|
||||
import net.querz.nbt.io.NBTUtil;
|
||||
import net.querz.nbt.tag.CompoundTag;
|
||||
import net.william278.husksync.BukkitHuskSync;
|
||||
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 org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.awt.*;
|
||||
import java.io.File;
|
||||
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().getSynchronization().isPersistLockedMaps()) {
|
||||
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
|
||||
*/
|
||||
@Nullable
|
||||
default ItemStack @NotNull [] setMapViews(@Nullable ItemStack @NotNull [] items) {
|
||||
if (!getPlugin().getSettings().getSynchronization().isPersistLockedMaps()) {
|
||||
return items;
|
||||
}
|
||||
return forEachMap(items, this::applyMapView);
|
||||
}
|
||||
|
||||
// Perform an operation on each map in an array of ItemStacks
|
||||
@NotNull
|
||||
private ItemStack[] forEachMap(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 = getPlugin().getServer().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 and save to file
|
||||
final MapView view = generateRenderedMap(canvasData);
|
||||
final String worldUid = getDefaultMapWorld().getUID().toString();
|
||||
meta.setMapView(view);
|
||||
map.setItemMeta(meta);
|
||||
saveMapToFile(canvasData, view.getId());
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
default void renderMapFromFile(@NotNull MapView view) {
|
||||
final File mapFile = new File(getMapCacheFolder(), view.getId() + ".dat");
|
||||
if (!mapFile.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
final MapData canvasData;
|
||||
try {
|
||||
canvasData = MapData.fromNbt(mapFile);
|
||||
} catch (Throwable e) {
|
||||
getPlugin().log(Level.WARNING, "Failed to deserialize map data from file", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new map view renderer with the map data color at each pixel
|
||||
// use view.removeRenderer() to remove all this maps renderers
|
||||
view.getRenderers().forEach(view::removeRenderer);
|
||||
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
|
||||
setMapView(view);
|
||||
}
|
||||
|
||||
default void saveMapToFile(@NotNull MapData data, int id) {
|
||||
getPlugin().runAsync(() -> {
|
||||
final File mapFile = new File(getMapCacheFolder(), id + ".dat");
|
||||
if (mapFile.exists()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final CompoundTag rootTag = new CompoundTag();
|
||||
rootTag.put("data", data.toNBT().getTag());
|
||||
NBTUtil.write(rootTag, mapFile);
|
||||
} catch (Throwable e) {
|
||||
getPlugin().log(Level.WARNING, "Failed to serialize map data to file", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private File getMapCacheFolder() {
|
||||
final File mapCache = new File(getPlugin().getDataFolder(), "maps");
|
||||
if (!mapCache.exists() && !mapCache.mkdirs()) {
|
||||
getPlugin().log(Level.WARNING, "Failed to create maps folder");
|
||||
}
|
||||
return mapCache;
|
||||
}
|
||||
|
||||
// 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();
|
||||
while (cursors.size() > 0) {
|
||||
cursors.removeCursor(cursors.getCursor(0));
|
||||
}
|
||||
|
||||
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) 8, // Always rotate banners upright
|
||||
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 = Lists.newArrayList();
|
||||
final String BANNER_PREFIX = "banner_";
|
||||
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_PREFIX)) {
|
||||
banners.add(new MapBanner(
|
||||
type.replaceAll(BANNER_PREFIX, ""),
|
||||
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
|
||||
BukkitHuskSync getPlugin();
|
||||
|
||||
}
|
||||
0
bukkit/src/main/resources/META-INF/.mojang-mapped
Normal file
0
bukkit/src/main/resources/META-INF/.mojang-mapped
Normal file
@@ -1,3 +0,0 @@
|
||||
inventory {
|
||||
name brigadier:string single_word;
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
husksync {
|
||||
update;
|
||||
about;
|
||||
status;
|
||||
reload;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
enderchest {
|
||||
name brigadier:string single_word;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
bukkit/src/main/resources/compatibility.yml
Normal file
2
bukkit/src/main/resources/compatibility.yml
Normal file
@@ -0,0 +1,2 @@
|
||||
# File used for checking Minecraft server compatibility with this version of HuskSync
|
||||
minecraft_version_range: '${minecraft_version_range}'
|
||||
@@ -5,7 +5,7 @@ website: 'https://william278.net/'
|
||||
main: 'net.william278.husksync.PaperHuskSync'
|
||||
loader: 'net.william278.husksync.PaperHuskSyncLoader'
|
||||
version: '${version}'
|
||||
api-version: '1.19'
|
||||
api-version: '${minecraft_api_version}'
|
||||
folia-supported: true
|
||||
dependencies:
|
||||
server:
|
||||
@@ -1,7 +1,7 @@
|
||||
name: 'HuskSync'
|
||||
version: '${version}'
|
||||
main: 'net.william278.husksync.BukkitHuskSync'
|
||||
api-version: 1.17
|
||||
api-version: '${minecraft_api_version}'
|
||||
author: 'William278'
|
||||
description: '${description}'
|
||||
website: 'https://william278.net'
|
||||
|
||||
@@ -3,25 +3,31 @@ plugins {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api 'commons-io:commons-io:2.16.1'
|
||||
api 'org.apache.commons:commons-text:1.12.0'
|
||||
api 'commons-io:commons-io:2.21.0'
|
||||
api 'org.apache.commons:commons-text:1.14.0'
|
||||
api 'net.william278:minedown:1.8.2'
|
||||
api 'org.json:json:20240303'
|
||||
api 'com.google.code.gson:gson:2.11.0'
|
||||
api 'net.william278:mapdataapi:2.0'
|
||||
api 'org.json:json:20250517'
|
||||
api 'com.google.code.gson:gson:2.13.2'
|
||||
api 'com.fatboyindustrial.gson-javatime-serialisers:gson-javatime-serialisers:1.1.2'
|
||||
api 'de.exlll:configlib-yaml:4.5.0'
|
||||
api 'de.exlll:configlib-yaml:4.6.4'
|
||||
api 'net.william278:paginedown:1.1.2'
|
||||
api 'net.william278:DesertWell:2.0.4'
|
||||
api('com.zaxxer:HikariCP:5.1.0') {
|
||||
api('com.zaxxer:HikariCP:7.0.2') {
|
||||
exclude module: 'slf4j-api'
|
||||
}
|
||||
|
||||
compileOnly 'org.projectlombok:lombok:1.18.32'
|
||||
compileOnly 'org.jetbrains:annotations:24.1.0'
|
||||
compileOnly 'net.kyori:adventure-api:4.17.0'
|
||||
compileOnly 'net.kyori:adventure-platform-api:4.3.2'
|
||||
compileOnly 'com.google.guava:guava:33.2.0-jre'
|
||||
compileOnly 'com.github.plan-player-analytics:Plan:5.5.2272'
|
||||
compileOnlyApi 'net.william278.toilet:toilet-common:1.0.16'
|
||||
|
||||
compileOnly 'net.william278.uniform:uniform-common:1.3.9'
|
||||
compileOnly 'com.mojang:brigadier:1.1.8'
|
||||
compileOnly 'org.projectlombok:lombok:1.18.42'
|
||||
compileOnly 'org.jetbrains:annotations:26.0.2-1'
|
||||
compileOnly 'net.kyori:adventure-api:4.25.0'
|
||||
compileOnly 'net.kyori:adventure-platform-api:4.4.0'
|
||||
compileOnly "net.kyori:adventure-text-serializer-plain:4.25.0"
|
||||
compileOnly 'com.google.guava:guava:33.5.0-jre'
|
||||
compileOnly 'com.github.plan-player-analytics:Plan:5.6.2965'
|
||||
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"
|
||||
@@ -31,10 +37,10 @@ dependencies {
|
||||
|
||||
testImplementation "redis.clients:jedis:$jedis_version"
|
||||
testImplementation "org.xerial.snappy:snappy-java:$snappy_version"
|
||||
testImplementation 'com.google.guava:guava:33.2.0-jre'
|
||||
testImplementation 'com.github.plan-player-analytics:Plan:5.5.2272'
|
||||
testCompileOnly 'de.exlll:configlib-yaml:4.5.0'
|
||||
testCompileOnly 'org.jetbrains:annotations:24.1.0'
|
||||
testImplementation 'com.google.guava:guava:33.5.0-jre'
|
||||
testImplementation 'com.github.plan-player-analytics:Plan:5.6.2965'
|
||||
testCompileOnly 'de.exlll:configlib-yaml:4.6.4'
|
||||
testCompileOnly 'org.jetbrains:annotations:26.0.2-1'
|
||||
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.32'
|
||||
annotationProcessor 'org.projectlombok:lombok:1.18.42'
|
||||
}
|
||||
@@ -20,6 +20,7 @@
|
||||
package net.william278.husksync;
|
||||
|
||||
import com.fatboyindustrial.gsonjavatime.Converters;
|
||||
import com.google.common.collect.Maps;
|
||||
import com.google.gson.Gson;
|
||||
import com.google.gson.GsonBuilder;
|
||||
import net.kyori.adventure.audience.Audience;
|
||||
@@ -31,19 +32,19 @@ import net.william278.husksync.adapter.DataAdapter;
|
||||
import net.william278.husksync.config.ConfigProvider;
|
||||
import net.william278.husksync.data.Data;
|
||||
import net.william278.husksync.data.Identifier;
|
||||
import net.william278.husksync.data.Serializer;
|
||||
import net.william278.husksync.data.SerializerRegistry;
|
||||
import net.william278.husksync.database.Database;
|
||||
import net.william278.husksync.event.EventDispatcher;
|
||||
import net.william278.husksync.listener.LockedHandler;
|
||||
import net.william278.husksync.migrator.Migrator;
|
||||
import net.william278.husksync.redis.RedisManager;
|
||||
import net.william278.husksync.sync.DataSyncer;
|
||||
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 net.william278.husksync.util.*;
|
||||
import net.william278.uniform.Uniform;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.InputStream;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
@@ -52,7 +53,8 @@ import java.util.logging.Level;
|
||||
/**
|
||||
* Abstract implementation of the HuskSync plugin.
|
||||
*/
|
||||
public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider {
|
||||
public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider, SerializerRegistry,
|
||||
CompatibilityChecker, DumpProvider, DataVersionSupplier {
|
||||
|
||||
int SPIGOT_RESOURCE_ID = 97144;
|
||||
|
||||
@@ -86,7 +88,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
*
|
||||
* @return the {@link RedisManager} implementation
|
||||
*/
|
||||
|
||||
@NotNull
|
||||
RedisManager getRedisManager();
|
||||
|
||||
@@ -98,43 +99,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
@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 the data syncer implementation
|
||||
*
|
||||
@@ -150,6 +114,14 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
*/
|
||||
void setDataSyncer(@NotNull DataSyncer dataSyncer);
|
||||
|
||||
/**
|
||||
* Get the uniform command provider
|
||||
*
|
||||
* @return the command provider
|
||||
*/
|
||||
@NotNull
|
||||
Uniform getUniform();
|
||||
|
||||
/**
|
||||
* Returns a list of available data {@link Migrator}s
|
||||
*
|
||||
@@ -159,7 +131,17 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
List<Migrator> getAvailableMigrators();
|
||||
|
||||
@NotNull
|
||||
Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user);
|
||||
Map<UUID, Map<Identifier, Data>> getPlayerCustomDataStore();
|
||||
|
||||
@NotNull
|
||||
default Map<Identifier, Data> getPlayerCustomDataStore(@NotNull OnlineUser user) {
|
||||
if (getPlayerCustomDataStore().containsKey(user.getUuid())) {
|
||||
return getPlayerCustomDataStore().get(user.getUuid());
|
||||
}
|
||||
final Map<Identifier, Data> data = Maps.newHashMap();
|
||||
getPlayerCustomDataStore().put(user.getUuid(), data);
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a faucet of the plugin.
|
||||
@@ -193,14 +175,6 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
*/
|
||||
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
|
||||
*
|
||||
@@ -283,6 +257,14 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
@NotNull
|
||||
String getPlatformType();
|
||||
|
||||
/**
|
||||
* Returns the server software version
|
||||
*
|
||||
* @return the server software version string
|
||||
*/
|
||||
@NotNull
|
||||
String getServerVersion();
|
||||
|
||||
/**
|
||||
* Returns the legacy data converter if it exists
|
||||
*
|
||||
@@ -312,6 +294,9 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
}
|
||||
}
|
||||
|
||||
@NotNull
|
||||
LockedHandler getLockedHandler();
|
||||
|
||||
/**
|
||||
* Get the set of UUIDs of "locked players", for which events will be canceled.
|
||||
* </p>
|
||||
@@ -320,6 +305,12 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
@NotNull
|
||||
Set<UUID> getLockedPlayers();
|
||||
|
||||
/**
|
||||
* Get the set of UUIDs of players who are currently marked as disconnecting or disconnected
|
||||
*/
|
||||
@NotNull
|
||||
Set<UUID> getDisconnectingPlayers();
|
||||
|
||||
default boolean isLocked(@NotNull UUID uuid) {
|
||||
return getLockedPlayers().contains(uuid);
|
||||
}
|
||||
@@ -358,7 +349,11 @@ public interface HuskSync extends Task.Supplier, EventDispatcher, ConfigProvider
|
||||
|
||||
Caused by: %s""";
|
||||
|
||||
FailedToLoadException(@NotNull String message, @NotNull Throwable cause) {
|
||||
public FailedToLoadException(@NotNull String message) {
|
||||
super(String.format(FORMAT, message));
|
||||
}
|
||||
|
||||
public FailedToLoadException(@NotNull String message, @NotNull Throwable cause) {
|
||||
super(String.format(FORMAT, message), cause);
|
||||
}
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ public class GsonAdapter implements DataAdapter {
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException {
|
||||
public <A extends Adaptable> A fromBytes(byte[] data, @NotNull Class<A> type) throws AdaptionException {
|
||||
return this.fromJson(new String(data, StandardCharsets.UTF_8), type);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,6 @@ public class SnappyGsonAdapter extends GsonAdapter {
|
||||
super(plugin);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public <A extends Adaptable> byte[] toBytes(@NotNull A data) throws AdaptionException {
|
||||
try {
|
||||
@@ -43,7 +42,7 @@ public class SnappyGsonAdapter extends GsonAdapter {
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
public <A extends Adaptable> A fromBytes(@NotNull byte[] data, @NotNull Class<A> type) throws AdaptionException {
|
||||
public <A extends Adaptable> A fromBytes(byte[] data, @NotNull Class<A> type) throws AdaptionException {
|
||||
try {
|
||||
return super.fromBytes(decompressBytes(data), type);
|
||||
} catch (IOException e) {
|
||||
|
||||
@@ -99,6 +99,18 @@ public class HuskSyncAPI {
|
||||
return plugin.supplyAsync(() -> plugin.getDatabase().getUser(uuid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an {@link OnlineUser} by their UUID
|
||||
*
|
||||
* @param uuid the UUID of the user to get
|
||||
* @return The {@link OnlineUser} wrapped in an optional, if they are online on <i>this</i> server.
|
||||
* @since 3.7.2
|
||||
*/
|
||||
@NotNull
|
||||
public Optional<OnlineUser> getOnlineUser(@NotNull UUID uuid) {
|
||||
return plugin.getOnlineUser(uuid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a {@link User} by their username
|
||||
*
|
||||
@@ -137,7 +149,7 @@ public class HuskSyncAPI {
|
||||
*/
|
||||
public CompletableFuture<Optional<DataSnapshot.Unpacked>> getCurrentData(@NotNull User user) {
|
||||
return plugin.getRedisManager()
|
||||
.getUserData(UUID.randomUUID(), user)
|
||||
.getOnlineUserData(UUID.randomUUID(), user, DataSnapshot.SaveCause.API)
|
||||
.thenApply(data -> data.or(() -> plugin.getDatabase().getLatestSnapshot(user)))
|
||||
.thenApply(data -> data.map(snapshot -> snapshot.unpack(plugin)));
|
||||
}
|
||||
@@ -378,6 +390,17 @@ public class HuskSyncAPI {
|
||||
plugin.registerSerializer(identifier, serializer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered data serializer by its identifier
|
||||
*
|
||||
* @param identifier The identifier of the data type to get the serializer for
|
||||
* @return The serializer for the given identifier, or an empty optional if the serializer isn't registered
|
||||
* @since 3.5.4
|
||||
*/
|
||||
public Optional<Serializer<Data>> getDataSerializer(@NotNull Identifier identifier) {
|
||||
return plugin.getSerializer(identifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a {@link DataSnapshot.Unpacked} from a {@link DataSnapshot.Packed}
|
||||
*
|
||||
@@ -500,17 +523,19 @@ public class HuskSyncAPI {
|
||||
*/
|
||||
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:
|
||||
private static final String REASONS = """
|
||||
This may 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.""";
|
||||
3) You are attempting to access HuskSync on plugin construction/before your plugin has enabled.""";
|
||||
|
||||
NotRegisteredException(@NotNull String reasons) {
|
||||
super("Could not access the HuskSync API as it has not yet been registered. %s".formatted(reasons));
|
||||
}
|
||||
|
||||
NotRegisteredException() {
|
||||
super(MESSAGE);
|
||||
this(REASONS);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
/*
|
||||
* 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 com.google.common.collect.Maps;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.user.CommandUser;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
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 = Maps.newHashMap();
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -23,7 +23,6 @@ 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.redis.RedisKeyType;
|
||||
import net.william278.husksync.redis.RedisManager;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import net.william278.husksync.user.User;
|
||||
@@ -37,7 +36,7 @@ import java.util.Optional;
|
||||
public class EnderChestCommand extends ItemsCommand {
|
||||
|
||||
public EnderChestCommand(@NotNull HuskSync plugin) {
|
||||
super(plugin, List.of("enderchest", "echest", "openechest"));
|
||||
super("enderchest", List.of("echest", "openechest"), DataSnapshot.SaveCause.ENDERCHEST_COMMAND, plugin);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -51,7 +50,7 @@ public class EnderChestCommand extends ItemsCommand {
|
||||
}
|
||||
|
||||
// Display opening message
|
||||
plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getUsername(),
|
||||
plugin.getLocales().getLocale("ender_chest_viewer_opened", user.getName(),
|
||||
snapshot.getTimestamp().format(DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)))
|
||||
.ifPresent(viewer::sendMessage);
|
||||
@@ -60,8 +59,8 @@ public class EnderChestCommand extends ItemsCommand {
|
||||
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()))),
|
||||
plugin.getLocales().getLocale("ender_chest_viewer_menu_title", user.getName())
|
||||
.orElse(new MineDown(String.format("%s's Ender Chest", user.getName()))),
|
||||
allowEdit,
|
||||
enderChest.getSlotCount(),
|
||||
(itemsOnClose) -> {
|
||||
@@ -84,18 +83,17 @@ public class EnderChestCommand extends ItemsCommand {
|
||||
|
||||
// Create and pack the snapshot with the updated enderChest
|
||||
final DataSnapshot.Packed snapshot = latestData.get().copy();
|
||||
boolean pin = plugin.getSettings().getSynchronization().doAutoPin(saveCause);
|
||||
snapshot.edit(plugin, (data) -> {
|
||||
data.getEnderChest().ifPresent(enderChest -> enderChest.setContents(items));
|
||||
data.setSaveCause(DataSnapshot.SaveCause.ENDERCHEST_COMMAND);
|
||||
data.setPinned(
|
||||
plugin.getSettings().getSynchronization().doAutoPin(DataSnapshot.SaveCause.ENDERCHEST_COMMAND)
|
||||
);
|
||||
data.setSaveCause(saveCause);
|
||||
data.setPinned(pin);
|
||||
});
|
||||
|
||||
// Save data
|
||||
final RedisManager redis = plugin.getRedisManager();
|
||||
plugin.getDataSyncer().saveData(holder, snapshot, (user, data) -> {
|
||||
redis.getUserData(user).ifPresent(d -> redis.setUserData(user, snapshot, RedisKeyType.TTL_1_YEAR));
|
||||
redis.getUserData(user).ifPresent(d -> redis.setUserData(user, snapshot));
|
||||
redis.sendUserDataUpdate(user, data);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -19,45 +19,44 @@
|
||||
|
||||
package net.william278.husksync.command;
|
||||
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import de.themoep.minedown.adventure.MineDown;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.JoinConfiguration;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.william278.desertwell.about.AboutMenu;
|
||||
import net.william278.desertwell.util.UpdateChecker;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.database.Database;
|
||||
import net.william278.husksync.migrator.Migrator;
|
||||
import net.william278.husksync.user.CommandUser;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import org.apache.commons.text.WordUtils;
|
||||
import net.william278.husksync.util.LegacyConverter;
|
||||
import net.william278.husksync.util.StatusLine;
|
||||
import net.william278.uniform.BaseCommand;
|
||||
import net.william278.uniform.CommandProvider;
|
||||
import net.william278.uniform.Permission;
|
||||
import net.william278.uniform.element.ArgumentElement;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.Function;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
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,
|
||||
"status", true,
|
||||
"reload", true,
|
||||
"migrate", true,
|
||||
"update", true
|
||||
);
|
||||
public class HuskSyncCommand extends PluginCommand {
|
||||
|
||||
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);
|
||||
|
||||
super("husksync", List.of(), Permission.Default.TRUE, ExecutionScope.ALL, plugin);
|
||||
this.updateChecker = plugin.getUpdateChecker();
|
||||
this.aboutMenu = AboutMenu.builder()
|
||||
.title(Component.text("HuskSync"))
|
||||
@@ -68,7 +67,10 @@ public class HuskSyncCommand extends Command implements TabProvider {
|
||||
.credits("Contributors",
|
||||
AboutMenu.Credit.of("HarvelsX").description("Code"),
|
||||
AboutMenu.Credit.of("HookWoods").description("Code"),
|
||||
AboutMenu.Credit.of("Preva1l").description("Code"))
|
||||
AboutMenu.Credit.of("Preva1l").description("Code"),
|
||||
AboutMenu.Credit.of("hanbings").description("Code (Fabric porting)"),
|
||||
AboutMenu.Credit.of("Stampede2011").description("Code (Fabric mixins)"),
|
||||
AboutMenu.Credit.of("VinerDream").description("Code"))
|
||||
.credits("Translators",
|
||||
AboutMenu.Credit.of("Namiu").description("Japanese (ja-jp)"),
|
||||
AboutMenu.Credit.of("anchelthe").description("Spanish (es-es)"),
|
||||
@@ -93,189 +95,165 @@ public class HuskSyncCommand extends Command implements TabProvider {
|
||||
}
|
||||
|
||||
@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 "status" -> {
|
||||
getPlugin().getLocales().getLocale("system_status_header").ifPresent(executor::sendMessage);
|
||||
executor.sendMessage(Component.join(
|
||||
JoinConfiguration.newlines(),
|
||||
Arrays.stream(StatusLine.values()).map(s -> s.get(plugin)).toList()
|
||||
));
|
||||
}
|
||||
case "reload" -> {
|
||||
try {
|
||||
plugin.loadSettings();
|
||||
plugin.loadLocales();
|
||||
plugin.loadServer();
|
||||
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);
|
||||
}
|
||||
public void provide(@NotNull BaseCommand<?> command) {
|
||||
command.setDefaultExecutor((ctx) -> about(command, ctx));
|
||||
command.addSubCommand("about", (sub) -> sub.setDefaultExecutor((ctx) -> about(command, ctx)));
|
||||
command.addSubCommand("status", needsOp("status"), status());
|
||||
command.addSubCommand("dump", needsOp("dump"), dump());
|
||||
command.addSubCommand("reload", needsOp("reload"), reload());
|
||||
command.addSubCommand("update", needsOp("update"), update());
|
||||
command.addSubCommand("forceupgrade", forceUpgrade());
|
||||
command.addSubCommand("migrate", migrate());
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
private void about(@NotNull BaseCommand<?> c, @NotNull CommandContext<?> ctx) {
|
||||
user(c, ctx).getAudience().sendMessage(aboutMenu.toComponent());
|
||||
}
|
||||
|
||||
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());
|
||||
@NotNull
|
||||
private CommandProvider status() {
|
||||
return (sub) -> sub.setDefaultExecutor((ctx) -> {
|
||||
final CommandUser user = user(sub, ctx);
|
||||
plugin.getLocales().getLocale("system_status_header").ifPresent(user::sendMessage);
|
||||
user.sendMessage(Component.join(
|
||||
JoinConfiguration.newlines(),
|
||||
Arrays.stream(StatusLine.values()).map(s -> s.get(plugin)).toList()
|
||||
));
|
||||
});
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider dump() {
|
||||
return (sub) -> {
|
||||
sub.setDefaultExecutor((ctx) -> {
|
||||
final CommandUser user = user(sub, ctx);
|
||||
plugin.getLocales().getLocale("system_dump_confirm").ifPresent(user::sendMessage);
|
||||
});
|
||||
sub.addSubCommand("confirm", (con) -> con.setDefaultExecutor((ctx) -> {
|
||||
final CommandUser user = user(sub, ctx);
|
||||
plugin.getLocales().getLocale("system_dump_started").ifPresent(user::sendMessage);
|
||||
plugin.runAsync(() -> {
|
||||
final String url = plugin.createDump(user);
|
||||
plugin.getLocales().getLocale("system_dump_ready").ifPresent(user::sendMessage);
|
||||
user.sendMessage(Component.text(url).clickEvent(ClickEvent.openUrl(url))
|
||||
.decorate(TextDecoration.UNDERLINED).color(NamedTextColor.GRAY));
|
||||
});
|
||||
}));
|
||||
};
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider reload() {
|
||||
return (sub) -> sub.setDefaultExecutor((ctx) -> {
|
||||
final CommandUser user = user(sub, ctx);
|
||||
try {
|
||||
plugin.loadSettings();
|
||||
plugin.loadLocales();
|
||||
plugin.loadServer();
|
||||
plugin.getLocales().getLocale("reload_complete").ifPresent(user::sendMessage);
|
||||
} catch (Throwable e) {
|
||||
user.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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider update() {
|
||||
return (sub) -> sub.setDefaultExecutor((ctx) -> updateChecker.check().thenAccept(checked -> {
|
||||
final CommandUser user = user(sub, ctx);
|
||||
if (checked.isUpToDate()) {
|
||||
plugin.getLocales().getLocale("up_to_date", plugin.getPluginVersion().toString())
|
||||
.ifPresent(user::sendMessage);
|
||||
return;
|
||||
}
|
||||
switch (args[2]) {
|
||||
case "start" -> migrator.start().thenAccept(succeeded -> {
|
||||
plugin.getLocales().getLocale("update_available", checked.getLatestVersion().toString(),
|
||||
plugin.getPluginVersion().toString()).ifPresent(user::sendMessage);
|
||||
}));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider migrate() {
|
||||
return (sub) -> {
|
||||
sub.setCondition((ctx) -> sub.getUser(ctx).isConsole());
|
||||
sub.setDefaultExecutor((ctx) -> {
|
||||
plugin.log(Level.INFO, "Please choose a migrator, then run \"husksync migrate start <migrator>\"");
|
||||
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"))
|
||||
));
|
||||
});
|
||||
sub.addSubCommand("help", (help) -> help.addSyntax((cmd) -> {
|
||||
final Migrator migrator = cmd.getArgument("migrator", Migrator.class);
|
||||
plugin.log(Level.INFO, migrator.getHelpMenu());
|
||||
}, migrator()));
|
||||
sub.addSubCommand("start", (start) -> start.addSyntax((cmd) -> {
|
||||
final Migrator migrator = cmd.getArgument("migrator", Migrator.class);
|
||||
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;
|
||||
}, migrator()));
|
||||
sub.addSubCommand("set", (set) -> set.addSyntax((cmd) -> {
|
||||
final Migrator migrator = cmd.getArgument("migrator", Migrator.class);
|
||||
final String[] args = cmd.getArgument("args", String.class).split(" ");
|
||||
migrator.handleConfigurationCommand(args);
|
||||
}, migrator(), BaseCommand.greedyString("args")));
|
||||
};
|
||||
}
|
||||
|
||||
private enum StatusLine {
|
||||
PLUGIN_VERSION(plugin -> Component.text("v" + plugin.getPluginVersion().toStringWithoutMetadata())
|
||||
.appendSpace().append(plugin.getPluginVersion().getMetadata().isBlank() ? Component.empty()
|
||||
: Component.text("(build " + plugin.getPluginVersion().getMetadata() + ")"))),
|
||||
PLATFORM_TYPE(plugin -> Component.text(WordUtils.capitalizeFully(plugin.getPlatformType()))),
|
||||
LANGUAGE(plugin -> Component.text(plugin.getSettings().getLanguage())),
|
||||
MINECRAFT_VERSION(plugin -> Component.text(plugin.getMinecraftVersion().toString())),
|
||||
JAVA_VERSION(plugin -> Component.text(System.getProperty("java.version"))),
|
||||
JAVA_VENDOR(plugin -> Component.text(System.getProperty("java.vendor"))),
|
||||
SYNC_MODE(plugin -> Component.text(WordUtils.capitalizeFully(
|
||||
plugin.getSettings().getSynchronization().getMode().toString()
|
||||
))),
|
||||
DELAY_LATENCY(plugin -> Component.text(
|
||||
plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds() + "ms"
|
||||
)),
|
||||
SERVER_NAME(plugin -> Component.text(plugin.getServerName())),
|
||||
CLUSTER_ID(plugin -> Component.text(plugin.getSettings().getClusterId().isBlank() ? "None" : plugin.getSettings().getClusterId())),
|
||||
DATABASE_TYPE(plugin ->
|
||||
Component.text(plugin.getSettings().getDatabase().getType().getDisplayName() +
|
||||
(plugin.getSettings().getDatabase().getType() == Database.Type.MONGO ?
|
||||
(plugin.getSettings().getDatabase().getMongoSettings().isUsingAtlas() ? " Atlas" : "") : ""))
|
||||
),
|
||||
IS_DATABASE_LOCAL(plugin -> getLocalhostBoolean(plugin.getSettings().getDatabase().getCredentials().getHost())),
|
||||
USING_REDIS_SENTINEL(plugin -> getBoolean(
|
||||
!plugin.getSettings().getRedis().getSentinel().getMaster().isBlank()
|
||||
)),
|
||||
USING_REDIS_PASSWORD(plugin -> getBoolean(
|
||||
!plugin.getSettings().getRedis().getCredentials().getPassword().isBlank()
|
||||
)),
|
||||
REDIS_USING_SSL(plugin -> getBoolean(
|
||||
plugin.getSettings().getRedis().getCredentials().isUseSsl()
|
||||
)),
|
||||
IS_REDIS_LOCAL(plugin -> getLocalhostBoolean(
|
||||
plugin.getSettings().getRedis().getCredentials().getHost()
|
||||
)),
|
||||
DATA_TYPES(plugin -> Component.join(
|
||||
JoinConfiguration.commas(true),
|
||||
plugin.getRegisteredDataTypes().stream().map(i -> {
|
||||
boolean enabled = plugin.getSettings().getSynchronization().isFeatureEnabled(i);
|
||||
return Component.textOfChildren(Component
|
||||
.text(i.toString()).appendSpace().append(Component.text(enabled ? '✔' : '❌')))
|
||||
.color(enabled ? NamedTextColor.GREEN : NamedTextColor.RED)
|
||||
.hoverEvent(HoverEvent.showText(Component.text(enabled ? "Enabled" : "Disabled")));
|
||||
}).toList()
|
||||
));
|
||||
@NotNull
|
||||
private CommandProvider forceUpgrade() {
|
||||
return (sub) -> {
|
||||
sub.setCondition((ctx) -> sub.getUser(ctx).isConsole());
|
||||
sub.setDefaultExecutor((ctx) -> {
|
||||
final LegacyConverter converter = plugin.getLegacyConverter().orElse(null);
|
||||
if (converter == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
private final Function<HuskSync, Component> supplier;
|
||||
plugin.runAsync(() -> {
|
||||
final Database database = plugin.getDatabase();
|
||||
plugin.log(Level.INFO, "Beginning forced legacy data upgrade for all users...");
|
||||
database.getAllUsers().forEach(user -> database.getLatestSnapshot(user).ifPresent(snapshot -> {
|
||||
final DataSnapshot.Packed upgraded = converter.convert(
|
||||
snapshot.asBytes(plugin),
|
||||
UUID.randomUUID(),
|
||||
OffsetDateTime.now()
|
||||
);
|
||||
upgraded.setSaveCause(DataSnapshot.SaveCause.CONVERTED_FROM_V2);
|
||||
plugin.getDatabase().addSnapshot(user, upgraded);
|
||||
plugin.getRedisManager().clearUserData(user);
|
||||
}));
|
||||
plugin.log(Level.INFO, "Legacy data upgrade complete!");
|
||||
});
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
StatusLine(@NotNull Function<HuskSync, Component> supplier) {
|
||||
this.supplier = supplier;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private Component get(@NotNull HuskSync plugin) {
|
||||
return Component
|
||||
.text("•").appendSpace()
|
||||
.append(Component.text(
|
||||
WordUtils.capitalizeFully(name().replaceAll("_", " ")),
|
||||
TextColor.color(0x848484)
|
||||
))
|
||||
.append(Component.text(':')).append(Component.space().color(NamedTextColor.WHITE))
|
||||
.append(supplier.apply(plugin));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static Component getBoolean(boolean value) {
|
||||
return Component.text(value ? "Yes" : "No", value ? NamedTextColor.GREEN : NamedTextColor.RED);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static Component getLocalhostBoolean(@NotNull String value) {
|
||||
return getBoolean(value.equals("127.0.0.1") || value.equals("0.0.0.0")
|
||||
|| value.equals("localhost") || value.equals("::1"));
|
||||
}
|
||||
@NotNull
|
||||
private <S> ArgumentElement<S, Migrator> migrator() {
|
||||
return new ArgumentElement<>("migrator", reader -> {
|
||||
final String id = reader.readString();
|
||||
final Migrator migrator = plugin.getAvailableMigrators().stream()
|
||||
.filter(m -> m.getIdentifier().equalsIgnoreCase(id)).findFirst().orElse(null);
|
||||
if (migrator == null) {
|
||||
throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument().createWithContext(reader);
|
||||
}
|
||||
return migrator;
|
||||
}, (context, builder) -> {
|
||||
for (Migrator material : plugin.getAvailableMigrators()) {
|
||||
builder.suggest(material.getIdentifier());
|
||||
}
|
||||
return builder.buildFuture();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ 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.redis.RedisKeyType;
|
||||
import net.william278.husksync.redis.RedisManager;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import net.william278.husksync.user.User;
|
||||
@@ -37,7 +36,7 @@ import java.util.Optional;
|
||||
public class InventoryCommand extends ItemsCommand {
|
||||
|
||||
public InventoryCommand(@NotNull HuskSync plugin) {
|
||||
super(plugin, List.of("inventory", "invsee", "openinv"));
|
||||
super("inventory", List.of("invsee", "openinv"), DataSnapshot.SaveCause.INVENTORY_COMMAND, plugin);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -51,7 +50,7 @@ public class InventoryCommand extends ItemsCommand {
|
||||
}
|
||||
|
||||
// Display opening message
|
||||
plugin.getLocales().getLocale("inventory_viewer_opened", user.getUsername(),
|
||||
plugin.getLocales().getLocale("inventory_viewer_opened", user.getName(),
|
||||
snapshot.getTimestamp().format(DateTimeFormatter
|
||||
.ofLocalizedDateTime(FormatStyle.MEDIUM, FormatStyle.SHORT)))
|
||||
.ifPresent(viewer::sendMessage);
|
||||
@@ -60,8 +59,8 @@ public class InventoryCommand extends ItemsCommand {
|
||||
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()))),
|
||||
plugin.getLocales().getLocale("inventory_viewer_menu_title", user.getName())
|
||||
.orElse(new MineDown(String.format("%s's Inventory", user.getName()))),
|
||||
allowEdit,
|
||||
inventory.getSlotCount(),
|
||||
(itemsOnClose) -> {
|
||||
@@ -84,18 +83,17 @@ public class InventoryCommand extends ItemsCommand {
|
||||
|
||||
// Create and pack the snapshot with the updated inventory
|
||||
final DataSnapshot.Packed snapshot = latestData.get().copy();
|
||||
boolean pin = plugin.getSettings().getSynchronization().doAutoPin(saveCause);
|
||||
snapshot.edit(plugin, (data) -> {
|
||||
data.getInventory().ifPresent(inventory -> inventory.setContents(items));
|
||||
data.setSaveCause(DataSnapshot.SaveCause.INVENTORY_COMMAND);
|
||||
data.setPinned(
|
||||
plugin.getSettings().getSynchronization().doAutoPin(DataSnapshot.SaveCause.INVENTORY_COMMAND)
|
||||
);
|
||||
data.setSaveCause(saveCause);
|
||||
data.setPinned(pin);
|
||||
});
|
||||
|
||||
// Save data
|
||||
final RedisManager redis = plugin.getRedisManager();
|
||||
plugin.getDataSyncer().saveData(holder, snapshot, (user, data) -> {
|
||||
redis.getUserData(user).ifPresent(d -> redis.setUserData(user, snapshot, RedisKeyType.TTL_1_YEAR));
|
||||
redis.getUserData(user).ifPresent(d -> redis.setUserData(user, snapshot));
|
||||
redis.sendUserDataUpdate(user, data);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,52 +24,52 @@ 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 net.william278.uniform.BaseCommand;
|
||||
import net.william278.uniform.Permission;
|
||||
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 {
|
||||
public abstract class ItemsCommand extends PluginCommand {
|
||||
|
||||
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));
|
||||
protected final DataSnapshot.SaveCause saveCause;
|
||||
|
||||
protected ItemsCommand(@NotNull String name, @NotNull List<String> aliases,
|
||||
@NotNull DataSnapshot.SaveCause saveCause, @NotNull HuskSync plugin) {
|
||||
super(name, aliases, Permission.Default.IF_OP, ExecutionScope.IN_GAME, plugin);
|
||||
this.saveCause = saveCause;
|
||||
}
|
||||
|
||||
|
||||
@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)
|
||||
);
|
||||
public void provide(@NotNull BaseCommand<?> command) {
|
||||
command.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final UUID version = ctx.getArgument("version", UUID.class);
|
||||
final CommandUser executor = user(command, ctx);
|
||||
if (!(executor instanceof OnlineUser online)) {
|
||||
plugin.getLocales().getLocale("error_in_game_command_only")
|
||||
.ifPresent(executor::sendMessage);
|
||||
return;
|
||||
}
|
||||
this.showSnapshotItems(online, user, version);
|
||||
}, user("username"), versionUuid());
|
||||
command.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final CommandUser executor = user(command, ctx);
|
||||
if (!(executor instanceof OnlineUser online)) {
|
||||
plugin.getLocales().getLocale("error_in_game_command_only")
|
||||
.ifPresent(executor::sendMessage);
|
||||
return;
|
||||
}
|
||||
this.showLatestItems(online, user);
|
||||
}, user("username"));
|
||||
}
|
||||
|
||||
// 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
|
||||
plugin.getRedisManager().getOnlineUserData(user.getUuid(), user, saveCause).thenAccept(d -> d
|
||||
.or(() -> plugin.getDatabase().getLatestSnapshot(user))
|
||||
.or(() -> {
|
||||
plugin.getLocales().getLocale("error_no_data_to_display")
|
||||
@@ -114,12 +114,4 @@ public abstract class ItemsCommand extends Command implements TabProvider {
|
||||
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;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
* 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 com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.user.CommandUser;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import net.william278.husksync.user.User;
|
||||
import net.william278.uniform.BaseCommand;
|
||||
import net.william278.uniform.Command;
|
||||
import net.william278.uniform.Permission;
|
||||
import net.william278.uniform.element.ArgumentElement;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
|
||||
public abstract class PluginCommand extends Command {
|
||||
|
||||
protected final HuskSync plugin;
|
||||
|
||||
protected PluginCommand(@NotNull String name, @NotNull List<String> aliases, @NotNull Permission.Default defPerm,
|
||||
@NotNull ExecutionScope scope, @NotNull HuskSync plugin) {
|
||||
super(name, aliases, getDescription(plugin, name), new Permission(createPermission(name), defPerm), scope);
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
private static String getDescription(@NotNull HuskSync plugin, @NotNull String name) {
|
||||
return plugin.getLocales().getRawLocale("%s_command_description".formatted(name)).orElse("");
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static String createPermission(@NotNull String name, @NotNull String... sub) {
|
||||
return "husksync.command." + name + (sub.length > 0 ? "." + String.join(".", sub) : "");
|
||||
}
|
||||
|
||||
@NotNull
|
||||
protected String getPermission(@NotNull String... sub) {
|
||||
return createPermission(this.getName(), sub);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@SuppressWarnings("rawtypes")
|
||||
protected CommandUser user(@NotNull BaseCommand base, @NotNull CommandContext context) {
|
||||
return adapt(base.getUser(context.getSource()));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
protected Permission needsOp(@NotNull String... nodes) {
|
||||
return new Permission(getPermission(nodes), Permission.Default.IF_OP);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
protected CommandUser adapt(net.william278.uniform.CommandUser user) {
|
||||
return user.getUuid() == null ? plugin.getConsole() : plugin.getOnlineUser(user.getUuid()).orElseThrow();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
protected <S> ArgumentElement<S, OnlineUser> onlineUser(@NotNull String name) {
|
||||
return new ArgumentElement<>(name, reader -> {
|
||||
final String username = reader.readString();
|
||||
return plugin.getOnlineUsers().stream()
|
||||
.filter(user -> username.equals(user.getName()))
|
||||
.findFirst().orElse(null);
|
||||
}, (context, builder) -> {
|
||||
plugin.getOnlineUsers().forEach(u -> builder.suggest(u.getName()));
|
||||
return builder.buildFuture();
|
||||
});
|
||||
}
|
||||
|
||||
@NotNull
|
||||
protected <S> ArgumentElement<S, User> user(@NotNull String name) {
|
||||
return new ArgumentElement<>(name, reader -> {
|
||||
final String username = reader.readString();
|
||||
return plugin.getDatabase().getUserByName(username).orElseThrow(
|
||||
() -> CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument().createWithContext(reader)
|
||||
);
|
||||
}, (context, builder) -> {
|
||||
plugin.getOnlineUsers().forEach(u -> builder.suggest(u.getName()));
|
||||
return builder.buildFuture();
|
||||
});
|
||||
}
|
||||
|
||||
@NotNull
|
||||
protected <S> ArgumentElement<S, UUID> versionUuid() {
|
||||
return new ArgumentElement<>("version", reader -> {
|
||||
try {
|
||||
return UUID.fromString(reader.readString());
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw CommandSyntaxException.BUILT_IN_EXCEPTIONS.dispatcherUnknownArgument().createWithContext(reader);
|
||||
}
|
||||
}, (context, builder) -> {
|
||||
try {
|
||||
plugin.getDatabase().getAllSnapshots(context.getArgument("username", User.class))
|
||||
.stream().sorted(Comparator.comparing(d -> d.getTimestamp().toEpochSecond()))
|
||||
.forEach(id -> builder.suggest(id.getId().toString()));
|
||||
return builder.buildFuture();
|
||||
} catch (IllegalArgumentException e) {
|
||||
return builder.buildFuture();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
|
||||
HUSKSYNC_COMMAND(HuskSyncCommand::new),
|
||||
USERDATA_COMMAND(UserDataCommand::new),
|
||||
INVENTORY_COMMAND(InventoryCommand::new),
|
||||
ENDER_CHEST_COMMAND(EnderChestCommand::new);
|
||||
|
||||
public final Function<HuskSync, PluginCommand> commandSupplier;
|
||||
|
||||
Type(@NotNull Function<HuskSync, PluginCommand> supplier) {
|
||||
this.commandSupplier = supplier;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public PluginCommand supply(@NotNull HuskSync plugin) {
|
||||
return commandSupplier.apply(plugin);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static PluginCommand[] create(@NotNull HuskSync plugin) {
|
||||
return Arrays.stream(values()).map(type -> type.supply(plugin))
|
||||
.filter(command -> !plugin.getSettings().isCommandDisabled(command))
|
||||
.toArray(PluginCommand[]::new);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
/*
|
||||
* 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();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -19,92 +19,47 @@
|
||||
|
||||
package net.william278.husksync.command;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import net.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.event.ClickEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextDecoration;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.redis.RedisKeyType;
|
||||
import net.william278.husksync.redis.RedisManager;
|
||||
import net.william278.husksync.user.CommandUser;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
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 net.william278.husksync.util.UserDataDumper;
|
||||
import net.william278.uniform.BaseCommand;
|
||||
import net.william278.uniform.CommandProvider;
|
||||
import net.william278.uniform.Permission;
|
||||
import net.william278.uniform.element.ArgumentElement;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
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 class UserDataCommand extends PluginCommand {
|
||||
|
||||
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);
|
||||
super("userdata", List.of("playerdata"), Permission.Default.IF_OP, ExecutionScope.ALL, plugin);
|
||||
}
|
||||
|
||||
@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> uuid = 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" -> uuid.ifPresentOrElse(
|
||||
version -> viewSnapshot(executor, user, version),
|
||||
() -> viewLatestSnapshot(executor, user)
|
||||
);
|
||||
case "list" -> listSnapshots(
|
||||
executor, user, parseIntArg(args, 2).or(() -> parseIntArg(args, 1)).orElse(1)
|
||||
);
|
||||
case "delete" -> uuid.ifPresentOrElse(
|
||||
version -> deleteSnapshot(executor, user, version),
|
||||
() -> plugin.getLocales().getLocale("error_invalid_syntax",
|
||||
"/userdata delete <username> <version_uuid>")
|
||||
.ifPresent(executor::sendMessage)
|
||||
);
|
||||
case "restore" -> uuid.ifPresentOrElse(
|
||||
version -> restoreSnapshot(executor, user, version),
|
||||
() -> plugin.getLocales().getLocale("error_invalid_syntax",
|
||||
"/userdata restore <username> <version_uuid>")
|
||||
.ifPresent(executor::sendMessage)
|
||||
);
|
||||
case "pin" -> uuid.ifPresentOrElse(
|
||||
version -> pinSnapshot(executor, user, version),
|
||||
() -> plugin.getLocales().getLocale("error_invalid_syntax",
|
||||
"/userdata pin <username> <version_uuid>")
|
||||
.ifPresent(executor::sendMessage)
|
||||
);
|
||||
case "dump" -> uuid.ifPresentOrElse(
|
||||
version -> dumpSnapshot(executor, user, version, parseStringArg(args, 3)
|
||||
.map(arg -> arg.equalsIgnoreCase("web")).orElse(false)),
|
||||
() -> plugin.getLocales().getLocale("error_invalid_syntax",
|
||||
"/userdata dump <web/file> <username> <version_uuid>")
|
||||
.ifPresent(executor::sendMessage)
|
||||
);
|
||||
default -> plugin.getLocales().getLocale("error_invalid_syntax", getUsage())
|
||||
.ifPresent(executor::sendMessage);
|
||||
}
|
||||
public void provide(@NotNull BaseCommand<?> command) {
|
||||
command.addSubCommand("view", needsOp("view"), view());
|
||||
command.addSubCommand("list", needsOp("list"), list());
|
||||
command.addSubCommand("delete", needsOp("delete"), delete());
|
||||
command.addSubCommand("save", needsOp("save"), save());
|
||||
command.addSubCommand("restore", needsOp("restore"), restore());
|
||||
command.addSubCommand("pin", needsOp("pin"), pin());
|
||||
command.addSubCommand("dump", needsOp("dump"), dump());
|
||||
}
|
||||
|
||||
// Show the latest snapshot
|
||||
@@ -152,6 +107,13 @@ public class UserDataCommand extends Command implements TabProvider {
|
||||
DataSnapshotList.create(dataList, user, plugin).displayPage(executor, page);
|
||||
}
|
||||
|
||||
// Create and save a snapshot of a user's current data
|
||||
private void createAndSaveSnapshot(@NotNull CommandUser executor, @NotNull OnlineUser onlineUser) {
|
||||
plugin.getDataSyncer().saveCurrentUserData(onlineUser, DataSnapshot.SaveCause.SAVE_COMMAND);
|
||||
plugin.getLocales().getLocale("data_saved", onlineUser.getName())
|
||||
.ifPresent(executor::sendMessage);
|
||||
}
|
||||
|
||||
// Delete a snapshot
|
||||
private void deleteSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version) {
|
||||
if (!plugin.getDatabase().deleteSnapshot(user, version)) {
|
||||
@@ -163,7 +125,7 @@ public class UserDataCommand extends Command implements TabProvider {
|
||||
plugin.getLocales().getLocale("data_deleted",
|
||||
version.toString().split("-")[0],
|
||||
version.toString(),
|
||||
user.getUsername(),
|
||||
user.getName(),
|
||||
user.getUuid().toString())
|
||||
.ifPresent(executor::sendMessage);
|
||||
}
|
||||
@@ -195,9 +157,9 @@ public class UserDataCommand extends Command implements TabProvider {
|
||||
// Save data
|
||||
final RedisManager redis = plugin.getRedisManager();
|
||||
plugin.getDataSyncer().saveData(user, data, (u, s) -> {
|
||||
redis.getUserData(u).ifPresent(d -> redis.setUserData(u, s, RedisKeyType.TTL_1_YEAR));
|
||||
redis.getUserData(u).ifPresent(d -> redis.setUserData(u, s));
|
||||
redis.sendUserDataUpdate(u, s);
|
||||
plugin.getLocales().getLocale("data_restored", u.getUsername(), u.getUuid().toString(),
|
||||
plugin.getLocales().getLocale("data_restored", u.getName(), u.getUuid().toString(),
|
||||
s.getShortId(), s.getId().toString()).ifPresent(executor::sendMessage);
|
||||
});
|
||||
}
|
||||
@@ -219,40 +181,147 @@ public class UserDataCommand extends Command implements TabProvider {
|
||||
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())
|
||||
data.getId().toString(), user.getName(), user.getUuid().toString())
|
||||
.ifPresent(executor::sendMessage);
|
||||
}
|
||||
|
||||
// Dump a snapshot
|
||||
private void dumpSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version, boolean webDump) {
|
||||
// Lookup a snapshot by UUID and dump
|
||||
private void dumpSnapshot(@NotNull CommandUser executor, @NotNull User user, @NotNull UUID version,
|
||||
@NotNull DumpType type) {
|
||||
final Optional<DataSnapshot.Packed> data = plugin.getDatabase().getSnapshot(user, version);
|
||||
if (data.isEmpty()) {
|
||||
plugin.getLocales().getLocale("error_invalid_version_uuid")
|
||||
.ifPresent(executor::sendMessage);
|
||||
return;
|
||||
}
|
||||
this.dumpSnapshot(executor, user, data.get(), type);
|
||||
}
|
||||
|
||||
// Dump the data
|
||||
final DataSnapshot.Packed userData = data.get();
|
||||
final DataDumper dumper = DataDumper.create(userData, user, plugin);
|
||||
// Dump a snapshot
|
||||
private void dumpSnapshot(@NotNull CommandUser executor, @NotNull User user,
|
||||
@NotNull DataSnapshot.Packed userData, @NotNull DumpType type) {
|
||||
final UserDataDumper dumper = UserDataDumper.create(userData, user, plugin);
|
||||
try {
|
||||
plugin.getLocales().getLocale("data_dumped", userData.getShortId(), user.getUsername(),
|
||||
(webDump ? dumper.toWeb() : dumper.toFile())).ifPresent(executor::sendMessage);
|
||||
final String url = type == DumpType.WEB ? dumper.toWeb() : dumper.toFile();
|
||||
plugin.getLocales().getLocale("data_dumped", userData.getShortId(), user.getName())
|
||||
.ifPresent(executor::sendMessage);
|
||||
executor.sendMessage(Component.text(url)
|
||||
.clickEvent(type == DumpType.WEB ? ClickEvent.openUrl(url) : ClickEvent.copyToClipboard(url))
|
||||
.decorate(TextDecoration.UNDERLINED).color(NamedTextColor.GRAY));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "Failed to dump user data", e);
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
@NotNull
|
||||
private CommandProvider view() {
|
||||
return (sub) -> {
|
||||
sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final UUID version = ctx.getArgument("version", UUID.class);
|
||||
viewSnapshot(user(sub, ctx), user, version);
|
||||
}, user("username"), versionUuid());
|
||||
sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
viewLatestSnapshot(user(sub, ctx), user);
|
||||
}, user("username"));
|
||||
};
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider list() {
|
||||
return (sub) -> {
|
||||
sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
listSnapshots(user(sub, ctx), user, 1);
|
||||
}, user("username"));
|
||||
sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final int page = ctx.getArgument("page", Integer.class);
|
||||
listSnapshots(user(sub, ctx), user, page);
|
||||
}, user("username"), BaseCommand.intNum("page", 1));
|
||||
};
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider delete() {
|
||||
return (sub) -> sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final UUID version = ctx.getArgument("version", UUID.class);
|
||||
deleteSnapshot(user(sub, ctx), user, version);
|
||||
}, user("username"), versionUuid());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider save() {
|
||||
return (sub) -> sub.addSyntax((ctx) -> {
|
||||
final OnlineUser user = ctx.getArgument("username", OnlineUser.class);
|
||||
createAndSaveSnapshot(user(sub, ctx), user);
|
||||
}, onlineUser("username"));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider restore() {
|
||||
return (sub) -> sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final UUID version = ctx.getArgument("version", UUID.class);
|
||||
restoreSnapshot(user(sub, ctx), user, version);
|
||||
}, user("username"), versionUuid());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider pin() {
|
||||
return (sub) -> sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final UUID version = ctx.getArgument("version", UUID.class);
|
||||
pinSnapshot(user(sub, ctx), user, version);
|
||||
}, user("username"), versionUuid());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private CommandProvider dump() {
|
||||
return (sub) -> {
|
||||
sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final CommandUser executor = user(sub, ctx);
|
||||
plugin.getRedisManager()
|
||||
.getOnlineUserData(UUID.randomUUID(), user, DataSnapshot.SaveCause.DUMP_COMMAND)
|
||||
.thenAccept((data) -> data
|
||||
.or(() -> plugin.getDatabase().getLatestSnapshot(user))
|
||||
.ifPresentOrElse(
|
||||
(s) -> dumpSnapshot(executor, user, s, DumpType.WEB),
|
||||
() -> plugin.getLocales().getLocale("error_no_data_to_display")
|
||||
.ifPresent(executor::sendMessage)
|
||||
));
|
||||
}, user("username"));
|
||||
sub.addSyntax((ctx) -> {
|
||||
final User user = ctx.getArgument("username", User.class);
|
||||
final UUID version = ctx.getArgument("version", UUID.class);
|
||||
final DumpType type = ctx.getArgument("type", DumpType.class);
|
||||
dumpSnapshot(user(sub, ctx), user, version, type);
|
||||
}, user("username"), versionUuid(), dumpType());
|
||||
};
|
||||
}
|
||||
|
||||
private <S> ArgumentElement<S, DumpType> dumpType() {
|
||||
return new ArgumentElement<>("type", reader -> {
|
||||
final String type = reader.readString();
|
||||
return switch (type.toLowerCase(Locale.ENGLISH)) {
|
||||
case "web" -> DumpType.WEB;
|
||||
case "file" -> DumpType.FILE;
|
||||
default -> throw CommandSyntaxException.BUILT_IN_EXCEPTIONS
|
||||
.dispatcherUnknownArgument().createWithContext(reader);
|
||||
};
|
||||
}, (context, builder) -> {
|
||||
builder.suggest("web");
|
||||
builder.suggest("file");
|
||||
return builder.buildFuture();
|
||||
});
|
||||
}
|
||||
|
||||
enum DumpType {
|
||||
WEB,
|
||||
FILE
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -131,6 +131,15 @@ public interface ConfigProvider {
|
||||
));
|
||||
}
|
||||
|
||||
default void validateConfigFiles() {
|
||||
// Validate server name is default
|
||||
if (getServerName().equals("server")) {
|
||||
getPlugin().log(Level.WARNING, "The server name set in ~/plugins/HuskSync/server.yml appears to" +
|
||||
"be unchanged from the default (currently set to: \"server\"). Please check that this value has" +
|
||||
"been updated to match the case-sensitive ID of this server in your proxy config file!");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a plugin resource
|
||||
*
|
||||
|
||||
@@ -193,14 +193,20 @@ public class Locales {
|
||||
* Displays the notification in the action bar
|
||||
*/
|
||||
ACTION_BAR,
|
||||
|
||||
/**
|
||||
* Displays the notification in the chat
|
||||
*/
|
||||
CHAT,
|
||||
|
||||
/**
|
||||
* Displays the notification in an Advancement Toast
|
||||
*
|
||||
* @deprecated No longer supported
|
||||
*/
|
||||
@Deprecated(since = "3.6.7")
|
||||
TOAST,
|
||||
|
||||
/**
|
||||
* Does not display the notification
|
||||
*/
|
||||
|
||||
@@ -22,13 +22,11 @@ package net.william278.husksync.config;
|
||||
import de.exlll.configlib.Configuration;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.nio.file.Path;
|
||||
|
||||
@Getter
|
||||
@Configuration
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
@@ -66,5 +64,9 @@ public class Server {
|
||||
}
|
||||
return super.equals(other);
|
||||
}
|
||||
|
||||
|
||||
public String getName() {
|
||||
final String envServerName = System.getenv("HUSKSYNC_SERVER_NAME");
|
||||
return envServerName == null ? name : envServerName;
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import de.exlll.configlib.Configuration;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import net.william278.husksync.command.PluginCommand;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.data.Identifier;
|
||||
import net.william278.husksync.database.Database;
|
||||
@@ -63,21 +64,21 @@ public class Settings {
|
||||
private boolean checkForUpdates = true;
|
||||
|
||||
@Comment("Specify a common ID for grouping servers running HuskSync. "
|
||||
+ "Don't modify this unless you know what you're doing!")
|
||||
+ "Don't modify this unless you know what you're doing!")
|
||||
private String clusterId = "";
|
||||
|
||||
@Comment("Enable development debug logging")
|
||||
private boolean debugLogging = false;
|
||||
|
||||
@Comment("Whether to provide modern, rich TAB suggestions for commands (if available)")
|
||||
private boolean brigadierTabCompletion = false;
|
||||
|
||||
@Comment({"Whether to enable the Player Analytics hook.", "Docs: https://william278.net/docs/husksync/plan-hook"})
|
||||
private boolean enablePlanHook = true;
|
||||
|
||||
@Comment("Whether to cancel game event packets directly when handling locked players if ProtocolLib or PacketEvents is installed")
|
||||
private boolean cancelPackets = true;
|
||||
|
||||
@Comment("Add HuskSync commands to this list to prevent them from being registered (e.g. ['userdata'])")
|
||||
@Getter(AccessLevel.NONE)
|
||||
private List<String> disabledCommands = Lists.newArrayList();
|
||||
|
||||
// Database settings
|
||||
@Comment("Database settings")
|
||||
@@ -140,13 +141,16 @@ public class Settings {
|
||||
@Getter(AccessLevel.NONE)
|
||||
private Map<String, String> tableNames = Database.TableName.getDefaults();
|
||||
|
||||
@Comment("Whether to run the creation SQL on the database when the server starts. Don't modify this unless you know what you're doing!")
|
||||
private boolean createTables = true;
|
||||
|
||||
@NotNull
|
||||
public String getTableName(@NotNull Database.TableName tableName) {
|
||||
return tableNames.getOrDefault(tableName.name().toLowerCase(Locale.ENGLISH), tableName.getDefaultName());
|
||||
}
|
||||
}
|
||||
|
||||
// 𝓡𝓮𝓭𝓲𝓼 settings
|
||||
// Redis settings
|
||||
@Comment("Redis settings")
|
||||
private RedisSettings redis = new RedisSettings();
|
||||
|
||||
@@ -155,7 +159,9 @@ public class Settings {
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public static class RedisSettings {
|
||||
|
||||
@Comment("Specify the credentials of your Redis database here. Set \"password\" to '' if you don't have one")
|
||||
@Comment({"Specify the credentials of your Redis server here.",
|
||||
"Set \"user\" to '' if you don't have one or would like to use the default user.",
|
||||
"Set \"password\" to '' if you don't have one."})
|
||||
private RedisCredentials credentials = new RedisCredentials();
|
||||
|
||||
@Getter
|
||||
@@ -164,8 +170,49 @@ public class Settings {
|
||||
public static class RedisCredentials {
|
||||
private String host = "localhost";
|
||||
private int port = 6379;
|
||||
@Comment("Only change the database if you know what you are doing. The default is 0.")
|
||||
private int database = 0;
|
||||
private String user = "";
|
||||
private String password = "";
|
||||
|
||||
@Comment("Use SSL/TLS for encrypted connections.")
|
||||
private boolean useSsl = false;
|
||||
|
||||
@Comment("Connection timeout in milliseconds.")
|
||||
private int connectionTimeout = 2000;
|
||||
|
||||
@Comment("Socket (read/write) timeout in milliseconds.")
|
||||
private int socketTimeout = 2000;
|
||||
|
||||
@Comment("Max number of connections in the pool.")
|
||||
private int maxTotalConnections = 50;
|
||||
|
||||
@Comment("Max number of idle connections in the pool.")
|
||||
private int maxIdleConnections = 8;
|
||||
|
||||
@Comment("Min number of idle connections in the pool.")
|
||||
private int minIdleConnections = 2;
|
||||
|
||||
@Comment("Enable health checks when borrowing connections from the pool.")
|
||||
private boolean testOnBorrow = true;
|
||||
|
||||
@Comment("Enable health checks when returning connections to the pool.")
|
||||
private boolean testOnReturn = true;
|
||||
|
||||
@Comment("Enable periodic idle connection health checks.")
|
||||
private boolean testWhileIdle = true;
|
||||
|
||||
@Comment("Min evictable idle time (ms) before a connection is eligible for eviction.")
|
||||
private long minEvictableIdleTimeMillis = 60000;
|
||||
|
||||
@Comment("Time (ms) between eviction runs.")
|
||||
private long timeBetweenEvictionRunsMillis = 30000;
|
||||
|
||||
@Comment("Number of retries for commands when connection fails.")
|
||||
private int maxRetries = 3;
|
||||
|
||||
@Comment("Base backoff time in ms for retries (exponential backoff multiplier).")
|
||||
private int retryBackoffMillis = 200;
|
||||
}
|
||||
|
||||
@Comment("Options for if you're using Redis sentinel. Don't modify this unless you know what you're doing!")
|
||||
@@ -185,7 +232,7 @@ public class Settings {
|
||||
}
|
||||
|
||||
// Synchronization settings
|
||||
@Comment("Redis settings")
|
||||
@Comment("Data syncing settings")
|
||||
private SynchronizationSettings synchronization = new SynchronizationSettings();
|
||||
|
||||
@Getter
|
||||
@@ -228,7 +275,7 @@ public class Settings {
|
||||
private boolean enabled = false;
|
||||
|
||||
@Comment("What items to save in death snapshots? (DROPS or ITEMS_TO_KEEP). "
|
||||
+ "Note that ITEMS_TO_KEEP (suggested for keepInventory servers) requires a Paper 1.19.4+ server.")
|
||||
+ "Note that ITEMS_TO_KEEP (suggested for keepInventory servers) requires a Paper 1.19.4+ server.")
|
||||
private DeathItemsMode itemsToSave = DeathItemsMode.DROPS;
|
||||
|
||||
@Comment("Should a death snapshot still be created even if the items to save on the player's death are empty?")
|
||||
@@ -249,14 +296,14 @@ public class Settings {
|
||||
@Comment("Whether to use the snappy data compression algorithm. Keep on unless you know what you're doing")
|
||||
private boolean compressData = true;
|
||||
|
||||
@Comment("Where to display sync notifications (ACTION_BAR, CHAT, TOAST or NONE)")
|
||||
@Comment("Where to display sync notifications (ACTION_BAR, CHAT or NONE)")
|
||||
private Locales.NotificationSlot notificationDisplaySlot = Locales.NotificationSlot.ACTION_BAR;
|
||||
|
||||
@Comment("Persist maps locked in a Cartography Table to let them be viewed on any server")
|
||||
private boolean persistLockedMaps = true;
|
||||
|
||||
@Comment("If using the DELAY sync method, how long should this server listen for Redis key data updates before "
|
||||
+ "pulling data from the database instead (i.e., if the user did not change servers).")
|
||||
+ "pulling data from the database instead (i.e., if the user did not change servers).")
|
||||
private int networkLatencyMilliseconds = 500;
|
||||
|
||||
@Comment({"Which data types to synchronize.", "Docs: https://william278.net/docs/husksync/sync-features"})
|
||||
@@ -266,15 +313,60 @@ public class Settings {
|
||||
@Comment("Commands which should be blocked before a player has finished syncing (Use * to block all commands)")
|
||||
private List<String> blacklistedCommandsWhileLocked = new ArrayList<>(List.of("*"));
|
||||
|
||||
@Comment({"For attribute syncing, which attributes should be ignored/skipped when syncing",
|
||||
"(e.g. ['minecraft:generic.max_health', 'minecraft:generic.attack_damage'])"})
|
||||
@Getter(AccessLevel.NONE)
|
||||
private List<String> ignoredAttributes = new ArrayList<>(List.of(""));
|
||||
@Comment("Configuration for how to sync attributes")
|
||||
private AttributeSettings attributes = new AttributeSettings();
|
||||
|
||||
@Getter
|
||||
@Configuration
|
||||
@NoArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public static class AttributeSettings {
|
||||
|
||||
@Comment({"Which attribute types should be saved as part of attribute syncing. Supports wildcard matching.",
|
||||
"(e.g. ['minecraft:generic.max_health', 'minecraft:generic.*'])"})
|
||||
@Getter(AccessLevel.NONE)
|
||||
private List<String> syncedAttributes = new ArrayList<>(List.of(
|
||||
"minecraft:generic.max_health", "minecraft:max_health",
|
||||
"minecraft:generic.max_absorption", "minecraft:max_absorption",
|
||||
"minecraft:generic.luck", "minecraft:luck",
|
||||
"minecraft:generic.scale", "minecraft:scale",
|
||||
"minecraft:generic.step_height", "minecraft:step_height",
|
||||
"minecraft:generic.gravity", "minecraft:gravity"
|
||||
));
|
||||
|
||||
@Comment({"Which attribute modifiers should be saved. Supports wildcard matching.",
|
||||
"(e.g. ['minecraft:effect.speed', 'minecraft:effect.*'])"})
|
||||
@Getter(AccessLevel.NONE)
|
||||
private List<String> ignoredModifiers = new ArrayList<>(List.of(
|
||||
"minecraft:effect.*", "minecraft:creative_mode_*"
|
||||
));
|
||||
|
||||
private boolean matchesWildcard(@NotNull String pat, @NotNull String value) {
|
||||
if (!pat.contains(":")) {
|
||||
pat = "minecraft:%s".formatted(pat);
|
||||
}
|
||||
if (!value.contains(":")) {
|
||||
value = "minecraft:%s".formatted(value);
|
||||
}
|
||||
return pat.contains("*") ? value.matches(pat.replace("*", ".*")) : pat.equals(value);
|
||||
}
|
||||
|
||||
public boolean isIgnoredAttribute(@NotNull String attribute) {
|
||||
return syncedAttributes.stream().noneMatch(wildcard -> matchesWildcard(wildcard, attribute));
|
||||
}
|
||||
|
||||
public boolean isIgnoredModifier(@NotNull String modifier) {
|
||||
return ignoredModifiers.stream().anyMatch(wildcard -> matchesWildcard(wildcard, modifier));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Comment("Event priorities for listeners (HIGHEST, NORMAL, LOWEST). Change if you encounter plugin conflicts")
|
||||
@Getter(AccessLevel.NONE)
|
||||
private Map<String, String> eventPriorities = EventListener.ListenerType.getDefaults();
|
||||
|
||||
@Comment("Enable check-in petitions for data syncing (don't change this unless you know what you're doing)")
|
||||
private boolean checkinPetitions = false;
|
||||
|
||||
public boolean doAutoPin(@NotNull DataSnapshot.SaveCause cause) {
|
||||
return autoPinnedSaveCauses.contains(cause.name());
|
||||
}
|
||||
@@ -283,10 +375,6 @@ public class Settings {
|
||||
return id.isCustom() || features.getOrDefault(id.getKeyValue(), id.isEnabledByDefault());
|
||||
}
|
||||
|
||||
public boolean isIgnoredAttribute(@NotNull String attribute) {
|
||||
return ignoredAttributes.contains(attribute);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public EventListener.Priority getEventPriority(@NotNull EventListener.ListenerType type) {
|
||||
try {
|
||||
@@ -297,4 +385,10 @@ public class Settings {
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCommandDisabled(@NotNull PluginCommand command) {
|
||||
return disabledCommands.stream().map(c -> c.startsWith("/") ? c.substring(1) : c)
|
||||
.anyMatch(c -> c.equalsIgnoreCase(command.getName()) || command.getAliases().contains(c));
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ package net.william278.husksync.data;
|
||||
|
||||
import com.google.common.collect.Sets;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.experimental.Accessors;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
@@ -78,6 +82,7 @@ public interface Data {
|
||||
*/
|
||||
interface Inventory extends Items {
|
||||
|
||||
int INVENTORY_SLOT_COUNT = 41;
|
||||
String ITEMS_TAG = "items";
|
||||
String HELD_ITEM_SLOT_TAG = "held_item_slot";
|
||||
|
||||
@@ -110,7 +115,7 @@ public interface Data {
|
||||
* Data container holding data for ender chests
|
||||
*/
|
||||
interface EnderChest extends Items {
|
||||
|
||||
int ENDER_CHEST_SLOT_COUNT = 27;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -126,7 +131,7 @@ public interface Data {
|
||||
/**
|
||||
* Represents a potion effect
|
||||
*
|
||||
* @param type the type of potion effect
|
||||
* @param type the key 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
|
||||
@@ -149,14 +154,14 @@ public interface Data {
|
||||
*/
|
||||
interface Advancements extends Data {
|
||||
|
||||
String RECIPE_ADVANCEMENT = "minecraft:recipe";
|
||||
|
||||
@NotNull
|
||||
List<Advancement> getCompleted();
|
||||
|
||||
@NotNull
|
||||
default List<Advancement> getCompletedExcludingRecipes() {
|
||||
return getCompleted().stream()
|
||||
.filter(advancement -> !advancement.getKey().startsWith("minecraft:recipe"))
|
||||
.collect(Collectors.toList());
|
||||
return getCompleted().stream().filter(adv -> !adv.getKey().startsWith(RECIPE_ADVANCEMENT)).toList();
|
||||
}
|
||||
|
||||
void setCompleted(@NotNull List<Advancement> completed);
|
||||
@@ -333,27 +338,78 @@ public interface Data {
|
||||
|
||||
}
|
||||
|
||||
record Modifier(
|
||||
@NotNull UUID uuid,
|
||||
@NotNull String name,
|
||||
double amount,
|
||||
@SerializedName("operation") int operationType,
|
||||
@SerializedName("equipment_slot") int equipmentSlot
|
||||
) {
|
||||
@Getter
|
||||
@Accessors(fluent = true)
|
||||
@RequiredArgsConstructor
|
||||
final class Modifier {
|
||||
final static String ANY_EQUIPMENT_SLOT_GROUP = "any";
|
||||
|
||||
@Getter(AccessLevel.NONE)
|
||||
@Nullable
|
||||
@SerializedName("uuid")
|
||||
private UUID uuid = null;
|
||||
|
||||
// Since 1.21.1: Name, amount, operation, slotGroup
|
||||
@SerializedName("name")
|
||||
private String name;
|
||||
|
||||
@SerializedName("amount")
|
||||
private double amount;
|
||||
|
||||
@SerializedName("operation")
|
||||
private int operation;
|
||||
|
||||
@SerializedName("equipment_slot")
|
||||
@Deprecated(since = "3.7")
|
||||
private int equipmentSlot;
|
||||
|
||||
@SerializedName("equipment_slot_group")
|
||||
private String slotGroup = ANY_EQUIPMENT_SLOT_GROUP;
|
||||
|
||||
public Modifier(@NotNull String name, double amount, int operation, @NotNull String slotGroup) {
|
||||
this.name = name;
|
||||
this.amount = amount;
|
||||
this.operation = operation;
|
||||
this.slotGroup = slotGroup;
|
||||
}
|
||||
|
||||
@Deprecated(since = "3.7")
|
||||
public Modifier(@NotNull UUID uuid, @NotNull String name, double amount, int operation, int equipmentSlot) {
|
||||
this.name = name;
|
||||
this.amount = amount;
|
||||
this.operation = operation;
|
||||
this.equipmentSlot = equipmentSlot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
return obj instanceof Modifier modifier && modifier.uuid.equals(uuid);
|
||||
if (obj instanceof Modifier other) {
|
||||
if (uuid != null && other.uuid != null) {
|
||||
return uuid.equals(other.uuid);
|
||||
}
|
||||
return name.equals(other.name);
|
||||
}
|
||||
return super.equals(obj);
|
||||
}
|
||||
|
||||
public double modify(double value) {
|
||||
return switch (operationType) {
|
||||
return switch (operation) {
|
||||
case 0 -> value + amount;
|
||||
case 1 -> value * amount;
|
||||
case 2 -> value * (1 + amount);
|
||||
default -> value;
|
||||
};
|
||||
}
|
||||
|
||||
public boolean hasUuid() {
|
||||
return uuid != null;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public UUID uuid() {
|
||||
return uuid != null ? uuid : UUID.nameUUIDFromBytes(name.getBytes());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
default Optional<Attribute> getAttribute(@NotNull Key key) {
|
||||
|
||||
@@ -45,13 +45,13 @@ public class DataException extends IllegalStateException {
|
||||
@AllArgsConstructor
|
||||
public enum Reason {
|
||||
INVALID_MINECRAFT_VERSION((plugin, snapshot) -> String.format("The Minecraft version of the snapshot (%s) is " +
|
||||
"newer than the server's version (%s). Ensure each server is on the same version of Minecraft.",
|
||||
"newer than the server's version (%s). Ensure each server is on the same version of Minecraft.",
|
||||
snapshot.getMinecraftVersion(), plugin.getMinecraftVersion())),
|
||||
INVALID_FORMAT_VERSION((plugin, snapshot) -> String.format("The format version of the snapshot (%s) is newer " +
|
||||
"than the server's version (%s). Ensure each server is running the same version of HuskSync.",
|
||||
"than the server's version (%s). Ensure each server is running the same version of HuskSync.",
|
||||
snapshot.getFormatVersion(), DataSnapshot.CURRENT_FORMAT_VERSION)),
|
||||
INVALID_PLATFORM_TYPE((plugin, snapshot) -> String.format("The platform type of the snapshot (%s) does " +
|
||||
"not match the server's platform type (%s). Ensure each server has the same platform type.",
|
||||
"not match the server's platform type (%s). Ensure each server has the same platform type.",
|
||||
snapshot.getPlatformType(), plugin.getPlatformType())),
|
||||
NO_LEGACY_CONVERTER((plugin, snapshot) -> String.format("No legacy converter to convert format version: %s",
|
||||
snapshot.getFormatVersion()));
|
||||
|
||||
@@ -30,8 +30,11 @@ public interface DataHolder {
|
||||
@NotNull
|
||||
Map<Identifier, Data> getData();
|
||||
|
||||
default Optional<? extends Data> getData(@NotNull Identifier identifier) {
|
||||
return Optional.ofNullable(getData().get(identifier));
|
||||
default Optional<? extends Data> getData(@NotNull Identifier id) {
|
||||
if (getData().containsKey(id)) {
|
||||
return Optional.of(getData().get(id));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
default void setData(@NotNull Identifier identifier, @NotNull Data data) {
|
||||
|
||||
@@ -391,23 +391,26 @@ public class DataSnapshot {
|
||||
@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(), getMinecraftVersion())
|
||||
)).orElse(null))
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
.filter(e -> plugin.getIdentifier(e.getKey()).isPresent())
|
||||
.map(entry -> Map.entry(plugin.getIdentifier(entry.getKey()).orElseThrow(), entry.getValue()))
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> plugin.deserializeData(entry.getKey(), entry.getValue(), getMinecraftVersion()),
|
||||
(a, b) -> a,
|
||||
HashMap::new
|
||||
));
|
||||
}
|
||||
|
||||
@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));
|
||||
.collect(Collectors.toMap(
|
||||
entry -> entry.getKey().toString(),
|
||||
entry -> plugin.serializeData(entry.getKey(), entry.getValue()),
|
||||
(a, b) -> a,
|
||||
HashMap::new
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -421,6 +424,20 @@ public class DataSnapshot {
|
||||
return deserialized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a sorted iterable of the snapshots the snapshot is holding
|
||||
*
|
||||
* @return The data map
|
||||
* @since 3.8.2
|
||||
*/
|
||||
@NotNull
|
||||
@ApiStatus.Internal
|
||||
public Iterable<Map.Entry<Identifier, Data>> getSortedIterable() {
|
||||
final TreeMap<Identifier, Data> tree = Maps.newTreeMap(SerializerRegistry.DEPENDENCY_ORDER_COMPARATOR);
|
||||
tree.putAll(deserialized);
|
||||
return tree.entrySet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Pack the {@link DataSnapshot} into a {@link DataSnapshot.Packed packed} snapshot
|
||||
*
|
||||
@@ -880,6 +897,20 @@ public class DataSnapshot {
|
||||
*/
|
||||
public static final SaveCause BACKUP_RESTORE = of("BACKUP_RESTORE");
|
||||
|
||||
/**
|
||||
* Indicates data was saved from executing the {@code /userdata save} command
|
||||
*
|
||||
* @since 3.8
|
||||
*/
|
||||
public static final SaveCause SAVE_COMMAND = of("SAVE_COMMAND", true);
|
||||
|
||||
/**
|
||||
* Indicates data was saved from executing the {@code /userdata dump} command
|
||||
*
|
||||
* @since 3.8
|
||||
*/
|
||||
public static final SaveCause DUMP_COMMAND = of("DUMP_COMMAND", true);
|
||||
|
||||
/**
|
||||
* Indicates data was saved by an API call
|
||||
*
|
||||
@@ -913,6 +944,8 @@ public class DataSnapshot {
|
||||
|
||||
private final boolean fireDataSaveEvent;
|
||||
|
||||
private static Map<String, SaveCause> registry;
|
||||
|
||||
/**
|
||||
* Get or create a {@link SaveCause} from a name
|
||||
*
|
||||
@@ -921,7 +954,7 @@ public class DataSnapshot {
|
||||
*/
|
||||
@NotNull
|
||||
public static SaveCause of(@NotNull String name) {
|
||||
return new SaveCause(name.length() > 32 ? name.substring(0, 31) : name, true);
|
||||
return of(name, true);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -933,7 +966,14 @@ public class DataSnapshot {
|
||||
*/
|
||||
@NotNull
|
||||
public static SaveCause of(@NotNull String name, boolean firesSaveEvent) {
|
||||
return new SaveCause(name.length() > 32 ? name.substring(0, 31) : name, firesSaveEvent);
|
||||
name = name.length() > 32 ? name.substring(0, 31) : name;
|
||||
|
||||
if (registry == null) registry = new HashMap<>();
|
||||
if (registry.containsKey(name)) return registry.get(name);
|
||||
|
||||
SaveCause cause = new SaveCause(name, firesSaveEvent);
|
||||
registry.put(cause.name(), cause);
|
||||
return cause;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@@ -944,11 +984,10 @@ public class DataSnapshot {
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@ApiStatus.Obsolete
|
||||
public static SaveCause[] values() {
|
||||
return new SaveCause[]{
|
||||
DISCONNECT, WORLD_SAVE, DEATH, SERVER_SHUTDOWN, INVENTORY_COMMAND, ENDERCHEST_COMMAND,
|
||||
BACKUP_RESTORE, API, MPDB_MIGRATION, LEGACY_MIGRATION, CONVERTED_FROM_V2
|
||||
};
|
||||
if (registry == null) registry = new HashMap<>();
|
||||
return registry.values().toArray(new SaveCause[0]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,40 +19,88 @@
|
||||
|
||||
package net.william278.husksync.data;
|
||||
|
||||
import lombok.*;
|
||||
import net.kyori.adventure.key.InvalidKeyException;
|
||||
import net.kyori.adventure.key.Key;
|
||||
import net.kyori.adventure.key.KeyPattern;
|
||||
import org.intellij.lang.annotations.Subst;
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Identifiers of different types of {@link Data}s
|
||||
*/
|
||||
public class Identifier {
|
||||
@Getter
|
||||
public class Identifier implements Comparable<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 ATTRIBUTES = huskSync("attributes", true);
|
||||
public static Identifier EXPERIENCE = huskSync("experience", true);
|
||||
public static Identifier GAME_MODE = huskSync("game_mode", true);
|
||||
public static Identifier FLIGHT_STATUS = huskSync("flight_status", true);
|
||||
public static Identifier PERSISTENT_DATA = huskSync("persistent_data", true);
|
||||
// Namespace for built-in identifiers
|
||||
private static final @KeyPattern String DEFAULT_NAMESPACE = "husksync";
|
||||
|
||||
// Built-in identifiers
|
||||
public static final Identifier PERSISTENT_DATA = huskSync("persistent_data", true);
|
||||
public static final Identifier INVENTORY = huskSync("inventory", true);
|
||||
public static final Identifier ENDER_CHEST = huskSync("ender_chest", true);
|
||||
public static final Identifier ADVANCEMENTS = huskSync("advancements", true);
|
||||
public static final Identifier STATISTICS = huskSync("statistics", true);
|
||||
public static final Identifier POTION_EFFECTS = huskSync("potion_effects", true);
|
||||
public static final Identifier GAME_MODE = huskSync("game_mode", true);
|
||||
public static final Identifier FLIGHT_STATUS = huskSync("flight_status", true,
|
||||
Dependency.optional("game_mode")
|
||||
);
|
||||
public static final Identifier ATTRIBUTES = huskSync("attributes", true,
|
||||
Dependency.optional("inventory"),
|
||||
Dependency.optional("potion_effects")
|
||||
);
|
||||
public static final Identifier HEALTH = huskSync("health", true,
|
||||
Dependency.optional("attributes")
|
||||
);
|
||||
public static final Identifier HUNGER = huskSync("hunger", true,
|
||||
Dependency.optional("attributes")
|
||||
);
|
||||
public static final Identifier EXPERIENCE = huskSync("experience", true,
|
||||
Dependency.optional("advancements")
|
||||
);
|
||||
public static final Identifier LOCATION = huskSync("location", false,
|
||||
Dependency.optional("flight_status"),
|
||||
Dependency.optional("potion_effects")
|
||||
);
|
||||
|
||||
private final Key key;
|
||||
private final boolean configDefault;
|
||||
private final boolean enabledByDefault;
|
||||
@Getter
|
||||
private final Set<Dependency> dependencies;
|
||||
@Setter
|
||||
@Getter
|
||||
public boolean enabled;
|
||||
|
||||
private Identifier(@NotNull Key key, boolean configDefault) {
|
||||
private Identifier(@NotNull Key key, boolean enabledByDefault, @NotNull Set<Dependency> dependencies) {
|
||||
this.key = key;
|
||||
this.configDefault = configDefault;
|
||||
this.enabledByDefault = enabledByDefault;
|
||||
this.enabled = enabledByDefault;
|
||||
this.dependencies = dependencies;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an identifier from a {@link Key}
|
||||
*
|
||||
* @param key the key
|
||||
* @param dependencies the dependencies
|
||||
* @return the identifier
|
||||
* @since 3.5.4
|
||||
*/
|
||||
@NotNull
|
||||
public static Identifier from(@NotNull Key key, @NotNull Set<Dependency> dependencies) {
|
||||
if (key.namespace().equals(DEFAULT_NAMESPACE)) {
|
||||
throw new IllegalArgumentException("Cannot register with %s as key namespace!".formatted(key.namespace()));
|
||||
}
|
||||
return new Identifier(key, true, dependencies);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,10 +112,7 @@ public class Identifier {
|
||||
*/
|
||||
@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);
|
||||
return from(key, Collections.emptySet());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -83,25 +128,34 @@ public class Identifier {
|
||||
return from(Key.key(plugin, name));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an identifier from a namespace, value, and dependencies
|
||||
*
|
||||
* @param plugin the namespace
|
||||
* @param name the value
|
||||
* @param dependencies the dependencies
|
||||
* @return the identifier
|
||||
* @since 3.5.4
|
||||
*/
|
||||
@NotNull
|
||||
public static Identifier from(@Subst("plugin") @NotNull String plugin, @Subst("null") @NotNull String name,
|
||||
@NotNull Set<Dependency> dependencies) {
|
||||
return from(Key.key(plugin, name), dependencies);
|
||||
}
|
||||
|
||||
// Return an identifier with a HuskSync namespace
|
||||
@NotNull
|
||||
private static Identifier huskSync(@Subst("null") @NotNull String name,
|
||||
boolean configDefault) throws InvalidKeyException {
|
||||
return new Identifier(Key.key("husksync", name), configDefault);
|
||||
return new Identifier(Key.key(DEFAULT_NAMESPACE, name), configDefault, Collections.emptySet());
|
||||
}
|
||||
|
||||
// Return an identifier with a HuskSync namespace
|
||||
@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);
|
||||
private static Identifier huskSync(@Subst("null") @NotNull String name,
|
||||
@SuppressWarnings("SameParameterValue") boolean configDefault,
|
||||
@NotNull Dependency... dependents) throws InvalidKeyException {
|
||||
return new Identifier(Key.key(DEFAULT_NAMESPACE, name), configDefault, Set.of(dependents));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +176,17 @@ public class Identifier {
|
||||
.toArray(Map.Entry[]::new));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the identifier depends on the given identifier
|
||||
*
|
||||
* @param identifier the identifier to check
|
||||
* @return {@code true} if the identifier depends on the given identifier
|
||||
* @since 3.5.4
|
||||
*/
|
||||
public boolean dependsOn(@NotNull Identifier identifier) {
|
||||
return dependencies.contains(Dependency.required(identifier.key));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the namespace of the identifier
|
||||
*
|
||||
@@ -148,13 +213,30 @@ public class Identifier {
|
||||
* @return {@code false} if {@link #getKeyNamespace()} returns "husksync"; {@code true} otherwise
|
||||
*/
|
||||
public boolean isCustom() {
|
||||
return !getKeyNamespace().equals("husksync");
|
||||
return !getKeyNamespace().equals(DEFAULT_NAMESPACE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the minimal string representation of this key.
|
||||
* <p>
|
||||
* If the namespace of the key is {@link #DEFAULT_NAMESPACE}, only the key value will be returned.
|
||||
*
|
||||
* @return the minimal string key representation
|
||||
* @since 3.8
|
||||
*/
|
||||
@NotNull
|
||||
public String asMinimalString() {
|
||||
if (getKey().namespace().equals(DEFAULT_NAMESPACE)) {
|
||||
return getKey().value();
|
||||
}
|
||||
return getKey().asString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the identifier as a string (the key)
|
||||
*
|
||||
* @return the identifier as a string
|
||||
* @since 3.0
|
||||
*/
|
||||
@NotNull
|
||||
@Override
|
||||
@@ -163,17 +245,123 @@ public class Identifier {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns {@code true} if the given object is an identifier with the same key as this identifier
|
||||
* Return whether this Identifier is equal to another Identifier
|
||||
*
|
||||
* @param obj the object to compare
|
||||
* @return {@code true} if the given object is an identifier with the same key as this identifier
|
||||
* @param obj another object
|
||||
* @return {@code true} if this identifier matches the identifier of {@code obj}
|
||||
* @since 3.8
|
||||
*/
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
public boolean equals(@Nullable Object obj) {
|
||||
if (obj instanceof Identifier other) {
|
||||
return key.equals(other.key);
|
||||
return asMinimalString().equals(other.asMinimalString());
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the hash code of the Identifier (equivalent to {@link #asMinimalString()}->{@code #hashCode()}
|
||||
*
|
||||
* @return the hash code
|
||||
* @since 3.8
|
||||
*/
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return asMinimalString().hashCode();
|
||||
}
|
||||
|
||||
// Get the config entry for the identifier
|
||||
@NotNull
|
||||
private Map.Entry<String, Boolean> getConfigEntry() {
|
||||
return Map.entry(getKeyValue(), enabledByDefault);
|
||||
}
|
||||
|
||||
// Comparable; always sort this Identifier after any dependencies
|
||||
@Override
|
||||
public int compareTo(@NotNull Identifier o) {
|
||||
if (this.dependsOn(o)) return 1;
|
||||
if (o.dependsOn(this)) return -1;
|
||||
return this.key.compareTo(o.key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compares two identifiers based on their dependencies.
|
||||
* <p>
|
||||
* If this identifier contains a dependency on the other, it should come after & vice versa
|
||||
*
|
||||
* @since 3.5.4
|
||||
*/
|
||||
@NoArgsConstructor(access = AccessLevel.PACKAGE)
|
||||
static class DependencyOrderComparator implements Comparator<Identifier> {
|
||||
|
||||
@Override
|
||||
public int compare(@NotNull Identifier i1, @NotNull Identifier i2) {
|
||||
if (i1.equals(i2)) {
|
||||
return 0;
|
||||
}
|
||||
if (i1.dependsOn(i2)) {
|
||||
if (i2.dependsOn(i1)) {
|
||||
throw new IllegalArgumentException(
|
||||
"Found circular dependency between %s and %s".formatted(i1.getKey(), i2.getKey())
|
||||
);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a data dependency of an identifier, used to determine the order in which data is applied to users
|
||||
*
|
||||
* @since 3.5.4
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public static class Dependency {
|
||||
/**
|
||||
* Key of the data dependency see {@code Identifier#key()}
|
||||
*/
|
||||
private Key key;
|
||||
/**
|
||||
* Whether the data dependency is required to be present & enabled for the dependant data to enabled
|
||||
*/
|
||||
private boolean required;
|
||||
|
||||
@NotNull
|
||||
protected static Dependency required(@NotNull Key identifier) {
|
||||
return new Dependency(identifier, true);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static Dependency optional(@NotNull Key identifier) {
|
||||
return new Dependency(identifier, false);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
private static Dependency required(@Subst("null") @NotNull String identifier) {
|
||||
return required(Key.key("husksync", identifier));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static Dependency optional(@Subst("null") @NotNull String identifier) {
|
||||
return optional(Key.key("husksync", identifier));
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj instanceof Dependency other) {
|
||||
return key.equals(other.key);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return key.toString().hashCode();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
package net.william278.husksync.data;
|
||||
|
||||
import net.william278.desertwell.util.Version;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.adapter.Adaptable;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public interface Serializer<T extends Data> {
|
||||
@@ -46,4 +48,26 @@ public interface Serializer<T extends Data> {
|
||||
}
|
||||
|
||||
|
||||
class Json<T extends Data & Adaptable> implements Serializer<T> {
|
||||
|
||||
private final HuskSync plugin;
|
||||
private final Class<T> type;
|
||||
|
||||
public Json(@NotNull HuskSync plugin, @NotNull Class<T> type) {
|
||||
this.type = type;
|
||||
this.plugin = plugin;
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.data;
|
||||
|
||||
import net.william278.desertwell.util.Version;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import org.jetbrains.annotations.ApiStatus;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface SerializerRegistry {
|
||||
|
||||
// Comparator for ordering identifiers based on dependency
|
||||
@NotNull
|
||||
@ApiStatus.Internal
|
||||
Comparator<Identifier> DEPENDENCY_ORDER_COMPARATOR = new Identifier.DependencyOrderComparator();
|
||||
|
||||
/**
|
||||
* Returns the data serializer for the given {@link Identifier}
|
||||
*
|
||||
* @since 3.0
|
||||
*/
|
||||
@NotNull
|
||||
<T extends Data> Map<Identifier, Serializer<T>> getSerializers();
|
||||
|
||||
/**
|
||||
* Register a data serializer for the given {@link Identifier}
|
||||
*
|
||||
* @param id the {@link Identifier}
|
||||
* @param serializer the {@link Serializer}
|
||||
* @since 3.0
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
default void registerSerializer(@NotNull Identifier id, @NotNull Serializer<? extends Data> serializer) {
|
||||
if (id.isCustom()) {
|
||||
getPlugin().log(Level.INFO, "Registered custom data type: %s".formatted(id));
|
||||
}
|
||||
id.setEnabled(id.isCustom() || getPlugin().getSettings().getSynchronization().isFeatureEnabled(id));
|
||||
getSerializers().put(id, (Serializer<Data>) serializer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure dependencies for identifiers that have required dependencies are met
|
||||
* <p>
|
||||
* This checks the dependencies of all registered identifiers and throws an {@link IllegalStateException}
|
||||
* if a dependency has not been registered or enabled via the config
|
||||
*
|
||||
* @since 3.5.4
|
||||
*/
|
||||
default void validateDependencies() throws IllegalStateException {
|
||||
getSerializers().keySet().stream().filter(Identifier::isEnabled)
|
||||
.forEach(identifier -> {
|
||||
final List<String> unmet = identifier.getDependencies().stream()
|
||||
.filter(Identifier.Dependency::isRequired)
|
||||
.filter(dep -> !isDataTypeAvailable(dep.getKey().asString()))
|
||||
.map(dep -> dep.getKey().asString()).toList();
|
||||
if (!unmet.isEmpty()) {
|
||||
identifier.setEnabled(false);
|
||||
getPlugin().log(Level.WARNING, "Disabled %s syncing as the following types need to be on: %s"
|
||||
.formatted(identifier, String.join(", ", unmet)));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the {@link Identifier} for the given key
|
||||
*
|
||||
* @since 3.0
|
||||
*/
|
||||
default Optional<Identifier> getIdentifier(@NotNull String key) {
|
||||
return getSerializers().keySet().stream().filter(e -> e.toString().equals(key)).findFirst();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a data serializer for the given {@link Identifier}
|
||||
*
|
||||
* @param identifier the {@link Identifier} to get the serializer for
|
||||
* @return the {@link Serializer} for the given {@link Identifier}
|
||||
* @since 3.5.4
|
||||
*/
|
||||
default Optional<Serializer<Data>> getSerializer(@NotNull Identifier identifier) {
|
||||
return Optional.ofNullable(getSerializers().get(identifier));
|
||||
}
|
||||
|
||||
/**
|
||||
* Serialize data for the given {@link Identifier}
|
||||
*
|
||||
* @param identifier the {@link Identifier} to serialize data for
|
||||
* @param data the data to serialize
|
||||
* @return the serialized data
|
||||
* @throws IllegalArgumentException if no serializer is found for the given {@link Identifier}
|
||||
* @since 3.5.4
|
||||
*/
|
||||
@NotNull
|
||||
default String serializeData(@NotNull Identifier identifier, @NotNull Data data) throws IllegalStateException {
|
||||
return getSerializer(identifier).map(serializer -> serializer.serialize(data))
|
||||
.orElseThrow(() -> new IllegalStateException("No serializer found for %s".formatted(identifier)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize data of a given {@link Version Minecraft version} for the given {@link Identifier data identifier}
|
||||
*
|
||||
* @param identifier the {@link Identifier} to deserialize data for
|
||||
* @param data the data to deserialize
|
||||
* @param dataMcVersion the Minecraft version of the data
|
||||
* @return the deserialized data
|
||||
* @throws IllegalStateException if no serializer is found for the given {@link Identifier}
|
||||
* @since 3.6.4
|
||||
*/
|
||||
@NotNull
|
||||
default Data deserializeData(@NotNull Identifier identifier, @NotNull String data,
|
||||
@NotNull Version dataMcVersion) throws IllegalStateException {
|
||||
return getSerializer(identifier).map(serializer -> serializer.deserialize(data, dataMcVersion)).orElseThrow(
|
||||
() -> new IllegalStateException("No serializer found for %s".formatted(identifier))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deserialize data for the given {@link Identifier data identifier}
|
||||
*
|
||||
* @param identifier the {@link Identifier} to deserialize data for
|
||||
* @param data the data to deserialize
|
||||
* @return the deserialized data
|
||||
* @since 3.5.4
|
||||
* @deprecated Use {@link #deserializeData(Identifier, String, Version)} instead
|
||||
*/
|
||||
@NotNull
|
||||
@Deprecated(since = "3.6.5")
|
||||
default Data deserializeData(@NotNull Identifier identifier, @NotNull String data) {
|
||||
return deserializeData(identifier, data, getPlugin().getMinecraftVersion());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of registered data types, in dependency order
|
||||
*
|
||||
* @return the list of registered data types
|
||||
* @since 3.0
|
||||
*/
|
||||
@NotNull
|
||||
default List<Identifier> getRegisteredDataTypes() {
|
||||
return getSerializers().keySet().stream().sorted(DEPENDENCY_ORDER_COMPARATOR).toList();
|
||||
}
|
||||
|
||||
// Returns if a data type is available and enabled in the config
|
||||
private boolean isDataTypeAvailable(@NotNull String key) {
|
||||
return getIdentifier(key).map(Identifier::isEnabled).orElse(false);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
HuskSync getPlugin();
|
||||
|
||||
}
|
||||
@@ -27,6 +27,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.logging.Level;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* A holder of data in the form of {@link Data}s, which can be synced
|
||||
@@ -34,7 +35,7 @@ import java.util.logging.Level;
|
||||
public interface UserDataHolder extends DataHolder {
|
||||
|
||||
/**
|
||||
* Get the data that is enabled for syncing in the config
|
||||
* Get the data enabled for syncing in the config
|
||||
*
|
||||
* @return the data that is enabled for syncing
|
||||
* @since 3.0
|
||||
@@ -43,10 +44,14 @@ public interface UserDataHolder extends DataHolder {
|
||||
@NotNull
|
||||
default Map<Identifier, Data> getData() {
|
||||
return getPlugin().getRegisteredDataTypes().stream()
|
||||
.filter(type -> type.isCustom() || getPlugin().getSettings().getSynchronization().isFeatureEnabled(type))
|
||||
.filter(Identifier::isEnabled)
|
||||
.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);
|
||||
.collect(Collectors.toMap(
|
||||
Map.Entry::getKey,
|
||||
entry -> entry.getValue().get(),
|
||||
(a, b) -> a, HashMap::new
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -75,11 +80,21 @@ public interface UserDataHolder extends DataHolder {
|
||||
return DataSnapshot.builder(getPlugin()).data(this.getData()).saveCause(saveCause).buildAndPack();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether data can be applied to the holder at this time
|
||||
*
|
||||
* @return {@code true} if data can be applied, otherwise false
|
||||
*/
|
||||
default boolean cannotApplySnapshot() {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* the main server thread. The order data will be applied is determined based on the dependencies of
|
||||
* each data type (see {@link Identifier.Dependency}).
|
||||
* </p>
|
||||
* The {@code runAfter} callback function will be run after the snapshot has been applied.
|
||||
*
|
||||
@@ -89,9 +104,12 @@ public interface UserDataHolder extends DataHolder {
|
||||
* @since 3.0
|
||||
*/
|
||||
default void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull ThrowingConsumer<Boolean> runAfter) {
|
||||
final HuskSync plugin = getPlugin();
|
||||
if (cannotApplySnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Unpack the snapshot
|
||||
final HuskSync plugin = getPlugin();
|
||||
final DataSnapshot.Unpacked unpacked;
|
||||
try {
|
||||
unpacked = snapshot.unpack(plugin);
|
||||
@@ -103,15 +121,22 @@ public interface UserDataHolder extends DataHolder {
|
||||
|
||||
// Synchronously attempt to apply the snapshot
|
||||
plugin.runSync(() -> {
|
||||
if (cannotApplySnapshot()) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
for (Map.Entry<Identifier, Data> entry : unpacked.getData().entrySet()) {
|
||||
for (Map.Entry<Identifier, Data> entry : unpacked.getSortedIterable()) {
|
||||
final Identifier identifier = entry.getKey();
|
||||
if (plugin.getSettings().getSynchronization().isFeatureEnabled(identifier)) {
|
||||
if (identifier.isCustom()) {
|
||||
getCustomDataStore().put(identifier, entry.getValue());
|
||||
}
|
||||
entry.getValue().apply(this, plugin);
|
||||
if (!identifier.isEnabled()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply the identified data
|
||||
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);
|
||||
|
||||
@@ -24,8 +24,10 @@ import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.config.Settings;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.user.User;
|
||||
import org.intellij.lang.annotations.Language;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
@@ -56,8 +58,8 @@ public abstract class Database {
|
||||
@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(";");
|
||||
return Arrays.stream(formatStatementTables(new String(Objects.requireNonNull(plugin.getResource(schemaFileName))
|
||||
.readAllBytes(), StandardCharsets.UTF_8)).split(";")).filter(s -> !s.isBlank()).toArray(String[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -67,10 +69,12 @@ public abstract class Database {
|
||||
* @return the formatted statement, with table placeholders replaced with the correct names
|
||||
*/
|
||||
@NotNull
|
||||
protected final String formatStatementTables(@NotNull String sql) {
|
||||
protected final String formatStatementTables(@NotNull @Language("SQL") String sql) {
|
||||
final Settings.DatabaseSettings settings = plugin.getSettings().getDatabase();
|
||||
return sql.replaceAll("%users_table%", settings.getTableName(TableName.USERS))
|
||||
.replaceAll("%user_data_table%", settings.getTableName(TableName.USER_DATA));
|
||||
.replaceAll("%user_data_table%", settings.getTableName(TableName.USER_DATA))
|
||||
.replaceAll("%map_data_table%", settings.getTableName(TableName.MAP_DATA))
|
||||
.replaceAll("%map_ids_table%", settings.getTableName(TableName.MAP_IDS));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,6 +111,14 @@ public abstract class Database {
|
||||
@Blocking
|
||||
public abstract Optional<User> getUserByName(@NotNull String username);
|
||||
|
||||
/**
|
||||
* Get all users
|
||||
*
|
||||
* @return A list of all users
|
||||
*/
|
||||
@NotNull
|
||||
@Blocking
|
||||
public abstract List<User> getAllUsers();
|
||||
|
||||
/**
|
||||
* Get the latest data snapshot for a user.
|
||||
@@ -127,6 +139,15 @@ public abstract class Database {
|
||||
@NotNull
|
||||
public abstract List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user);
|
||||
|
||||
/**
|
||||
* Get the number of unpinned {@link DataSnapshot}s a user has
|
||||
*
|
||||
* @param user the user to count snapshots for
|
||||
* @return the number of snapshots this user has saved
|
||||
*/
|
||||
@Blocking
|
||||
public abstract int getUnpinnedSnapshotCount(@NotNull User user);
|
||||
|
||||
/**
|
||||
* Gets a specific {@link DataSnapshot} entry for a user from the database, by its UUID.
|
||||
*
|
||||
@@ -238,6 +259,58 @@ public abstract class Database {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Write map data to a database
|
||||
*
|
||||
* @param serverName Name of the server the map originates from
|
||||
* @param mapId Original map ID
|
||||
* @param data Map data
|
||||
*/
|
||||
@Blocking
|
||||
public abstract void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data);
|
||||
|
||||
/**
|
||||
* Read map data from a database
|
||||
*
|
||||
* @param serverName Name of the server the map originates from
|
||||
* @param mapId Original map ID
|
||||
* @return the map data bytes, or null if not found
|
||||
*/
|
||||
@Blocking
|
||||
public abstract byte @Nullable [] getMapData(@NotNull String serverName, int mapId);
|
||||
|
||||
/**
|
||||
* Reverse lookup: given a local map binding, find the origin server and map ID.
|
||||
*
|
||||
* @param serverName Name of the local server (to_server_name in the binding)
|
||||
* @param mapId Local map ID on this server (to_id in the binding)
|
||||
* @return Map.Entry with origin server name (key) and origin map ID (value), or null if not found
|
||||
*/
|
||||
@Blocking
|
||||
public abstract @Nullable Map.Entry<String, Integer> getMapBinding(@NotNull String serverName, int mapId);
|
||||
|
||||
/**
|
||||
* Bind map IDs across different servers
|
||||
*
|
||||
* @param fromServerName Name of the server the map originates from
|
||||
* @param fromMapId Original map ID
|
||||
* @param toServerName Name of the new server
|
||||
* @param toMapId New map ID
|
||||
*/
|
||||
@Blocking
|
||||
public abstract void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId);
|
||||
|
||||
/**
|
||||
* Get map ID for the new server
|
||||
*
|
||||
* @param fromServerName Name of the server the map originates from
|
||||
* @param fromMapId Original map ID
|
||||
* @param toServerName Name of the new server
|
||||
* @return New map ID or -1 if not found
|
||||
*/
|
||||
@Blocking
|
||||
public abstract int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName);
|
||||
|
||||
/**
|
||||
* Wipes <b>all</b> {@link User} entries from the database.
|
||||
* <b>This should only be used when preparing tables for a data migration.</b>
|
||||
@@ -275,7 +348,9 @@ public abstract class Database {
|
||||
@Getter
|
||||
public enum TableName {
|
||||
USERS("husksync_users"),
|
||||
USER_DATA("husksync_user_data");
|
||||
USER_DATA("husksync_user_data"),
|
||||
MAP_DATA("husksync_map_data"),
|
||||
MAP_IDS("husksync_map_ids");
|
||||
|
||||
private final String defaultName;
|
||||
|
||||
|
||||
@@ -35,13 +35,11 @@ import org.bson.conversions.Bson;
|
||||
import org.bson.types.Binary;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.TimeZone;
|
||||
import java.util.UUID;
|
||||
import java.util.*;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public class MongoDbDatabase extends Database {
|
||||
@@ -50,17 +48,17 @@ public class MongoDbDatabase extends Database {
|
||||
|
||||
private final String usersTable;
|
||||
private final String userDataTable;
|
||||
private final String mapDataTable;
|
||||
private final String mapIdsTable;
|
||||
|
||||
public MongoDbDatabase(@NotNull HuskSync plugin) {
|
||||
super(plugin);
|
||||
this.usersTable = plugin.getSettings().getDatabase().getTableName(TableName.USERS);
|
||||
this.userDataTable = plugin.getSettings().getDatabase().getTableName(TableName.USER_DATA);
|
||||
this.mapDataTable = plugin.getSettings().getDatabase().getTableName(TableName.MAP_DATA);
|
||||
this.mapIdsTable = plugin.getSettings().getDatabase().getTableName(TableName.MAP_IDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database and ensure tables are present; create tables if they do not exist.
|
||||
*
|
||||
* @throws IllegalStateException if the database could not be initialized
|
||||
*/
|
||||
@Override
|
||||
public void initialize() throws IllegalStateException {
|
||||
final Settings.DatabaseSettings.DatabaseCredentials credentials = plugin.getSettings().getDatabase().getCredentials();
|
||||
@@ -68,12 +66,22 @@ public class MongoDbDatabase extends Database {
|
||||
ConnectionString URI = createConnectionURI(credentials);
|
||||
mongoConnectionHandler = new MongoConnectionHandler(URI, credentials.getDatabase());
|
||||
mongoCollectionHelper = new MongoCollectionHelper(mongoConnectionHandler);
|
||||
|
||||
// Check config for if tables should be created
|
||||
if (!plugin.getSettings().getDatabase().isCreateTables()) return;
|
||||
|
||||
if (mongoCollectionHelper.getCollection(usersTable) == null) {
|
||||
mongoCollectionHelper.createCollection(usersTable);
|
||||
}
|
||||
if (mongoCollectionHelper.getCollection(userDataTable) == null) {
|
||||
mongoCollectionHelper.createCollection(userDataTable);
|
||||
}
|
||||
if (mongoCollectionHelper.getCollection(mapDataTable) == null) {
|
||||
mongoCollectionHelper.createCollection(mapDataTable);
|
||||
}
|
||||
if (mongoCollectionHelper.getCollection(mapIdsTable) == null) {
|
||||
mongoCollectionHelper.createCollection(mapIdsTable);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to establish a connection to the MongoDB database. " +
|
||||
"Please check the supplied database credentials in the config file", e);
|
||||
@@ -93,27 +101,22 @@ public class MongoDbDatabase extends Database {
|
||||
return new ConnectionString(baseURI);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Override
|
||||
public void ensureUser(@NotNull User user) {
|
||||
try {
|
||||
getUser(user.getUuid()).ifPresentOrElse(
|
||||
existingUser -> {
|
||||
if (!existingUser.getUsername().equals(user.getUsername())) {
|
||||
if (!existingUser.getName().equals(user.getName())) {
|
||||
// Update a user's name if it has changed in the database
|
||||
try {
|
||||
Document filter = new Document("uuid", existingUser.getUuid().toString());
|
||||
Document filter = new Document("uuid", existingUser.getUuid());
|
||||
Document doc = mongoCollectionHelper.getCollection(usersTable).find(filter).first();
|
||||
if (doc == null) {
|
||||
throw new MongoException("User document returned null!");
|
||||
}
|
||||
|
||||
Bson updates = Updates.set("username", user.getUsername());
|
||||
Bson updates = Updates.set("username", user.getName());
|
||||
mongoCollectionHelper.updateDocument(usersTable, doc, updates);
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
|
||||
@@ -123,7 +126,7 @@ public class MongoDbDatabase extends Database {
|
||||
() -> {
|
||||
// Insert new player data into the database
|
||||
try {
|
||||
Document doc = new Document("uuid", user.getUuid().toString()).append("username", user.getUsername());
|
||||
Document doc = new Document("uuid", user.getUuid()).append("username", user.getName());
|
||||
mongoCollectionHelper.insertDocument(usersTable, doc);
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to insert a user into the database", e);
|
||||
@@ -135,12 +138,6 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Override
|
||||
public Optional<User> getUser(@NotNull UUID uuid) {
|
||||
@@ -148,8 +145,7 @@ public class MongoDbDatabase extends Database {
|
||||
Document filter = new Document("uuid", uuid);
|
||||
Document doc = mongoCollectionHelper.getCollection(usersTable).find(filter).first();
|
||||
if (doc != null) {
|
||||
return Optional.of(new User(UUID.fromString(doc.getString("uuid")),
|
||||
doc.getString("username")));
|
||||
return Optional.of(new User(uuid, doc.getString("username")));
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (MongoException e) {
|
||||
@@ -158,12 +154,6 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Override
|
||||
public Optional<User> getUserByName(@NotNull String username) {
|
||||
@@ -171,7 +161,7 @@ public class MongoDbDatabase extends Database {
|
||||
Document filter = new Document("username", username);
|
||||
Document doc = mongoCollectionHelper.getCollection(usersTable).find(filter).first();
|
||||
if (doc != null) {
|
||||
return Optional.of(new User(UUID.fromString(doc.getString("uuid")),
|
||||
return Optional.of(new User(doc.get("uuid", UUID.class),
|
||||
doc.getString("username")));
|
||||
}
|
||||
return Optional.empty();
|
||||
@@ -181,22 +171,34 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@Override
|
||||
@NotNull
|
||||
public List<User> getAllUsers() {
|
||||
final List<User> users = Lists.newArrayList();
|
||||
try {
|
||||
final FindIterable<Document> doc = mongoCollectionHelper.getCollection(usersTable).find();
|
||||
for (Document document : doc) {
|
||||
users.add(new User(
|
||||
UUID.fromString(document.getString("uuid")),
|
||||
document.getString("username")
|
||||
));
|
||||
}
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get all users from the database", e);
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
|
||||
try {
|
||||
Document filter = new Document("player_uuid", user.getUuid().toString());
|
||||
Document filter = new Document("player_uuid", user.getUuid());
|
||||
Document sort = new Document("timestamp", -1); // -1 = Descending
|
||||
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable).find(filter).sort(sort);
|
||||
Document doc = iterable.first();
|
||||
if (doc != null) {
|
||||
final UUID versionUuid = UUID.fromString(doc.getString("version_uuid"));
|
||||
final UUID versionUuid = doc.get("version_uuid", UUID.class);
|
||||
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(Instant.ofEpochMilli((long) doc.get("timestamp")), TimeZone.getDefault().toZoneId());
|
||||
final Binary bin = doc.get("data", Binary.class);
|
||||
final byte[] dataByteArray = bin.getData();
|
||||
@@ -209,23 +211,17 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Override
|
||||
@NotNull
|
||||
public List<DataSnapshot.Packed> getAllSnapshots(@NotNull User user) {
|
||||
try {
|
||||
final List<DataSnapshot.Packed> retrievedData = Lists.newArrayList();
|
||||
Document filter = new Document("player_uuid", user.getUuid().toString());
|
||||
Document filter = new Document("player_uuid", user.getUuid());
|
||||
Document sort = new Document("timestamp", -1); // -1 = Descending
|
||||
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable).find(filter).sort(sort);
|
||||
for (Document doc : iterable) {
|
||||
final UUID versionUuid = UUID.fromString(doc.getString("version_uuid"));
|
||||
final UUID versionUuid = doc.get("version_uuid", UUID.class);
|
||||
final OffsetDateTime timestamp = OffsetDateTime.ofInstant(Instant.ofEpochMilli((long) doc.get("timestamp")), TimeZone.getDefault().toZoneId());
|
||||
final Binary bin = doc.get("data", Binary.class);
|
||||
final byte[] dataByteArray = bin.getData();
|
||||
@@ -238,18 +234,22 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
@Override
|
||||
public int getUnpinnedSnapshotCount(@NotNull User user) {
|
||||
try {
|
||||
Document filter = new Document("player_uuid", user.getUuid()).append("pinned", false);
|
||||
return (int) mongoCollectionHelper.getCollection(userDataTable).countDocuments(filter);
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to fetch a user's current snapshot count", e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
|
||||
try {
|
||||
Document filter = new Document("player_uuid", user.getUuid().toString()).append("version_uuid", versionUuid.toString());
|
||||
Document filter = new Document("player_uuid", user.getUuid()).append("version_uuid", versionUuid);
|
||||
Document sort = new Document("timestamp", -1); // -1 = Descending
|
||||
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable).find(filter).sort(sort);
|
||||
Document doc = iterable.first();
|
||||
@@ -266,27 +266,18 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <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
|
||||
@Override
|
||||
protected void rotateSnapshots(@NotNull User user) {
|
||||
try {
|
||||
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
|
||||
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
|
||||
final int unpinnedSnapshots = getUnpinnedSnapshotCount(user);
|
||||
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
|
||||
if (unpinnedUserData.size() > maxSnapshots) {
|
||||
|
||||
Document filter = new Document("player_uuid", user.getUuid().toString()).append("pinned", false);
|
||||
if (unpinnedSnapshots > maxSnapshots) {
|
||||
Document filter = new Document("player_uuid", user.getUuid()).append("pinned", false);
|
||||
Document sort = new Document("timestamp", 1); // 1 = Ascending
|
||||
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable)
|
||||
.find(filter)
|
||||
.sort(sort)
|
||||
.limit(unpinnedUserData.size() - maxSnapshots);
|
||||
.find(filter).sort(sort)
|
||||
.limit(unpinnedSnapshots - maxSnapshots);
|
||||
|
||||
for (Document doc : iterable) {
|
||||
mongoCollectionHelper.deleteDocument(userDataTable, doc);
|
||||
@@ -297,17 +288,11 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Override
|
||||
public boolean deleteSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
|
||||
try {
|
||||
Document filter = new Document("player_uuid", user.getUuid().toString()).append("version_uuid", versionUuid.toString());
|
||||
Document filter = new Document("player_uuid", user.getUuid()).append("version_uuid", versionUuid);
|
||||
Document doc = mongoCollectionHelper.getCollection(userDataTable).find(filter).first();
|
||||
if (doc == null) {
|
||||
return false;
|
||||
@@ -320,19 +305,11 @@ public class MongoDbDatabase extends Database {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Override
|
||||
protected void rotateLatestSnapshot(@NotNull User user, @NotNull OffsetDateTime within) {
|
||||
try {
|
||||
Document filter = new Document("player_uuid", user.getUuid().toString()).append("pinned", false);
|
||||
Document filter = new Document("player_uuid", user.getUuid()).append("pinned", false);
|
||||
Document sort = new Document("timestamp", 1); // 1 = Ascending
|
||||
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(userDataTable)
|
||||
.find(filter)
|
||||
@@ -352,18 +329,12 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <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
|
||||
@Override
|
||||
protected void createSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
|
||||
try {
|
||||
Document doc = new Document("player_uuid", user.getUuid().toString())
|
||||
.append("version_uuid", data.getId().toString())
|
||||
Document doc = new Document("player_uuid", user.getUuid())
|
||||
.append("version_uuid", data.getId())
|
||||
.append("timestamp", data.getTimestamp().toInstant().toEpochMilli())
|
||||
.append("save_cause", data.getSaveCause().name())
|
||||
.append("pinned", data.isPinned())
|
||||
@@ -374,17 +345,11 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a saved {@link DataSnapshot} by given version UUID
|
||||
*
|
||||
* @param user The user whose data snapshot
|
||||
* @param data The {@link DataSnapshot} to update
|
||||
*/
|
||||
@Blocking
|
||||
@Override
|
||||
public void updateSnapshot(@NotNull User user, @NotNull DataSnapshot.Packed data) {
|
||||
try {
|
||||
Document doc = new Document("player_uuid", user.getUuid().toString()).append("version_uuid", data.getId().toString());
|
||||
Document doc = new Document("player_uuid", user.getUuid()).append("version_uuid", data.getId());
|
||||
Bson updates = Updates.combine(
|
||||
Updates.set("save_cause", data.getSaveCause().name()),
|
||||
Updates.set("pinned", data.isPinned()),
|
||||
@@ -396,10 +361,85 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@Override
|
||||
public void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data) {
|
||||
try {
|
||||
Document doc = new Document("server_name", serverName)
|
||||
.append("map_id", mapId)
|
||||
.append("data", new Binary(data));
|
||||
mongoCollectionHelper.insertDocument(mapDataTable, doc);
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to write map data to the database", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public byte @Nullable [] getMapData(@NotNull String serverName, int mapId) {
|
||||
try {
|
||||
Document filter = new Document("server_name", serverName).append("map_id", mapId);
|
||||
FindIterable<Document> iterable = mongoCollectionHelper.getCollection(mapDataTable).find(filter);
|
||||
Document doc = iterable.first();
|
||||
if (doc != null) {
|
||||
final Binary bin = doc.get("data", Binary.class);
|
||||
return bin.getData();
|
||||
}
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get map data from the database", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public @Nullable Map.Entry<String, Integer> getMapBinding(@NotNull String serverName, int mapId) {
|
||||
final Document filter = new Document("to_server_name", serverName).append("to_id", mapId);
|
||||
final FindIterable<Document> iterable = mongoCollectionHelper.getCollection(mapIdsTable).find(filter);
|
||||
final Document doc = iterable.first();
|
||||
if (doc != null) {
|
||||
return new AbstractMap.SimpleImmutableEntry<>(
|
||||
doc.getString("from_server_name"),
|
||||
doc.getInteger("from_id")
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId) {
|
||||
try {
|
||||
final Document doc = new Document("from_server_name", fromServerName)
|
||||
.append("from_id", fromMapId)
|
||||
.append("to_server_name", toServerName)
|
||||
.append("to_id", toMapId);
|
||||
mongoCollectionHelper.insertDocument(mapIdsTable, doc);
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to connect map IDs in the database", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) {
|
||||
try {
|
||||
final Document filter = new Document("from_server_name", fromServerName)
|
||||
.append("from_id", fromMapId)
|
||||
.append("to_server_name", toServerName);
|
||||
final FindIterable<Document> iterable = mongoCollectionHelper.getCollection(mapIdsTable).find(filter);
|
||||
|
||||
final Document doc = iterable.first();
|
||||
if (doc != null) {
|
||||
return doc.getInteger("to_id");
|
||||
}
|
||||
return -1;
|
||||
} catch (MongoException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get new map id from the database", e);
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public void wipeDatabase() {
|
||||
@@ -410,9 +450,6 @@ public class MongoDbDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection
|
||||
*/
|
||||
@Override
|
||||
public void terminate() {
|
||||
if (mongoConnectionHandler != null) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.user.User;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
@@ -115,6 +116,9 @@ public class MySqlDatabase extends Database {
|
||||
);
|
||||
dataSource.setDataSourceProperties(properties);
|
||||
|
||||
// Check config for if tables should be created
|
||||
if (!plugin.getSettings().getDatabase().isCreateTables()) return;
|
||||
|
||||
// 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));
|
||||
@@ -137,7 +141,7 @@ public class MySqlDatabase extends Database {
|
||||
public void ensureUser(@NotNull User user) {
|
||||
getUser(user.getUuid()).ifPresentOrElse(
|
||||
existingUser -> {
|
||||
if (!existingUser.getUsername().equals(user.getUsername())) {
|
||||
if (!existingUser.getName().equals(user.getName())) {
|
||||
// Update a user's name if it has changed in the database
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
@@ -145,11 +149,12 @@ public class MySqlDatabase extends Database {
|
||||
SET `username`=?
|
||||
WHERE `uuid`=?"""))) {
|
||||
|
||||
statement.setString(1, user.getUsername());
|
||||
statement.setString(1, user.getName());
|
||||
statement.setString(2, existingUser.getUuid().toString());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
plugin.log(Level.INFO, "Updated " + user.getUsername() + "'s name in the database (" + existingUser.getUsername() + " -> " + user.getUsername() + ")");
|
||||
plugin.log(Level.INFO, "Updated " + user.getName() + "'s name in the database ("
|
||||
+ existingUser.getName() + " -> " + user.getName() + ")");
|
||||
} catch (SQLException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e);
|
||||
}
|
||||
@@ -163,7 +168,7 @@ public class MySqlDatabase extends Database {
|
||||
VALUES (?,?);"""))) {
|
||||
|
||||
statement.setString(1, user.getUuid().toString());
|
||||
statement.setString(2, user.getUsername());
|
||||
statement.setString(2, user.getName());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
@@ -218,6 +223,27 @@ public class MySqlDatabase extends Database {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public List<User> getAllUsers() {
|
||||
final List<User> users = Lists.newArrayList();
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `uuid`, `username`
|
||||
FROM `%users_table%`;
|
||||
"""))) {
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
while (resultSet.next()) {
|
||||
users.add(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 users;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public Optional<DataSnapshot.Packed> getLatestSnapshot(@NotNull User user) {
|
||||
@@ -273,11 +299,30 @@ public class MySqlDatabase extends Database {
|
||||
return retrievedData;
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
|
||||
plugin.log(Level.SEVERE, "Failed to fetch a user's list of snapshots from the database", e);
|
||||
}
|
||||
return retrievedData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getUnpinnedSnapshotCount(@NotNull User user) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT COUNT(`version_uuid`)
|
||||
FROM `%user_data_table%`
|
||||
WHERE `player_uuid`=? AND `pinned`=false;"""))) {
|
||||
statement.setString(1, user.getUuid().toString());
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getInt(1);
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to fetch a user's current snapshot count", e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public Optional<DataSnapshot.Packed> getSnapshot(@NotNull User user, @NotNull UUID versionUuid) {
|
||||
@@ -310,10 +355,9 @@ public class MySqlDatabase extends Database {
|
||||
@Blocking
|
||||
@Override
|
||||
protected void rotateSnapshots(@NotNull User user) {
|
||||
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
|
||||
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
|
||||
final int unpinnedSnapshots = getUnpinnedSnapshotCount(user);
|
||||
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
|
||||
if (unpinnedUserData.size() > maxSnapshots) {
|
||||
if (unpinnedSnapshots > maxSnapshots) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
DELETE FROM `%user_data_table%`
|
||||
@@ -321,7 +365,7 @@ public class MySqlDatabase extends Database {
|
||||
AND `pinned` IS FALSE
|
||||
ORDER BY `timestamp` ASC
|
||||
LIMIT %entry_count%;""".replace("%entry_count%",
|
||||
Integer.toString(unpinnedUserData.size() - maxSnapshots))))) {
|
||||
Integer.toString(unpinnedSnapshots - maxSnapshots))))) {
|
||||
statement.setString(1, user.getUuid().toString());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
@@ -409,6 +453,120 @@ public class MySqlDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
INSERT INTO `%map_data_table%`
|
||||
(`server_name`,`map_id`,`data`)
|
||||
VALUES (?,?,?);"""))) {
|
||||
statement.setString(1, serverName);
|
||||
statement.setInt(2, mapId);
|
||||
statement.setBlob(3, new ByteArrayInputStream(data));
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to write map data to the database", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public byte @Nullable [] getMapData(@NotNull String serverName, int mapId) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `data`
|
||||
FROM `%map_data_table%`
|
||||
WHERE `server_name`=? AND `map_id`=?
|
||||
LIMIT 1;"""))) {
|
||||
statement.setString(1, serverName);
|
||||
statement.setInt(2, mapId);
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
final Blob blob = resultSet.getBlob("data");
|
||||
final byte[] dataByteArray = blob.getBytes(1, (int) blob.length());
|
||||
blob.free();
|
||||
return dataByteArray;
|
||||
}
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get map data from the database", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public @Nullable Map.Entry<String, Integer> getMapBinding(@NotNull String serverName, int mapId) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `from_server_name`, `from_id`
|
||||
FROM `%map_ids_table%`
|
||||
WHERE `to_server_name`=? AND `to_id`=?
|
||||
LIMIT 1;
|
||||
"""))) {
|
||||
statement.setString(1, serverName);
|
||||
statement.setInt(2, mapId);
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return new AbstractMap.SimpleImmutableEntry<>(
|
||||
resultSet.getString("from_server_name"),
|
||||
resultSet.getInt("from_id")
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get map data from the database", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
INSERT INTO `%map_ids_table%`
|
||||
(`from_server_name`,`from_id`,`to_server_name`,`to_id`)
|
||||
VALUES (?,?,?,?);"""))) {
|
||||
statement.setString(1, fromServerName);
|
||||
statement.setInt(2, fromMapId);
|
||||
statement.setString(3, toServerName);
|
||||
statement.setInt(4, toMapId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to connect map IDs in the database", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT `to_id`
|
||||
FROM `%map_ids_table%`
|
||||
WHERE `from_server_name`=? AND `from_id`=? AND `to_server_name`=?
|
||||
LIMIT 1;"""))) {
|
||||
statement.setString(1, fromServerName);
|
||||
statement.setInt(2, fromMapId);
|
||||
statement.setString(3, toServerName);
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getInt("to_id");
|
||||
}
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get new map id from the database", e);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void wipeDatabase() {
|
||||
try (Connection connection = getConnection()) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.user.User;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.sql.*;
|
||||
@@ -51,12 +52,6 @@ public class PostgresDatabase extends Database {
|
||||
this.driverClass = "org.postgresql.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 {
|
||||
@@ -114,6 +109,9 @@ public class PostgresDatabase extends Database {
|
||||
);
|
||||
dataSource.setDataSourceProperties(properties);
|
||||
|
||||
// Check config for if tables should be created
|
||||
if (!plugin.getSettings().getDatabase().isCreateTables()) return;
|
||||
|
||||
// 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));
|
||||
@@ -136,19 +134,19 @@ public class PostgresDatabase extends Database {
|
||||
public void ensureUser(@NotNull User user) {
|
||||
getUser(user.getUuid()).ifPresentOrElse(
|
||||
existingUser -> {
|
||||
if (!existingUser.getUsername().equals(user.getUsername())) {
|
||||
if (!existingUser.getName().equals(user.getName())) {
|
||||
// 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"=?"""))) {
|
||||
UPDATE %users_table%
|
||||
SET username=?
|
||||
WHERE uuid=?;"""))) {
|
||||
|
||||
statement.setString(1, user.getUsername());
|
||||
statement.setString(1, user.getName());
|
||||
statement.setObject(2, existingUser.getUuid());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
plugin.log(Level.INFO, "Updated " + user.getUsername() + "'s name in the database (" + existingUser.getUsername() + " -> " + user.getUsername() + ")");
|
||||
plugin.log(Level.INFO, "Updated " + user.getName() + "'s name in the database (" + existingUser.getName() + " -> " + user.getName() + ")");
|
||||
} catch (SQLException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to update a user's name on the database", e);
|
||||
}
|
||||
@@ -158,11 +156,11 @@ public class PostgresDatabase extends Database {
|
||||
// Insert new player data into the database
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
INSERT INTO "%users_table%" ("uuid","username")
|
||||
INSERT INTO %users_table% (uuid,username)
|
||||
VALUES (?,?);"""))) {
|
||||
|
||||
statement.setObject(1, user.getUuid());
|
||||
statement.setString(2, user.getUsername());
|
||||
statement.setString(2, user.getName());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
@@ -177,9 +175,9 @@ public class PostgresDatabase extends Database {
|
||||
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"=?"""))) {
|
||||
SELECT uuid, username
|
||||
FROM %users_table%
|
||||
WHERE uuid=?;"""))) {
|
||||
|
||||
statement.setObject(1, uuid);
|
||||
|
||||
@@ -200,9 +198,9 @@ public class PostgresDatabase extends Database {
|
||||
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"=?"""))) {
|
||||
SELECT uuid, username
|
||||
FROM %users_table%
|
||||
WHERE username=?;"""))) {
|
||||
statement.setString(1, username);
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
@@ -217,15 +215,37 @@ public class PostgresDatabase extends Database {
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
@NotNull
|
||||
public List<User> getAllUsers() {
|
||||
final List<User> users = Lists.newArrayList();
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT uuid, username
|
||||
FROM %users_table%;
|
||||
"""))) {
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
while (resultSet.next()) {
|
||||
users.add(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 users;
|
||||
}
|
||||
|
||||
@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
|
||||
SELECT version_uuid, timestamp, data
|
||||
FROM %user_data_table%
|
||||
WHERE player_uuid=?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1;"""))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
@@ -251,10 +271,10 @@ public class PostgresDatabase extends Database {
|
||||
final List<DataSnapshot.Packed> retrievedData = Lists.newArrayList();
|
||||
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;"""))) {
|
||||
SELECT version_uuid, timestamp, data
|
||||
FROM %user_data_table%
|
||||
WHERE player_uuid=?
|
||||
ORDER BY timestamp DESC;"""))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
while (resultSet.next()) {
|
||||
@@ -268,20 +288,39 @@ public class PostgresDatabase extends Database {
|
||||
return retrievedData;
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to fetch a user's current user data from the database", e);
|
||||
plugin.log(Level.SEVERE, "Failed to fetch a user's list of snapshots from the database", e);
|
||||
}
|
||||
return retrievedData;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getUnpinnedSnapshotCount(@NotNull User user) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT COUNT(version_uuid)
|
||||
FROM %user_data_table%
|
||||
WHERE player_uuid=? AND pinned=false;"""))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getInt(1);
|
||||
}
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to fetch a user's current snapshot count", e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@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
|
||||
SELECT version_uuid, timestamp, data
|
||||
FROM %user_data_table%
|
||||
WHERE player_uuid=? AND version_uuid=?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT 1;"""))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
statement.setObject(2, versionUuid);
|
||||
@@ -303,18 +342,22 @@ public class PostgresDatabase extends Database {
|
||||
@Blocking
|
||||
@Override
|
||||
protected void rotateSnapshots(@NotNull User user) {
|
||||
final List<DataSnapshot.Packed> unpinnedUserData = getAllSnapshots(user).stream()
|
||||
.filter(dataSnapshot -> !dataSnapshot.isPinned()).toList();
|
||||
final int unpinnedSnapshots = getUnpinnedSnapshotCount(user);
|
||||
final int maxSnapshots = plugin.getSettings().getSynchronization().getMaxUserDataSnapshots();
|
||||
if (unpinnedUserData.size() > maxSnapshots) {
|
||||
if (unpinnedSnapshots > maxSnapshots) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
DELETE FROM "%user_data_table%"
|
||||
WHERE "player_uuid"=?
|
||||
AND "pinned" = FALSE
|
||||
ORDER BY "timestamp" ASC
|
||||
LIMIT %entry_count%;""".replace("%entry_count%",
|
||||
Integer.toString(unpinnedUserData.size() - maxSnapshots))))) {
|
||||
WITH cte AS (
|
||||
SELECT version_uuid
|
||||
FROM %user_data_table%
|
||||
WHERE player_uuid=?
|
||||
AND pinned=FALSE
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT %entry_count%
|
||||
)
|
||||
DELETE FROM %user_data_table%
|
||||
WHERE version_uuid IN (SELECT version_uuid FROM cte);""".replace("%entry_count%",
|
||||
Integer.toString(unpinnedSnapshots - maxSnapshots))))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
statement.executeUpdate();
|
||||
}
|
||||
@@ -329,11 +372,10 @@ public class PostgresDatabase extends Database {
|
||||
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;"""))) {
|
||||
DELETE FROM %user_data_table%
|
||||
WHERE player_uuid=? AND version_uuid=?;"""))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
statement.setString(2, versionUuid.toString());
|
||||
statement.setObject(2, versionUuid);
|
||||
return statement.executeUpdate() > 0;
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
@@ -347,12 +389,12 @@ public class PostgresDatabase extends Database {
|
||||
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" = (
|
||||
SELECT "timestamp"
|
||||
FROM "%user_data_table%"
|
||||
WHERE "player_uuid"=? AND "timestamp" > ? AND "pinned" = FALSE
|
||||
ORDER BY "timestamp" ASC
|
||||
DELETE FROM %user_data_table%
|
||||
WHERE player_uuid=? AND timestamp = (
|
||||
SELECT timestamp
|
||||
FROM %user_data_table%
|
||||
WHERE player_uuid=? AND timestamp > ? AND pinned=FALSE
|
||||
ORDER BY timestamp ASC
|
||||
LIMIT 1
|
||||
);"""))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
@@ -370,8 +412,8 @@ public class PostgresDatabase extends Database {
|
||||
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")
|
||||
INSERT INTO %user_data_table%
|
||||
(player_uuid,version_uuid,timestamp,save_cause,pinned,data)
|
||||
VALUES (?,?,?,?,?,?);"""))) {
|
||||
statement.setObject(1, user.getUuid());
|
||||
statement.setObject(2, data.getId());
|
||||
@@ -391,10 +433,10 @@ public class PostgresDatabase extends Database {
|
||||
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;"""))) {
|
||||
UPDATE %user_data_table%
|
||||
SET save_cause=?,pinned=?,data=?
|
||||
WHERE player_uuid=? AND version_uuid=?;
|
||||
"""))) {
|
||||
statement.setString(1, data.getSaveCause().name());
|
||||
statement.setBoolean(2, data.isPinned());
|
||||
statement.setBytes(3, data.asBytes(plugin));
|
||||
@@ -407,11 +449,122 @@ public class PostgresDatabase extends Database {
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public void saveMapData(@NotNull String serverName, int mapId, byte @NotNull [] data) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
INSERT INTO %map_data_table%
|
||||
(server_name,map_id,data)
|
||||
VALUES (?,?,?);"""))) {
|
||||
statement.setString(1, serverName);
|
||||
statement.setInt(2, mapId);
|
||||
statement.setBytes(3, data);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to write map data to the database", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public byte @Nullable [] getMapData(@NotNull String serverName, int mapId) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT data
|
||||
FROM %map_data_table%
|
||||
WHERE server_name=? AND map_id=?
|
||||
LIMIT 1;"""))) {
|
||||
statement.setString(1, serverName);
|
||||
statement.setInt(2, mapId);
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getBytes("data");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get map data from the database", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public @Nullable Map.Entry<String, Integer> getMapBinding(@NotNull String serverName, int mapId) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT from_server_name, from_id
|
||||
FROM %map_ids_table%
|
||||
WHERE to_server_name=? AND to_id=?
|
||||
LIMIT 1;
|
||||
"""))) {
|
||||
statement.setString(1, serverName);
|
||||
statement.setInt(2, mapId);
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return new AbstractMap.SimpleImmutableEntry<>(
|
||||
resultSet.getString("from_server_name"),
|
||||
resultSet.getInt("from_id")
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get map data from the database", e);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public void setMapBinding(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName, int toMapId) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
INSERT INTO %map_ids_table%
|
||||
(from_server_name,from_id,to_server_name,to_id)
|
||||
VALUES (?,?,?,?);"""))) {
|
||||
statement.setString(1, fromServerName);
|
||||
statement.setInt(2, fromMapId);
|
||||
statement.setString(3, toServerName);
|
||||
statement.setInt(4, toMapId);
|
||||
statement.executeUpdate();
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to connect map IDs in the database", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
@Override
|
||||
public int getBoundMapId(@NotNull String fromServerName, int fromMapId, @NotNull String toServerName) {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (PreparedStatement statement = connection.prepareStatement(formatStatementTables("""
|
||||
SELECT to_id
|
||||
FROM %map_ids_table%
|
||||
WHERE from_server_name=? AND from_id=? AND to_server_name=?
|
||||
LIMIT 1;"""))) {
|
||||
statement.setString(1, fromServerName);
|
||||
statement.setInt(2, fromMapId);
|
||||
statement.setString(3, toServerName);
|
||||
|
||||
final ResultSet resultSet = statement.executeQuery();
|
||||
if (resultSet.next()) {
|
||||
return resultSet.getInt("to_id");
|
||||
}
|
||||
}
|
||||
} catch (SQLException | DataAdapter.AdaptionException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get new map id from the database", e);
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void wipeDatabase() {
|
||||
try (Connection connection = getConnection()) {
|
||||
try (Statement statement = connection.createStatement()) {
|
||||
statement.executeUpdate(formatStatementTables("DELETE FROM \"%user_data_table%\";"));
|
||||
statement.executeUpdate(formatStatementTables("DELETE FROM %user_data_table%;"));
|
||||
}
|
||||
} catch (SQLException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to wipe the database", e);
|
||||
|
||||
@@ -29,6 +29,7 @@ public class MongoCollectionHelper {
|
||||
|
||||
/**
|
||||
* Initialize the collection helper
|
||||
*
|
||||
* @param database Instance of {@link MongoConnectionHandler}
|
||||
*/
|
||||
public MongoCollectionHelper(@NotNull MongoConnectionHandler database) {
|
||||
@@ -37,6 +38,7 @@ public class MongoCollectionHelper {
|
||||
|
||||
/**
|
||||
* Create a collection
|
||||
*
|
||||
* @param collectionName the collection name
|
||||
*/
|
||||
public void createCollection(@NotNull String collectionName) {
|
||||
@@ -45,6 +47,7 @@ public class MongoCollectionHelper {
|
||||
|
||||
/**
|
||||
* Delete a collection
|
||||
*
|
||||
* @param collectionName the collection name
|
||||
*/
|
||||
public void deleteCollection(@NotNull String collectionName) {
|
||||
@@ -53,6 +56,7 @@ public class MongoCollectionHelper {
|
||||
|
||||
/**
|
||||
* Get a collection
|
||||
*
|
||||
* @param collectionName the collection name
|
||||
* @return MongoCollection<Document>
|
||||
*/
|
||||
@@ -62,8 +66,9 @@ public class MongoCollectionHelper {
|
||||
|
||||
/**
|
||||
* Add a document to a collection
|
||||
*
|
||||
* @param collectionName collection to add to
|
||||
* @param document Document to add
|
||||
* @param document Document to add
|
||||
*/
|
||||
public void insertDocument(@NotNull String collectionName, @NotNull Document document) {
|
||||
MongoCollection<Document> collection = database.getDatabase().getCollection(collectionName);
|
||||
@@ -72,9 +77,10 @@ public class MongoCollectionHelper {
|
||||
|
||||
/**
|
||||
* Update a document
|
||||
*
|
||||
* @param collectionName collection the document is in
|
||||
* @param document filter of document
|
||||
* @param updates Bson of updates
|
||||
* @param document filter of document
|
||||
* @param updates Bson of updates
|
||||
*/
|
||||
public void updateDocument(@NotNull String collectionName, @NotNull Document document, @NotNull Bson updates) {
|
||||
MongoCollection<Document> collection = database.getDatabase().getCollection(collectionName);
|
||||
@@ -83,8 +89,9 @@ public class MongoCollectionHelper {
|
||||
|
||||
/**
|
||||
* Delete a document
|
||||
*
|
||||
* @param collectionName collection the document is in
|
||||
* @param document filter to remove
|
||||
* @param document filter to remove
|
||||
*/
|
||||
public void deleteDocument(@NotNull String collectionName, @NotNull Document document) {
|
||||
MongoCollection<Document> collection = database.getDatabase().getCollection(collectionName);
|
||||
|
||||
@@ -35,9 +35,10 @@ public class MongoConnectionHandler {
|
||||
|
||||
/**
|
||||
* Initiate a connection to a Mongo Server
|
||||
*
|
||||
* @param uri The connection string
|
||||
*/
|
||||
public MongoConnectionHandler(@NotNull ConnectionString uri, @NotNull String databaseName) {
|
||||
public MongoConnectionHandler(@NotNull ConnectionString uri, @NotNull String databaseName) {
|
||||
try {
|
||||
final MongoClientSettings settings = MongoClientSettings.builder()
|
||||
.applyConnectionString(uri)
|
||||
@@ -48,7 +49,7 @@ public class MongoConnectionHandler {
|
||||
this.database = mongoClient.getDatabase(databaseName);
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("Failed to establish a connection to the MongoDB database. " +
|
||||
"Please check the supplied database credentials in the config file", e);
|
||||
"Please check the supplied database credentials in the config file", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@
|
||||
|
||||
package net.william278.husksync.event;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public interface Cancellable extends Event {
|
||||
|
||||
default boolean isCancelled() {
|
||||
|
||||
@@ -27,7 +27,7 @@ import org.jetbrains.annotations.NotNull;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
public interface PreSyncEvent extends PlayerEvent {
|
||||
public interface PreSyncEvent extends PlayerEvent, Cancellable {
|
||||
|
||||
@NotNull
|
||||
DataSnapshot.Packed getData();
|
||||
|
||||
@@ -25,9 +25,7 @@ import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.*;
|
||||
|
||||
import static net.william278.husksync.config.Settings.SynchronizationSettings.SaveOnDeathSettings;
|
||||
|
||||
@@ -49,11 +47,12 @@ public abstract class EventListener {
|
||||
* @param user The {@link OnlineUser} to handle
|
||||
*/
|
||||
protected final void handlePlayerJoin(@NotNull OnlineUser user) {
|
||||
plugin.getDisconnectingPlayers().remove(user.getUuid());
|
||||
if (user.isNpc()) {
|
||||
return;
|
||||
}
|
||||
plugin.lockPlayer(user.getUuid());
|
||||
plugin.getDataSyncer().setUserData(user);
|
||||
plugin.getDataSyncer().syncApplyUserData(user);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,11 +61,17 @@ public abstract class EventListener {
|
||||
* @param user The {@link OnlineUser} to handle
|
||||
*/
|
||||
protected final void handlePlayerQuit(@NotNull OnlineUser user) {
|
||||
if (user.isNpc() || plugin.isDisabling() || plugin.isLocked(user.getUuid())) {
|
||||
// Check the user is a user, the plugin isn't disabling, then mark as disconnecting
|
||||
if (user.isNpc() || plugin.isDisabling()) {
|
||||
return;
|
||||
}
|
||||
plugin.lockPlayer(user.getUuid());
|
||||
plugin.getDataSyncer().saveUserData(user);
|
||||
plugin.getDisconnectingPlayers().add(user.getUuid());
|
||||
|
||||
// Lock, then save their data if the user is unlocked
|
||||
if (!plugin.isLocked(user.getUuid())) {
|
||||
plugin.lockPlayer(user.getUuid());
|
||||
plugin.getDataSyncer().syncSaveUserData(user);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,9 +84,9 @@ public abstract class EventListener {
|
||||
return;
|
||||
}
|
||||
usersInWorld.stream()
|
||||
.filter(user -> !plugin.isLocked(user.getUuid()) && !user.isNpc())
|
||||
.forEach(user -> plugin.getDataSyncer().saveData(
|
||||
user, user.createSnapshot(DataSnapshot.SaveCause.WORLD_SAVE)
|
||||
.filter(user -> !user.isNpc() && !user.hasDisconnected() && !plugin.isLocked(user.getUuid()))
|
||||
.forEach(user -> plugin.getDataSyncer().saveCurrentUserData(
|
||||
user, DataSnapshot.SaveCause.WORLD_SAVE
|
||||
));
|
||||
}
|
||||
|
||||
@@ -98,8 +103,9 @@ public abstract class EventListener {
|
||||
return;
|
||||
}
|
||||
|
||||
// We don't persist this to Redis for syncing, as this snapshot is from a state they won't be in post-respawn
|
||||
final DataSnapshot.Packed snapshot = user.createSnapshot(DataSnapshot.SaveCause.DEATH);
|
||||
snapshot.edit(plugin, (data -> data.getInventory().ifPresent(inventory -> inventory.setContents(items))));
|
||||
snapshot.edit(plugin, (data -> data.getInventory().ifPresent(inv -> inv.setContents(items))));
|
||||
plugin.getDataSyncer().saveData(user, snapshot);
|
||||
}
|
||||
|
||||
@@ -108,16 +114,12 @@ public abstract class EventListener {
|
||||
* Handle the plugin disabling
|
||||
*/
|
||||
public void handlePluginDisable() {
|
||||
// Save for all online players
|
||||
// Save for all online players.
|
||||
plugin.getOnlineUsers().stream()
|
||||
.filter(user -> !plugin.isLocked(user.getUuid()) && !user.isNpc())
|
||||
.forEach(user -> {
|
||||
plugin.lockPlayer(user.getUuid());
|
||||
plugin.getDataSyncer().saveData(
|
||||
user,
|
||||
user.createSnapshot(DataSnapshot.SaveCause.SERVER_SHUTDOWN),
|
||||
(saved, data) -> plugin.getRedisManager().clearUserData(saved)
|
||||
);
|
||||
plugin.getDataSyncer().saveCurrentUserData(user, DataSnapshot.SaveCause.SERVER_SHUTDOWN);
|
||||
});
|
||||
|
||||
// Close outstanding connections
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* 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.maps;
|
||||
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import lombok.AllArgsConstructor;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import net.william278.husksync.adapter.Adaptable;
|
||||
import net.william278.mapdataapi.MapData;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
@AllArgsConstructor
|
||||
public class AdaptableMapData implements Adaptable {
|
||||
|
||||
@SerializedName("data")
|
||||
private final byte[] data;
|
||||
|
||||
public AdaptableMapData(@NotNull MapData data) {
|
||||
this(data.toBytes());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public MapData getData(int dataVersion) throws IOException {
|
||||
return MapData.fromByteArray(dataVersion, data);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -27,7 +27,10 @@ public enum RedisKeyType {
|
||||
|
||||
LATEST_SNAPSHOT,
|
||||
SERVER_SWITCH,
|
||||
DATA_CHECKOUT;
|
||||
DATA_CHECKOUT,
|
||||
MAP_ID,
|
||||
MAP_ID_REVERSED,
|
||||
MAP_DATA;
|
||||
|
||||
public static final int TTL_1_YEAR = 60 * 60 * 24 * 7 * 52; // 1 year
|
||||
public static final int TTL_10_SECONDS = 10; // 10 seconds
|
||||
|
||||
@@ -25,6 +25,7 @@ import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.user.User;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import redis.clients.jedis.*;
|
||||
import redis.clients.jedis.exceptions.JedisException;
|
||||
import redis.clients.jedis.util.Pool;
|
||||
@@ -61,41 +62,62 @@ public class RedisManager extends JedisPubSub {
|
||||
/**
|
||||
* Initialize Redis connection pool
|
||||
*/
|
||||
|
||||
@Blocking
|
||||
public void initialize() throws IllegalStateException {
|
||||
final Settings.RedisSettings.RedisCredentials credentials = plugin.getSettings().getRedis().getCredentials();
|
||||
|
||||
final String user = credentials.getUser();
|
||||
final String password = credentials.getPassword();
|
||||
final String host = credentials.getHost();
|
||||
final int port = credentials.getPort();
|
||||
final int database = credentials.getDatabase();
|
||||
final boolean useSSL = credentials.isUseSsl();
|
||||
|
||||
// Create the jedis pool
|
||||
// Configure JedisPoolConfig
|
||||
final JedisPoolConfig config = new JedisPoolConfig();
|
||||
config.setMaxIdle(0);
|
||||
config.setTestOnBorrow(true);
|
||||
config.setTestOnReturn(true);
|
||||
config.setMaxTotal(credentials.getMaxTotalConnections());
|
||||
config.setMaxIdle(credentials.getMaxIdleConnections());
|
||||
config.setMinIdle(credentials.getMinIdleConnections());
|
||||
config.setTestOnBorrow(credentials.isTestOnBorrow());
|
||||
config.setTestOnReturn(credentials.isTestOnReturn());
|
||||
config.setTestWhileIdle(credentials.isTestWhileIdle());
|
||||
config.setMinEvictableIdleTimeMillis(credentials.getMinEvictableIdleTimeMillis());
|
||||
config.setTimeBetweenEvictionRunsMillis(credentials.getTimeBetweenEvictionRunsMillis());
|
||||
|
||||
final Settings.RedisSettings.RedisSentinel sentinel = plugin.getSettings().getRedis().getSentinel();
|
||||
Set<String> redisSentinelNodes = new HashSet<>(sentinel.getNodes());
|
||||
|
||||
if (redisSentinelNodes.isEmpty()) {
|
||||
this.jedisPool = password.isEmpty()
|
||||
? new JedisPool(config, host, port, 0, useSSL)
|
||||
: new JedisPool(config, host, port, 0, password, useSSL);
|
||||
// Standalone Redis setup
|
||||
DefaultJedisClientConfig.Builder clientConfigBuilder = DefaultJedisClientConfig.builder()
|
||||
.ssl(useSSL)
|
||||
.database(database)
|
||||
.timeoutMillis(credentials.getConnectionTimeout()) // connection and socket timeout combined
|
||||
.user(user.isEmpty() ? null : user)
|
||||
.password(password.isEmpty() ? null : password);
|
||||
|
||||
this.jedisPool = new JedisPool(config, new HostAndPort(host, port), clientConfigBuilder.build());
|
||||
} else {
|
||||
final String sentinelPassword = sentinel.getPassword();
|
||||
this.jedisPool = new JedisSentinelPool(sentinel.getMaster(), redisSentinelNodes, password.isEmpty()
|
||||
? null : password, sentinelPassword.isEmpty() ? null : sentinelPassword);
|
||||
this.jedisPool = new JedisSentinelPool(
|
||||
sentinel.getMaster(),
|
||||
redisSentinelNodes,
|
||||
config,
|
||||
credentials.getConnectionTimeout(),
|
||||
credentials.getSocketTimeout(),
|
||||
password.isEmpty() ? null : password,
|
||||
sentinelPassword.isEmpty() ? null : sentinelPassword,
|
||||
database);
|
||||
}
|
||||
|
||||
// Ping the server to check the connection
|
||||
try {
|
||||
jedisPool.getResource().ping();
|
||||
try (var jedis = jedisPool.getResource()) {
|
||||
jedis.ping();
|
||||
} catch (JedisException e) {
|
||||
throw new IllegalStateException("Failed to establish connection with Redis. "
|
||||
+ "Please check the supplied credentials in the config file", e);
|
||||
throw new IllegalStateException("Failed to establish connection with Redis. " +
|
||||
"Please check the supplied credentials in the config file", e);
|
||||
}
|
||||
|
||||
// Subscribe using a thread (rather than a task)
|
||||
enabled = true;
|
||||
new Thread(this::subscribe, "husksync:redis_subscriber").start();
|
||||
}
|
||||
@@ -112,8 +134,7 @@ public class RedisManager extends JedisPubSub {
|
||||
this,
|
||||
Arrays.stream(RedisMessage.Type.values())
|
||||
.map(type -> type.getMessageChannel(clusterId))
|
||||
.toArray(String[]::new)
|
||||
);
|
||||
.toArray(String[]::new));
|
||||
} catch (Throwable t) {
|
||||
// Thread was unlocked due error
|
||||
onThreadUnlock(t);
|
||||
@@ -157,27 +178,47 @@ public class RedisManager extends JedisPubSub {
|
||||
|
||||
final RedisMessage redisMessage = RedisMessage.fromJson(plugin, message);
|
||||
switch (messageType) {
|
||||
case UPDATE_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
|
||||
case UPDATE_USER_DATA -> redisMessage.getTargetUser(plugin).ifPresent(
|
||||
user -> {
|
||||
plugin.lockPlayer(user.getUuid());
|
||||
try {
|
||||
final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin, redisMessage.getPayload());
|
||||
final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin,
|
||||
redisMessage.getPayload());
|
||||
user.applySnapshot(data, DataSnapshot.UpdateCause.UPDATED);
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred updating user data from Redis", e);
|
||||
user.completeSync(false, DataSnapshot.UpdateCause.UPDATED, plugin);
|
||||
}
|
||||
}
|
||||
);
|
||||
case REQUEST_USER_DATA -> plugin.getOnlineUser(redisMessage.getTargetUuid()).ifPresent(
|
||||
});
|
||||
case REQUEST_USER_DATA -> redisMessage.getTargetUser(plugin).ifPresent(
|
||||
user -> RedisMessage.create(
|
||||
UUID.fromString(new String(redisMessage.getPayload(), StandardCharsets.UTF_8)),
|
||||
user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin)
|
||||
).dispatch(plugin, RedisMessage.Type.RETURN_USER_DATA)
|
||||
);
|
||||
user.createSnapshot(DataSnapshot.SaveCause.INVENTORY_COMMAND).asBytes(plugin))
|
||||
.dispatch(plugin, RedisMessage.Type.RETURN_USER_DATA));
|
||||
case CHECK_IN_PETITION -> {
|
||||
if (!redisMessage.isTargetServer(plugin)
|
||||
|| !plugin.getSettings().getSynchronization().isCheckinPetitions()) {
|
||||
return;
|
||||
}
|
||||
final String payload = new String(redisMessage.getPayload(), StandardCharsets.UTF_8);
|
||||
final User user = new User(UUID.fromString(payload.split("/")[0]), payload.split("/")[1]);
|
||||
|
||||
// Only release checkout if user is truly offline AND not being processed
|
||||
final boolean isOnline = plugin.getOnlineUser(user.getUuid()).isPresent();
|
||||
final boolean isLocked = plugin.isLocked(user.getUuid());
|
||||
|
||||
if (isOnline || isLocked) {
|
||||
plugin.debug("[%s] Petition ignored - user still being processed (online=%s, locked=%s)"
|
||||
.formatted(user.getName(), isOnline, isLocked));
|
||||
return;
|
||||
}
|
||||
|
||||
plugin.getRedisManager().setUserCheckedOut(user, false);
|
||||
plugin.debug("[%s] Petition accepted - user checked in".formatted(user.getName()));
|
||||
}
|
||||
case RETURN_USER_DATA -> {
|
||||
final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get(
|
||||
redisMessage.getTargetUuid()
|
||||
);
|
||||
final UUID target = redisMessage.getTargetUuid().orElse(null);
|
||||
final CompletableFuture<Optional<DataSnapshot.Packed>> future = pendingRequests.get(target);
|
||||
if (future != null) {
|
||||
try {
|
||||
final DataSnapshot.Packed data = DataSnapshot.deserialize(plugin, redisMessage.getPayload());
|
||||
@@ -186,7 +227,7 @@ public class RedisManager extends JedisPubSub {
|
||||
plugin.log(Level.SEVERE, "An exception occurred returning user data from Redis", e);
|
||||
future.complete(Optional.empty());
|
||||
}
|
||||
pendingRequests.remove(redisMessage.getTargetUuid());
|
||||
pendingRequests.remove(target);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -209,58 +250,58 @@ public class RedisManager extends JedisPubSub {
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public void sendUserDataUpdate(@NotNull User user, @NotNull DataSnapshot.Packed data) {
|
||||
plugin.runAsync(() -> {
|
||||
final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin));
|
||||
redisMessage.dispatch(plugin, RedisMessage.Type.UPDATE_USER_DATA);
|
||||
});
|
||||
final RedisMessage redisMessage = RedisMessage.create(user.getUuid(), data.asBytes(plugin));
|
||||
redisMessage.dispatch(plugin, RedisMessage.Type.UPDATE_USER_DATA);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<DataSnapshot.Packed>> getUserData(@NotNull UUID requestId, @NotNull User user) {
|
||||
@Blocking
|
||||
public void petitionServerCheckin(@NotNull String server, @NotNull User user) {
|
||||
final RedisMessage redisMessage = RedisMessage.create(
|
||||
server, "%s/%s".formatted(user.getUuid(), user.getName()).getBytes(StandardCharsets.UTF_8));
|
||||
redisMessage.dispatch(plugin, RedisMessage.Type.CHECK_IN_PETITION);
|
||||
}
|
||||
|
||||
public CompletableFuture<Optional<DataSnapshot.Packed>> getOnlineUserData(@NotNull UUID requestId,
|
||||
@NotNull User user,
|
||||
@NotNull DataSnapshot.SaveCause saveCause) {
|
||||
return plugin.getOnlineUser(user.getUuid())
|
||||
.map(online -> CompletableFuture.completedFuture(
|
||||
Optional.of(online.createSnapshot(DataSnapshot.SaveCause.API)))
|
||||
)
|
||||
.orElse(this.requestData(requestId, user));
|
||||
Optional.of(online.createSnapshot(saveCause))))
|
||||
.orElse(this.getNetworkedUserData(requestId, user));
|
||||
}
|
||||
|
||||
private CompletableFuture<Optional<DataSnapshot.Packed>> requestData(@NotNull UUID requestId, @NotNull User user) {
|
||||
// Request a user's dat x-server
|
||||
private CompletableFuture<Optional<DataSnapshot.Packed>> getNetworkedUserData(@NotNull UUID requestId,
|
||||
@NotNull User user) {
|
||||
final CompletableFuture<Optional<DataSnapshot.Packed>> future = new CompletableFuture<>();
|
||||
pendingRequests.put(requestId, future);
|
||||
plugin.runAsync(() -> {
|
||||
final RedisMessage redisMessage = RedisMessage.create(
|
||||
user.getUuid(),
|
||||
requestId.toString().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
requestId.toString().getBytes(StandardCharsets.UTF_8));
|
||||
redisMessage.dispatch(plugin, RedisMessage.Type.REQUEST_USER_DATA);
|
||||
});
|
||||
return future
|
||||
.orTimeout(
|
||||
plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds(),
|
||||
TimeUnit.MILLISECONDS
|
||||
)
|
||||
TimeUnit.MILLISECONDS)
|
||||
.exceptionally(throwable -> {
|
||||
pendingRequests.remove(requestId);
|
||||
return Optional.empty();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a user's data to Redis
|
||||
*
|
||||
* @param user the user to set data for
|
||||
* @param data the user's data to set
|
||||
* @param timeToLive The time to cache the data for
|
||||
*/
|
||||
// Set a user's data to Redis
|
||||
@Blocking
|
||||
public void setUserData(@NotNull User user, @NotNull DataSnapshot.Packed data, int timeToLive) {
|
||||
public void setUserData(@NotNull User user, @NotNull DataSnapshot.Packed data) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
jedis.setex(
|
||||
getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId),
|
||||
timeToLive,
|
||||
data.asBytes(plugin)
|
||||
);
|
||||
plugin.debug(String.format("[%s] Set %s key on Redis", user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
RedisKeyType.TTL_1_YEAR,
|
||||
data.asBytes(plugin));
|
||||
plugin.debug(String.format("[%s] Set %s key on Redis", user.getName(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred setting user data on Redis", e);
|
||||
}
|
||||
@@ -270,9 +311,8 @@ public class RedisManager extends JedisPubSub {
|
||||
public void clearUserData(@NotNull User user) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
jedis.del(
|
||||
getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId)
|
||||
);
|
||||
plugin.debug(String.format("[%s] Cleared %s on Redis", user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
getKey(RedisKeyType.LATEST_SNAPSHOT, user.getUuid(), clusterId));
|
||||
plugin.debug(String.format("[%s] Cleared %s on Redis", user.getName(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred clearing user data on Redis", e);
|
||||
}
|
||||
@@ -281,16 +321,20 @@ public class RedisManager extends JedisPubSub {
|
||||
@Blocking
|
||||
public void setUserCheckedOut(@NotNull User user, boolean checkedOut) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
final String key = getKeyString(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId);
|
||||
if (checkedOut) {
|
||||
jedis.set(
|
||||
getKey(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId),
|
||||
plugin.getServerName().getBytes(StandardCharsets.UTF_8)
|
||||
);
|
||||
key.getBytes(StandardCharsets.UTF_8),
|
||||
plugin.getServerName().getBytes(StandardCharsets.UTF_8));
|
||||
} else {
|
||||
jedis.del(getKey(RedisKeyType.DATA_CHECKOUT, user.getUuid(), clusterId));
|
||||
if (jedis.del(key.getBytes(StandardCharsets.UTF_8)) == 0) {
|
||||
plugin.debug(String.format("[%s] %s key not set on Redis when attempting removal (%s)",
|
||||
user.getName(), RedisKeyType.DATA_CHECKOUT, key));
|
||||
return;
|
||||
}
|
||||
}
|
||||
plugin.debug(String.format("[%s] %s %s key to/from Redis", user.getUsername(),
|
||||
checkedOut ? "Set" : "Removed", RedisKeyType.DATA_CHECKOUT));
|
||||
plugin.debug(String.format("[%s] %s %s key %s Redis (%s)", user.getName(),
|
||||
checkedOut ? "Set" : "Removed", RedisKeyType.DATA_CHECKOUT, checkedOut ? "to" : "from", key));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred setting checkout to", e);
|
||||
}
|
||||
@@ -304,13 +348,13 @@ public class RedisManager extends JedisPubSub {
|
||||
if (readData != null) {
|
||||
final String checkoutServer = new String(readData, StandardCharsets.UTF_8);
|
||||
plugin.debug(String.format("[%s] Waiting for %s %s key to be unset on Redis",
|
||||
user.getUsername(), checkoutServer, RedisKeyType.DATA_CHECKOUT));
|
||||
user.getName(), checkoutServer, RedisKeyType.DATA_CHECKOUT));
|
||||
return Optional.of(checkoutServer);
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred getting a user's checkout key from Redis", e);
|
||||
}
|
||||
plugin.debug(String.format("[%s] %s key not set on Redis", user.getUsername(),
|
||||
plugin.debug(String.format("[%s] %s key not set on Redis", user.getName(),
|
||||
RedisKeyType.DATA_CHECKOUT));
|
||||
return Optional.empty();
|
||||
}
|
||||
@@ -345,10 +389,9 @@ public class RedisManager extends JedisPubSub {
|
||||
jedis.setex(
|
||||
getKey(RedisKeyType.SERVER_SWITCH, user.getUuid(), clusterId),
|
||||
RedisKeyType.TTL_10_SECONDS,
|
||||
new byte[0]
|
||||
);
|
||||
new byte[0]);
|
||||
plugin.debug(String.format("[%s] Set %s key to Redis",
|
||||
user.getUsername(), RedisKeyType.SERVER_SWITCH));
|
||||
user.getName(), RedisKeyType.SERVER_SWITCH));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred setting a user's server switch key from Redis", e);
|
||||
}
|
||||
@@ -358,7 +401,8 @@ public class RedisManager extends JedisPubSub {
|
||||
* Fetch a user's data from Redis and consume the key if found
|
||||
*
|
||||
* @param user The user to fetch data for
|
||||
* @return The user's data, if it's present on the database. Otherwise, an empty optional.
|
||||
* @return The user's data, if it's present on the database. Otherwise, an empty
|
||||
* optional.
|
||||
*/
|
||||
@Blocking
|
||||
public Optional<DataSnapshot.Packed> getUserData(@NotNull User user) {
|
||||
@@ -367,11 +411,11 @@ public class RedisManager extends JedisPubSub {
|
||||
final byte[] dataByteArray = jedis.get(key);
|
||||
if (dataByteArray == null) {
|
||||
plugin.debug(String.format("[%s] Waiting for %s key from Redis",
|
||||
user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
user.getName(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
return Optional.empty();
|
||||
}
|
||||
plugin.debug(String.format("[%s] Read %s key from Redis",
|
||||
user.getUsername(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
user.getName(), RedisKeyType.LATEST_SNAPSHOT));
|
||||
|
||||
// Consume the key (delete from redis)
|
||||
jedis.del(key);
|
||||
@@ -391,11 +435,11 @@ public class RedisManager extends JedisPubSub {
|
||||
final byte[] readData = jedis.get(key);
|
||||
if (readData == null) {
|
||||
plugin.debug(String.format("[%s] Waiting for %s key from Redis",
|
||||
user.getUsername(), RedisKeyType.SERVER_SWITCH));
|
||||
user.getName(), RedisKeyType.SERVER_SWITCH));
|
||||
return false;
|
||||
}
|
||||
plugin.debug(String.format("[%s] Read %s key from Redis",
|
||||
user.getUsername(), RedisKeyType.SERVER_SWITCH));
|
||||
user.getName(), RedisKeyType.SERVER_SWITCH));
|
||||
|
||||
// Consume the key (delete from redis)
|
||||
jedis.del(key);
|
||||
@@ -406,6 +450,121 @@ public class RedisManager extends JedisPubSub {
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public String getStatusDump() {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
return jedis.info();
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public long getLatency() {
|
||||
final long startTime = System.currentTimeMillis();
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
jedis.ping();
|
||||
return System.currentTimeMillis() - startTime;
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public String getVersion() {
|
||||
final String info = getStatusDump();
|
||||
for (String line : info.split("\n")) {
|
||||
if (line.startsWith("redis_version:")) {
|
||||
return line.split(":")[1];
|
||||
}
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public void bindMapIds(@NotNull String fromServer, int fromId, @NotNull String toServer, int toId) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
jedis.setex(
|
||||
getMapIdKey(fromServer, fromId, toServer, clusterId),
|
||||
RedisKeyType.TTL_1_YEAR,
|
||||
String.valueOf(toId).getBytes(StandardCharsets.UTF_8));
|
||||
jedis.setex(
|
||||
getReversedMapIdKey(toServer, toId, clusterId),
|
||||
RedisKeyType.TTL_1_YEAR,
|
||||
String.format("%s:%s", fromServer, fromId).getBytes(StandardCharsets.UTF_8));
|
||||
plugin.debug(String.format("Bound map %s:%s -> %s:%s on Redis", fromServer, fromId, toServer, toId));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred binding map ids on Redis", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public Optional<Integer> getBoundMapId(@NotNull String fromServer, int fromId, @NotNull String toServer) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
final byte[] readData = jedis.get(getMapIdKey(fromServer, fromId, toServer, clusterId));
|
||||
if (readData == null) {
|
||||
plugin.debug(String.format("[%s:%s] No bound map id for server %s Redis",
|
||||
fromServer, fromId, toServer));
|
||||
return Optional.empty();
|
||||
}
|
||||
plugin.debug(String.format("[%s:%s] Read bound map id for server %s from Redis",
|
||||
fromServer, fromId, toServer));
|
||||
|
||||
return Optional.of(Integer.parseInt(new String(readData, StandardCharsets.UTF_8)));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred getting bound map id from Redis", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public @Nullable Map.Entry<String, Integer> getReversedMapBound(@NotNull String toServer, int toId) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
final byte[] readData = jedis.get(getReversedMapIdKey(toServer, toId, clusterId));
|
||||
if (readData == null) {
|
||||
plugin.debug(String.format("[%s:%s] No reversed map bound on Redis",
|
||||
toServer, toId));
|
||||
return null;
|
||||
}
|
||||
plugin.debug(String.format("[%s:%s] Read reversed map bound from Redis",
|
||||
toServer, toId));
|
||||
|
||||
String[] parts = new String(readData, StandardCharsets.UTF_8).split(":");
|
||||
return Map.entry(parts[0], Integer.parseInt(parts[1]));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred reading reversed map bound from Redis", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public void setMapData(@NotNull String serverName, int mapId, byte[] data) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
jedis.setex(
|
||||
getMapDataKey(serverName, mapId, clusterId),
|
||||
RedisKeyType.TTL_1_YEAR,
|
||||
data);
|
||||
plugin.debug(String.format("Set map data %s:%s on Redis", serverName, mapId));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred setting map data on Redis", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public byte @Nullable [] getMapData(@NotNull String serverName, int mapId) {
|
||||
try (Jedis jedis = jedisPool.getResource()) {
|
||||
final byte[] readData = jedis.get(getMapDataKey(serverName, mapId, clusterId));
|
||||
if (readData == null) {
|
||||
plugin.debug(String.format("[%s:%s] No map data on Redis",
|
||||
serverName, mapId));
|
||||
return null;
|
||||
}
|
||||
plugin.debug(String.format("[%s:%s] Read map data from Redis",
|
||||
serverName, mapId));
|
||||
|
||||
return readData;
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "An exception occurred reading map data from Redis", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Blocking
|
||||
public void terminate() {
|
||||
enabled = false;
|
||||
@@ -418,7 +577,28 @@ public class RedisManager extends JedisPubSub {
|
||||
}
|
||||
|
||||
private static byte[] getKey(@NotNull RedisKeyType keyType, @NotNull UUID uuid, @NotNull String clusterId) {
|
||||
return String.format("%s:%s", keyType.getKeyPrefix(clusterId), uuid).getBytes(StandardCharsets.UTF_8);
|
||||
return getKeyString(keyType, uuid, clusterId).getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static String getKeyString(@NotNull RedisKeyType keyType, @NotNull UUID uuid, @NotNull String clusterId) {
|
||||
return String.format("%s:%s", keyType.getKeyPrefix(clusterId), uuid);
|
||||
}
|
||||
|
||||
private static byte[] getMapIdKey(@NotNull String fromServer, int fromId, @NotNull String toServer,
|
||||
@NotNull String clusterId) {
|
||||
return String.format("%s:%s:%s:%s", RedisKeyType.MAP_ID.getKeyPrefix(clusterId), fromServer, fromId, toServer)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] getReversedMapIdKey(@NotNull String toServer, int toId, @NotNull String clusterId) {
|
||||
return String.format("%s:%s:%s", RedisKeyType.MAP_ID_REVERSED.getKeyPrefix(clusterId), toServer, toId)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static byte[] getMapDataKey(@NotNull String serverName, int mapId, @NotNull String clusterId) {
|
||||
return String.format("%s:%s:%s", RedisKeyType.MAP_DATA.getKeyPrefix(clusterId), serverName, mapId)
|
||||
.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -21,25 +21,42 @@ package net.william278.husksync.redis;
|
||||
|
||||
import com.google.gson.JsonSyntaxException;
|
||||
import com.google.gson.annotations.SerializedName;
|
||||
import lombok.Getter;
|
||||
import lombok.Setter;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.adapter.Adaptable;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.UUID;
|
||||
|
||||
@Setter
|
||||
public class RedisMessage implements Adaptable {
|
||||
|
||||
private @Nullable String targetServer;
|
||||
@SerializedName("target_uuid")
|
||||
private UUID targetUuid;
|
||||
private @Nullable UUID targetUuid;
|
||||
@Getter
|
||||
@Setter
|
||||
@SerializedName("payload")
|
||||
private byte[] payload;
|
||||
|
||||
private RedisMessage(byte[] payload) {
|
||||
setPayload(payload);
|
||||
}
|
||||
|
||||
private RedisMessage(@NotNull UUID targetUuid, byte[] message) {
|
||||
this(message);
|
||||
this.setTargetUuid(targetUuid);
|
||||
this.setPayload(message);
|
||||
}
|
||||
|
||||
private RedisMessage(@NotNull String targetServer, byte[] message) {
|
||||
this(message);
|
||||
this.setTargetServer(targetServer);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unused")
|
||||
@@ -51,6 +68,11 @@ public class RedisMessage implements Adaptable {
|
||||
return new RedisMessage(targetUuid, message);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static RedisMessage create(@NotNull String targetServer, byte[] message) {
|
||||
return new RedisMessage(targetServer, message);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public static RedisMessage fromJson(@NotNull HuskSync plugin, @NotNull String json) throws JsonSyntaxException {
|
||||
return plugin.getGson().fromJson(json, RedisMessage.class);
|
||||
@@ -63,28 +85,23 @@ public class RedisMessage implements Adaptable {
|
||||
));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public UUID getTargetUuid() {
|
||||
return targetUuid;
|
||||
public Optional<UUID> getTargetUuid() {
|
||||
return Optional.ofNullable(targetUuid);
|
||||
}
|
||||
|
||||
public void setTargetUuid(@NotNull UUID targetUuid) {
|
||||
this.targetUuid = targetUuid;
|
||||
public Optional<OnlineUser> getTargetUser(@NotNull HuskSync plugin) {
|
||||
return getTargetUuid().flatMap(plugin::getOnlineUser);
|
||||
}
|
||||
|
||||
public byte[] getPayload() {
|
||||
return payload;
|
||||
}
|
||||
|
||||
public void setPayload(byte[] payload) {
|
||||
this.payload = payload;
|
||||
public boolean isTargetServer(@NotNull HuskSync plugin) {
|
||||
return targetServer != null && targetServer.equals(plugin.getServerName());
|
||||
}
|
||||
|
||||
public enum Type {
|
||||
|
||||
UPDATE_USER_DATA,
|
||||
REQUEST_USER_DATA,
|
||||
RETURN_USER_DATA;
|
||||
RETURN_USER_DATA,
|
||||
CHECK_IN_PETITION;
|
||||
|
||||
@NotNull
|
||||
public String getMessageChannel(@NotNull String clusterId) {
|
||||
|
||||
@@ -81,18 +81,28 @@ public abstract class DataSyncer {
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when a user's data should be fetched and applied to them
|
||||
* Called when a user's data should be fetched and applied to them as part of a synchronization process
|
||||
*
|
||||
* @param user the user to fetch data for
|
||||
*/
|
||||
public abstract void setUserData(@NotNull OnlineUser user);
|
||||
public abstract void syncApplyUserData(@NotNull OnlineUser user);
|
||||
|
||||
/**
|
||||
* Called when a user's data should be serialized and saved
|
||||
* Called when a user's data should be serialized and saved as part of a synchronization process
|
||||
*
|
||||
* @param user the user to save
|
||||
*/
|
||||
public abstract void saveUserData(@NotNull OnlineUser user);
|
||||
public abstract void syncSaveUserData(@NotNull OnlineUser user);
|
||||
|
||||
/**
|
||||
* Save a user's current data
|
||||
*
|
||||
* @param onlineUser the user to save data of
|
||||
* @param cause the save cause
|
||||
*/
|
||||
public void saveCurrentUserData(@NotNull OnlineUser onlineUser, @NotNull DataSnapshot.SaveCause cause) {
|
||||
this.saveData(onlineUser, onlineUser.createSnapshot(cause), getRedis()::setUserData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a {@link DataSnapshot.Packed user's data snapshot} to the database,
|
||||
@@ -163,7 +173,7 @@ public abstract class DataSyncer {
|
||||
() -> user.completeSync(true, DataSnapshot.UpdateCause.NEW_USER, plugin)
|
||||
);
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.WARNING, "Failed to set %s's data from the database".formatted(user.getUsername()), e);
|
||||
plugin.log(Level.WARNING, "Failed to set %s's data from the database".formatted(user.getName()), e);
|
||||
user.completeSync(false, DataSnapshot.UpdateCause.SYNCHRONIZED, plugin);
|
||||
}
|
||||
}
|
||||
@@ -175,7 +185,7 @@ public abstract class DataSyncer {
|
||||
final AtomicReference<Task.Repeating> task = new AtomicReference<>();
|
||||
final AtomicBoolean processing = new AtomicBoolean(false);
|
||||
final Runnable runnable = () -> {
|
||||
if (user.isOffline()) {
|
||||
if (user.cannotApplySnapshot()) {
|
||||
task.get().cancel();
|
||||
return;
|
||||
}
|
||||
@@ -188,7 +198,7 @@ public abstract class DataSyncer {
|
||||
if (plugin.isDisabling() || timesRun.getAndIncrement() > maxListenAttempts) {
|
||||
task.get().cancel();
|
||||
plugin.debug(String.format("[%s] Redis timed out after %s attempts; setting from database",
|
||||
user.getUsername(), timesRun.get()));
|
||||
user.getName(), timesRun.get()));
|
||||
setUserFromDatabase(user);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ package net.william278.husksync.sync;
|
||||
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.redis.RedisKeyType;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
@@ -35,7 +34,7 @@ public class DelayDataSyncer extends DataSyncer {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserData(@NotNull OnlineUser user) {
|
||||
public void syncApplyUserData(@NotNull OnlineUser user) {
|
||||
plugin.runAsyncDelayed(
|
||||
() -> {
|
||||
// Fetch from the database if the user isn't changing servers
|
||||
@@ -58,12 +57,15 @@ public class DelayDataSyncer extends DataSyncer {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveUserData(@NotNull OnlineUser onlineUser) {
|
||||
public void syncSaveUserData(@NotNull OnlineUser onlineUser) {
|
||||
plugin.runAsync(() -> {
|
||||
getRedis().setUserServerSwitch(onlineUser);
|
||||
saveData(
|
||||
onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
|
||||
(user, data) -> getRedis().setUserData(user, data, RedisKeyType.TTL_10_SECONDS)
|
||||
(user, data) -> {
|
||||
getRedis().setUserData(user, data);
|
||||
plugin.unlockPlayer(user.getUuid());
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ package net.william278.husksync.sync;
|
||||
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.redis.RedisKeyType;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
public class LockstepDataSyncer extends DataSyncer {
|
||||
|
||||
public LockstepDataSyncer(@NotNull HuskSync plugin) {
|
||||
@@ -43,11 +44,23 @@ public class LockstepDataSyncer extends DataSyncer {
|
||||
|
||||
// Consume their data when they are checked in
|
||||
@Override
|
||||
public void setUserData(@NotNull OnlineUser user) {
|
||||
public void syncApplyUserData(@NotNull OnlineUser user) {
|
||||
this.listenForRedisData(user, () -> {
|
||||
if (getRedis().getUserCheckedOut(user).isPresent()) {
|
||||
if (user.cannotApplySnapshot()) {
|
||||
plugin.debug("Not checking data state for user who has gone offline: %s".formatted(user.getName()));
|
||||
return false;
|
||||
}
|
||||
|
||||
// If they are checked out, ask the server to check them back in and return false
|
||||
final Optional<String> server = getRedis().getUserCheckedOut(user);
|
||||
if (server.isPresent() && !server.get().equals(plugin.getServerName())) {
|
||||
if (plugin.getSettings().getSynchronization().isCheckinPetitions()) {
|
||||
getRedis().petitionServerCheckin(server.get(), user);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// If they are checked in - or checked out on *this* server - we can apply their latest data
|
||||
getRedis().setUserCheckedOut(user, true);
|
||||
getRedis().getUserData(user).ifPresentOrElse(
|
||||
data -> user.applySnapshot(data, DataSnapshot.UpdateCause.SYNCHRONIZED),
|
||||
@@ -58,17 +71,15 @@ public class LockstepDataSyncer extends DataSyncer {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void saveUserData(@NotNull OnlineUser onlineUser) {
|
||||
plugin.runAsync(() -> {
|
||||
getRedis().setUserServerSwitch(onlineUser);
|
||||
saveData(
|
||||
onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
|
||||
(user, data) -> {
|
||||
getRedis().setUserData(user, data, RedisKeyType.TTL_1_YEAR);
|
||||
getRedis().setUserCheckedOut(user, false);
|
||||
}
|
||||
);
|
||||
});
|
||||
public void syncSaveUserData(@NotNull OnlineUser onlineUser) {
|
||||
plugin.runAsync(() -> saveData(
|
||||
onlineUser, onlineUser.createSnapshot(DataSnapshot.SaveCause.DISCONNECT),
|
||||
(user, data) -> {
|
||||
getRedis().setUserData(user, data);
|
||||
getRedis().setUserCheckedOut(user, false);
|
||||
plugin.unlockPlayer(user.getUuid());
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -42,4 +42,6 @@ public final class ConsoleUser implements CommandUser {
|
||||
public boolean hasPermission(@NotNull String permission) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -43,11 +43,27 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates if the player has gone offline
|
||||
* Indicates if the player is offline
|
||||
*
|
||||
* @return {@code true} if the player has left the server; {@code false} otherwise
|
||||
* @deprecated use {@code hasDisconnected} instead
|
||||
*/
|
||||
public abstract boolean isOffline();
|
||||
@Deprecated(since = "3.8")
|
||||
public boolean isOffline() {
|
||||
return hasDisconnected();
|
||||
}
|
||||
|
||||
public abstract boolean hasDisconnected();
|
||||
|
||||
// Users cannot have snapshots applied if they have disconnected!
|
||||
@Override
|
||||
public boolean cannotApplySnapshot() {
|
||||
if (hasDisconnected()) {
|
||||
getPlugin().debug("[%s] Cannot apply snapshot as user is offline!".formatted(getName()));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Override
|
||||
@@ -89,7 +105,9 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
|
||||
* @param description the description of the toast
|
||||
* @param iconMaterial the namespace-keyed material to use as an hasIcon of the toast
|
||||
* @param backgroundType the background ("ToastType") of the toast
|
||||
* @deprecated No longer supported
|
||||
*/
|
||||
@Deprecated(since = "3.6.7")
|
||||
public abstract void sendToast(@NotNull MineDown title, @NotNull MineDown description,
|
||||
@NotNull String iconMaterial, @NotNull String backgroundType);
|
||||
|
||||
@@ -115,7 +133,7 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
|
||||
|
||||
|
||||
/**
|
||||
* Set a player's status from a {@link DataSnapshot}
|
||||
* Apply a {@link DataSnapshot} to a player, updating their data
|
||||
*
|
||||
* @param snapshot The {@link DataSnapshot} to set the player's status from
|
||||
* @param cause The {@link DataSnapshot.UpdateCause} of the snapshot
|
||||
@@ -123,14 +141,12 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
|
||||
*/
|
||||
public void applySnapshot(@NotNull DataSnapshot.Packed snapshot, @NotNull DataSnapshot.UpdateCause cause) {
|
||||
getPlugin().fireEvent(getPlugin().getPreSyncEvent(this, snapshot), (event) -> {
|
||||
if (!isOffline()) {
|
||||
getPlugin().debug(String.format("Applying snapshot (%s) to %s (cause: %s)",
|
||||
snapshot.getShortId(), getUsername(), cause.getDisplayName()
|
||||
));
|
||||
UserDataHolder.super.applySnapshot(
|
||||
event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin())
|
||||
);
|
||||
}
|
||||
getPlugin().debug(String.format("Attempting to apply snapshot (%s) to %s (cause: %s)",
|
||||
snapshot.getShortId(), getName(), cause.getDisplayName()
|
||||
));
|
||||
UserDataHolder.super.applySnapshot(
|
||||
event.getData(), (succeeded) -> completeSync(succeeded, cause, getPlugin())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -145,12 +161,6 @@ public abstract class OnlineUser extends User implements CommandUser, UserDataHo
|
||||
switch (plugin.getSettings().getSynchronization().getNotificationDisplaySlot()) {
|
||||
case CHAT -> cause.getCompletedLocale(plugin).ifPresent(this::sendMessage);
|
||||
case ACTION_BAR -> cause.getCompletedLocale(plugin).ifPresent(this::sendActionBar);
|
||||
case TOAST -> cause.getCompletedLocale(plugin)
|
||||
.ifPresent(locale -> this.sendToast(
|
||||
locale, new MineDown(""),
|
||||
"minecraft:bell",
|
||||
"TASK"
|
||||
));
|
||||
}
|
||||
plugin.fireEvent(
|
||||
plugin.getSyncCompleteEvent(this),
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
|
||||
package net.william278.husksync.user;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.Getter;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
@@ -26,39 +29,18 @@ import java.util.UUID;
|
||||
/**
|
||||
* Represents a user who has their data synchronized by HuskSync
|
||||
*/
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
@EqualsAndHashCode(exclude = {"name"})
|
||||
public class User {
|
||||
|
||||
private final UUID uuid;
|
||||
private final String name;
|
||||
|
||||
private final String username;
|
||||
|
||||
public User(@NotNull UUID uuid, @NotNull String username) {
|
||||
this.username = username;
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's unique account ID
|
||||
*/
|
||||
@NotNull
|
||||
public UUID getUuid() {
|
||||
return uuid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the user's username
|
||||
*/
|
||||
@NotNull
|
||||
@Deprecated(since = "3.7.4")
|
||||
public String getUsername() {
|
||||
return username;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object object) {
|
||||
if (object instanceof User other) {
|
||||
return this.getUuid().equals(other.getUuid());
|
||||
}
|
||||
return super.equals(object);
|
||||
return name;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/*
|
||||
* 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.exlll.configlib.Configuration;
|
||||
import de.exlll.configlib.YamlConfigurationProperties;
|
||||
import de.exlll.configlib.YamlConfigurationStore;
|
||||
import lombok.AllArgsConstructor;
|
||||
import net.william278.desertwell.util.Version;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.InputStream;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static net.william278.husksync.config.ConfigProvider.YAML_CONFIGURATION_PROPERTIES;
|
||||
|
||||
public interface CompatibilityChecker {
|
||||
|
||||
String COMPATIBILITY_FILE = "compatibility.yml";
|
||||
|
||||
default void checkCompatibility() throws HuskSync.FailedToLoadException {
|
||||
final YamlConfigurationProperties p = YAML_CONFIGURATION_PROPERTIES.build();
|
||||
final CompatibilityConfig compat;
|
||||
|
||||
// Load compatibility file
|
||||
try (InputStream input = getResource(COMPATIBILITY_FILE)) {
|
||||
compat = new YamlConfigurationStore<>(CompatibilityConfig.class, p).read(input);
|
||||
} catch (Throwable e) {
|
||||
getPlugin().log(Level.WARNING, "Failed to load compatibility config, skipping check.", e);
|
||||
return;
|
||||
}
|
||||
|
||||
// Check compatibility
|
||||
if (!compat.isCompatibleWith(getPlugin().getMinecraftVersion())) {
|
||||
throw new HuskSync.FailedToLoadException("""
|
||||
Incompatible Minecraft version. This version of HuskSync is designed for Minecraft %s.
|
||||
Please download the correct version of HuskSync for your server's Minecraft version (%s)."""
|
||||
.formatted(compat.minecraftVersionRange(), getPlugin().getMinecraftVersion().toString()));
|
||||
}
|
||||
}
|
||||
|
||||
InputStream getResource(@NotNull String name);
|
||||
|
||||
@NotNull
|
||||
HuskSync getPlugin();
|
||||
|
||||
@Configuration
|
||||
record CompatibilityConfig(@NotNull String minecraftVersionRange) {
|
||||
|
||||
@AllArgsConstructor
|
||||
enum ExpressionType {
|
||||
GTE(">=", (v, s) -> v.compareTo(Version.fromString(s.substring(2))) >= 0),
|
||||
LTE("<=", (v, s) -> v.compareTo(Version.fromString(s.substring(2))) <= 0),
|
||||
GT(">", (v, s) -> v.compareTo(Version.fromString(s.substring(1))) > 0),
|
||||
LT("<", (v, s) -> v.compareTo(Version.fromString(s.substring(1))) < 0),
|
||||
NOT("!", (v, s) -> v.compareTo(Version.fromString(s.substring(1))) != 0),
|
||||
E("=", (v, s) -> v.compareTo(Version.fromString(s.substring(1))) == 0);
|
||||
|
||||
private final String match;
|
||||
private final BiFunction<Version, String, Boolean> function;
|
||||
|
||||
private static boolean check(@NotNull String versionRange, @NotNull Version mcVer) {
|
||||
boolean passes = true;
|
||||
versions:
|
||||
for (String exp : versionRange.split(" ")) {
|
||||
for (ExpressionType type : values()) {
|
||||
if (exp.trim().startsWith(type.match)) {
|
||||
passes = passes && type.function.apply(mcVer, exp.trim());
|
||||
continue versions;
|
||||
}
|
||||
}
|
||||
passes = passes && mcVer.compareTo(Version.fromString(exp.trim())) == 0;
|
||||
}
|
||||
return passes;
|
||||
}
|
||||
}
|
||||
|
||||
public boolean isCompatibleWith(@NotNull Version version) {
|
||||
return ExpressionType.check(minecraftVersionRange, version);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,191 +0,0 @@
|
||||
/*
|
||||
* 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 com.google.gson.JsonObject;
|
||||
import com.google.gson.JsonParser;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.user.User;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.*;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.URL;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Utility class for dumping {@link DataSnapshot}s to a file or as a paste on the web
|
||||
*/
|
||||
public class DataDumper {
|
||||
|
||||
private static final String LOGS_SITE_ENDPOINT = "https://api.mclo.gs/1/log";
|
||||
|
||||
private final HuskSync plugin;
|
||||
private final DataSnapshot.Packed snapshot;
|
||||
private final User user;
|
||||
|
||||
private DataDumper(@NotNull DataSnapshot.Packed snapshot, @NotNull User user, @NotNull HuskSync implementor) {
|
||||
this.snapshot = snapshot;
|
||||
this.user = user;
|
||||
this.plugin = implementor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a {@link DataDumper} of the given {@link DataSnapshot}
|
||||
*
|
||||
* @param dataSnapshot The {@link DataSnapshot} to dump
|
||||
* @param user The {@link User} whose data is being dumped
|
||||
* @param plugin The implementing {@link HuskSync} plugin
|
||||
* @return A {@link DataDumper} for the given {@link DataSnapshot}
|
||||
*/
|
||||
public static DataDumper create(@NotNull DataSnapshot.Packed dataSnapshot,
|
||||
@NotNull User user, @NotNull HuskSync plugin) {
|
||||
return new DataDumper(dataSnapshot, user, plugin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps the data snapshot to a string
|
||||
*
|
||||
* @return the data snapshot as a string
|
||||
*/
|
||||
@Override
|
||||
@NotNull
|
||||
public String toString() {
|
||||
return snapshot.asJson(plugin);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public String toWeb() {
|
||||
try {
|
||||
final URL url = new URL(LOGS_SITE_ENDPOINT);
|
||||
final HttpURLConnection connection = (HttpURLConnection) url.openConnection();
|
||||
connection.setRequestMethod("POST");
|
||||
connection.setDoOutput(true);
|
||||
|
||||
// Dispatch the request
|
||||
final byte[] messageBody = getWebContentField().getBytes(StandardCharsets.UTF_8);
|
||||
final int messageLength = messageBody.length;
|
||||
connection.setFixedLengthStreamingMode(messageLength);
|
||||
connection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8");
|
||||
connection.connect();
|
||||
try (OutputStream messageOutputStream = connection.getOutputStream()) {
|
||||
messageOutputStream.write(messageBody);
|
||||
}
|
||||
|
||||
// Get the response
|
||||
if (connection.getResponseCode() == HttpURLConnection.HTTP_OK) {
|
||||
// Get the body as a json
|
||||
try (BufferedReader reader = new BufferedReader(new InputStreamReader(connection.getInputStream()))) {
|
||||
final StringBuilder response = new StringBuilder();
|
||||
String line;
|
||||
while ((line = reader.readLine()) != null) {
|
||||
response.append(line);
|
||||
}
|
||||
|
||||
// Parse the response as json
|
||||
final JsonObject responseJson = JsonParser.parseString(response.toString()).getAsJsonObject();
|
||||
if (responseJson.has("url")) {
|
||||
return responseJson.get("url").getAsString();
|
||||
}
|
||||
return "(Failed to get URL from response)";
|
||||
}
|
||||
} else {
|
||||
return "(Failed to upload to logs site, got: " + connection.getResponseCode() + ")";
|
||||
}
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "Failed to upload data to logs site", e);
|
||||
}
|
||||
return "(Failed to upload to logs site)";
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private String getWebContentField() {
|
||||
return "content=" + URLEncoder.encode(toString(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the {@link DataSnapshot} to a file and return the file name
|
||||
*
|
||||
* @return the relative path of the file the data was dumped to
|
||||
*/
|
||||
@NotNull
|
||||
public String toFile() throws IOException {
|
||||
final File filePath = getFilePath();
|
||||
|
||||
// Write the data from #getString to the file using a writer
|
||||
try (final FileWriter writer = new FileWriter(filePath, StandardCharsets.UTF_8, false)) {
|
||||
writer.write(toString());
|
||||
} catch (IOException e) {
|
||||
throw new IOException("Failed to write data to file", e);
|
||||
}
|
||||
|
||||
return "~/plugins/HuskSync/dumps/" + filePath.getName();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path to dump the data to
|
||||
*
|
||||
* @return the file path
|
||||
* @throws IOException if the prerequisite dumps parent folder could not be created
|
||||
*/
|
||||
@NotNull
|
||||
private File getFilePath() throws IOException {
|
||||
return new File(getDumpsFolder(), getFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the folder to dump the data to and create it if it does not exist
|
||||
*
|
||||
* @return the dumps folder
|
||||
* @throws IOException if the folder could not be created
|
||||
*/
|
||||
@NotNull
|
||||
private File getDumpsFolder() throws IOException {
|
||||
final File dumpsFolder = new File(plugin.getDataFolder(), "dumps");
|
||||
if (!dumpsFolder.exists()) {
|
||||
if (!dumpsFolder.mkdirs()) {
|
||||
throw new IOException("Failed to create user data dumps folder");
|
||||
}
|
||||
}
|
||||
return dumpsFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the file to dump the data snapshot to
|
||||
*
|
||||
* @return the file name
|
||||
*/
|
||||
@NotNull
|
||||
private String getFileName() {
|
||||
return new StringJoiner("_")
|
||||
.add(user.getUsername())
|
||||
.add(snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")))
|
||||
.add(snapshot.getSaveCause().name().toLowerCase(Locale.ENGLISH))
|
||||
.add(snapshot.getShortId())
|
||||
+ ".json";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -49,7 +49,7 @@ public class DataSnapshotList {
|
||||
.map(snapshot -> plugin.getLocales()
|
||||
.getRawLocale(!snapshot.isInvalid() ? "data_list_item" : "data_list_item_invalid",
|
||||
getNumberIcon(snapshotNumber.getAndIncrement()),
|
||||
dataOwner.getUsername(),
|
||||
dataOwner.getName(),
|
||||
snapshot.getId().toString(),
|
||||
snapshot.getShortId(),
|
||||
snapshot.isPinned() ? "※" : " ",
|
||||
@@ -63,10 +63,10 @@ public class DataSnapshotList {
|
||||
.orElse("• " + snapshot.getId())).toList(),
|
||||
plugin.getLocales().getBaseChatList(6)
|
||||
.setHeaderFormat(plugin.getLocales()
|
||||
.getRawLocale("data_list_title", dataOwner.getUsername(),
|
||||
.getRawLocale("data_list_title", dataOwner.getName(),
|
||||
"%first_item_on_page_index%", "%last_item_on_page_index%", "%total_items%")
|
||||
.orElse(""))
|
||||
.setCommand("/husksync:userdata list " + dataOwner.getUsername())
|
||||
.setCommand("/husksync:userdata list " + dataOwner.getName())
|
||||
.build());
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,7 @@ public class DataSnapshotOverview {
|
||||
// Title message, timestamp, owner and cause.
|
||||
final Locales locales = plugin.getLocales();
|
||||
locales.getLocale("data_manager_title", snapshot.getShortId(), snapshot.getId().toString(),
|
||||
dataOwner.getUsername(), dataOwner.getUuid().toString())
|
||||
dataOwner.getName(), dataOwner.getUuid().toString())
|
||||
.ifPresent(user::sendMessage);
|
||||
locales.getLocale("data_manager_timestamp",
|
||||
snapshot.getTimestamp().format(DateTimeFormatter
|
||||
@@ -106,14 +106,14 @@ public class DataSnapshotOverview {
|
||||
.ifPresent(user::sendMessage);
|
||||
|
||||
if (user.hasPermission("husksync.command.inventory.edit")
|
||||
&& user.hasPermission("husksync.command.enderchest.edit")) {
|
||||
locales.getLocale("data_manager_item_buttons", dataOwner.getUsername(), snapshot.getId().toString())
|
||||
&& user.hasPermission("husksync.command.enderchest.edit")) {
|
||||
locales.getLocale("data_manager_item_buttons", dataOwner.getName(), snapshot.getId().toString())
|
||||
.ifPresent(user::sendMessage);
|
||||
}
|
||||
locales.getLocale("data_manager_management_buttons", dataOwner.getUsername(), snapshot.getId().toString())
|
||||
locales.getLocale("data_manager_management_buttons", dataOwner.getName(), snapshot.getId().toString())
|
||||
.ifPresent(user::sendMessage);
|
||||
if (user.hasPermission("husksync.command.userdata.dump")) {
|
||||
locales.getLocale("data_manager_system_buttons", dataOwner.getUsername(), snapshot.getId().toString())
|
||||
locales.getLocale("data_manager_system_buttons", dataOwner.getName(), snapshot.getId().toString())
|
||||
.ifPresent(user::sendMessage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* 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.desertwell.util.Version;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public interface DataVersionSupplier {
|
||||
|
||||
int VERSION1_16_5 = 2586;
|
||||
int VERSION1_17_1 = 2730;
|
||||
int VERSION1_18_2 = 2975;
|
||||
int VERSION1_19_2 = 3120;
|
||||
int VERSION1_19_4 = 3337;
|
||||
int VERSION1_20_1 = 3465;
|
||||
int VERSION1_20_2 = 3578;
|
||||
int VERSION1_20_4 = 3700;
|
||||
int VERSION1_20_5 = 3837;
|
||||
int VERSION1_21_1 = 3955;
|
||||
int VERSION1_21_3 = 4082;
|
||||
int VERSION1_21_4 = 4189;
|
||||
int VERSION1_21_5 = 4323;
|
||||
int VERSION1_21_6 = 4435;
|
||||
int VERSION1_21_7 = 4438;
|
||||
int VERSION1_21_8 = 4438;
|
||||
int VERSION1_21_9 = 4554;
|
||||
int VERSION1_21_10 = 4556;
|
||||
int VERSION1_21_11 = 4671;
|
||||
|
||||
/**
|
||||
* Returns the data version for a Minecraft version
|
||||
*
|
||||
* @param mcVersion the Minecraft version
|
||||
* @return the data version int
|
||||
*/
|
||||
default int getDataVersion(@NotNull Version mcVersion) {
|
||||
return switch (mcVersion.toStringWithoutMetadata()) {
|
||||
case "1.16", "1.16.1", "1.16.2", "1.16.3", "1.16.4", "1.16.5" -> VERSION1_16_5;
|
||||
case "1.17", "1.17.1" -> VERSION1_17_1;
|
||||
case "1.18", "1.18.1", "1.18.2" -> VERSION1_18_2;
|
||||
case "1.19", "1.19.1", "1.19.2" -> VERSION1_19_2;
|
||||
case "1.19.4" -> VERSION1_19_4;
|
||||
case "1.20", "1.20.1" -> VERSION1_20_1;
|
||||
case "1.20.2" -> VERSION1_20_2;
|
||||
case "1.20.4" -> VERSION1_20_4;
|
||||
case "1.20.5", "1.20.6" -> VERSION1_20_5;
|
||||
case "1.21", "1.21.1" -> VERSION1_21_1;
|
||||
case "1.21.2", "1.21.3" -> VERSION1_21_3;
|
||||
case "1.21.4" -> VERSION1_21_4;
|
||||
case "1.21.5" -> VERSION1_21_5;
|
||||
case "1.21.6" -> VERSION1_21_6;
|
||||
case "1.21.7" -> VERSION1_21_7;
|
||||
case "1.21.8" -> VERSION1_21_8;
|
||||
case "1.21.9" -> VERSION1_21_9;
|
||||
case "1.21.10" -> VERSION1_21_10;
|
||||
case "1.21.11" -> VERSION1_21_11;
|
||||
default -> VERSION1_21_11; // Latest supported version
|
||||
};
|
||||
}
|
||||
|
||||
@NotNull
|
||||
HuskSync getPlugin();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/*
|
||||
* 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.user.CommandUser;
|
||||
import net.william278.husksync.user.OnlineUser;
|
||||
import net.william278.toilet.DumpOptions;
|
||||
import net.william278.toilet.Toilet;
|
||||
import net.william278.toilet.dump.*;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import static net.william278.toilet.DumpOptions.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public interface DumpProvider {
|
||||
|
||||
@NotNull String BYTEBIN_URL = "https://bytebin.lucko.me";
|
||||
@NotNull String VIEWER_URL = "https://william278.net/dump";
|
||||
|
||||
@NotNull
|
||||
Toilet getToilet();
|
||||
|
||||
@NotNull
|
||||
@Blocking
|
||||
default String createDump(@NotNull CommandUser u) {
|
||||
return getToilet().dump(getPluginStatus(), u instanceof OnlineUser o
|
||||
? new DumpUser(o.getName(), o.getUuid()) : null,
|
||||
getRedisInfo()).toString();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
default DumpOptions getDumpOptions() {
|
||||
return builder()
|
||||
.bytebinUrl(BYTEBIN_URL)
|
||||
.viewerUrl(VIEWER_URL)
|
||||
.projectMeta(ProjectMeta.builder()
|
||||
.id("husksync")
|
||||
.name("HuskSync")
|
||||
.version(getPlugin().getPluginVersion().toString())
|
||||
.md5("unknown")
|
||||
.author("William278")
|
||||
.sourceCode("https://github.com/WiIIiam278/HuskSync")
|
||||
.website("https://william278.net/project/husksync")
|
||||
.support("https://discord.gg/tVYhJfyDWG")
|
||||
.build())
|
||||
.fileInclusionRules(List.of(
|
||||
FileInclusionRule.configFile("config.yml", "Config File"),
|
||||
FileInclusionRule.configFile(getMessagesFile(), "Locales File")
|
||||
))
|
||||
.compatibilityRules(List.of(
|
||||
getCompatibilityWarning("CombatLogX", "Combat loggers require additional" +
|
||||
"configuration for use with HuskSync. Check https://william278.net/docs/husksync/event-priorities"),
|
||||
getIncompatibleNotice("UltimateAutoRestart", "Restart plugins are not" +
|
||||
"compatible with HuskSync as they affect the way the server shuts down, preventing data" +
|
||||
"from saving correctly during a restart. Check https://william278.net/docs/husksync/troubleshooting")
|
||||
))
|
||||
.build();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Blocking
|
||||
private PluginStatus getPluginStatus() {
|
||||
return PluginStatus.builder()
|
||||
.blocks(List.of(getSystemStatus(), getRegisteredDataTypes()))
|
||||
.build();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Blocking
|
||||
private PluginStatus.MapStatusBlock getSystemStatus() {
|
||||
return new PluginStatus.MapStatusBlock(
|
||||
Map.ofEntries(
|
||||
Map.entry("Language", StatusLine.LANGUAGE.getValue(getPlugin())),
|
||||
Map.entry("Database Type", StatusLine.DATABASE_TYPE.getValue(getPlugin())),
|
||||
Map.entry("Database Local", StatusLine.IS_DATABASE_LOCAL.getValue(getPlugin())),
|
||||
Map.entry("Locked User Handler", StatusLine.LOCKED_USER_HANDLER.getValue(getPlugin())),
|
||||
Map.entry("Server Name", StatusLine.SERVER_NAME.getValue(getPlugin())),
|
||||
Map.entry("Redis Version", StatusLine.REDIS_VERSION.getValue(getPlugin())),
|
||||
Map.entry("Redis Latency", StatusLine.REDIS_LATENCY.getValue(getPlugin())),
|
||||
Map.entry("Redis Sentinel", StatusLine.USING_REDIS_SENTINEL.getValue(getPlugin())),
|
||||
Map.entry("Redis Database", StatusLine.REDIS_DATABASE.getValue(getPlugin())),
|
||||
Map.entry("Redis User", StatusLine.USING_REDIS_USER.getValue(getPlugin())),
|
||||
Map.entry("Redis Password", StatusLine.USING_REDIS_PASSWORD.getValue(getPlugin())),
|
||||
Map.entry("Redis SSL", StatusLine.REDIS_USING_SSL.getValue(getPlugin())),
|
||||
Map.entry("Redis Local", StatusLine.IS_REDIS_LOCAL.getValue(getPlugin()))
|
||||
),
|
||||
"Plugin Status", "fa6-solid:wrench"
|
||||
);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Blocking
|
||||
private PluginStatus.MapStatusBlock getRegisteredDataTypes() {
|
||||
return new PluginStatus.MapStatusBlock(
|
||||
getPlugin().getRegisteredDataTypes().stream().collect(Collectors.toMap(
|
||||
i -> i.getKey().asMinimalString(),
|
||||
i -> i.isEnabled() ? "✅ Enabled" : "❌ Disabled",
|
||||
(a, b) -> a)
|
||||
),
|
||||
"Registered Data Types", "carbon:data-blob"
|
||||
);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@Blocking
|
||||
private ExtraFile getRedisInfo() {
|
||||
return new ExtraFile(
|
||||
"redis-status", "Redis Status", "devicon-plain:redis",
|
||||
getPlugin().getRedisManager().getStatusDump(),
|
||||
"markdown"
|
||||
);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
private CompatibilityRule getCompatibilityWarning(@NotNull String plugin, @NotNull String description) {
|
||||
return CompatibilityRule.builder()
|
||||
.labelToApply(new PluginInfo.Label("Warning", "#fcba03", description))
|
||||
.resourceName(plugin).build();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
@SuppressWarnings("SameParameterValue")
|
||||
private CompatibilityRule getIncompatibleNotice(@NotNull String plugin, @NotNull String description) {
|
||||
return CompatibilityRule.builder()
|
||||
.labelToApply(new PluginInfo.Label("Incompatible", "#ff3300", description))
|
||||
.resourceName(plugin).build();
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private String getMessagesFile() {
|
||||
return "messages-%s.yml".formatted(getPlugin().getSettings().getLanguage());
|
||||
}
|
||||
|
||||
@NotNull
|
||||
HuskSync getPlugin();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
* 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.kyori.adventure.text.Component;
|
||||
import net.kyori.adventure.text.JoinConfiguration;
|
||||
import net.kyori.adventure.text.event.HoverEvent;
|
||||
import net.kyori.adventure.text.format.NamedTextColor;
|
||||
import net.kyori.adventure.text.format.TextColor;
|
||||
import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.database.Database;
|
||||
import org.apache.commons.text.WordUtils;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public enum StatusLine {
|
||||
PLUGIN_VERSION(plugin -> Component.text("v" + plugin.getPluginVersion().toStringWithoutMetadata())
|
||||
.appendSpace().append(plugin.getPluginVersion().getMetadata().isBlank() ? Component.empty()
|
||||
: Component.text("(build " + plugin.getPluginVersion().getMetadata() + ")"))),
|
||||
SERVER_VERSION(plugin -> Component.text(plugin.getServerVersion())),
|
||||
LANGUAGE(plugin -> Component.text(plugin.getSettings().getLanguage())),
|
||||
MINECRAFT_VERSION(plugin -> Component.text(plugin.getMinecraftVersion().toString())),
|
||||
JAVA_VERSION(plugin -> Component.text(System.getProperty("java.version"))),
|
||||
JAVA_VENDOR(plugin -> Component.text(System.getProperty("java.vendor"))),
|
||||
SERVER_NAME(plugin -> Component.text(plugin.getServerName())),
|
||||
CLUSTER_ID(plugin -> Component.text(plugin.getSettings().getClusterId().isBlank() ? "None" : plugin.getSettings().getClusterId())),
|
||||
SYNC_MODE(plugin -> Component.text(WordUtils.capitalizeFully(
|
||||
plugin.getSettings().getSynchronization().getMode().toString()
|
||||
))),
|
||||
DELAY_LATENCY(plugin -> Component.text(
|
||||
plugin.getSettings().getSynchronization().getNetworkLatencyMilliseconds() + "ms"
|
||||
)),
|
||||
DATABASE_TYPE(plugin ->
|
||||
Component.text(plugin.getSettings().getDatabase().getType().getDisplayName() +
|
||||
(plugin.getSettings().getDatabase().getType() == Database.Type.MONGO ?
|
||||
(plugin.getSettings().getDatabase().getMongoSettings().isUsingAtlas() ? " Atlas" : "") : ""))
|
||||
),
|
||||
IS_DATABASE_LOCAL(plugin -> getLocalhostBoolean(plugin.getSettings().getDatabase().getCredentials().getHost())),
|
||||
REDIS_VERSION(plugin -> Component.text(plugin.getRedisManager().getVersion())),
|
||||
USING_REDIS_SENTINEL(plugin -> getBoolean(
|
||||
!plugin.getSettings().getRedis().getSentinel().getMaster().isBlank()
|
||||
)),
|
||||
REDIS_DATABASE(plugin -> Component.text(plugin.getSettings().getRedis().getCredentials().getDatabase())),
|
||||
USING_REDIS_USER(plugin -> getBoolean(
|
||||
!plugin.getSettings().getRedis().getCredentials().getUser().isBlank()
|
||||
)),
|
||||
USING_REDIS_PASSWORD(plugin -> getBoolean(
|
||||
!plugin.getSettings().getRedis().getCredentials().getPassword().isBlank()
|
||||
)),
|
||||
REDIS_USING_SSL(plugin -> getBoolean(
|
||||
plugin.getSettings().getRedis().getCredentials().isUseSsl()
|
||||
)),
|
||||
REDIS_LATENCY(plugin -> Component.text("%sms".formatted(plugin.getRedisManager().getLatency()))),
|
||||
IS_REDIS_LOCAL(plugin -> getLocalhostBoolean(
|
||||
plugin.getSettings().getRedis().getCredentials().getHost()
|
||||
)),
|
||||
LOCKED_USER_HANDLER(plugin -> Component.text(plugin.getLockedHandler().getClass().getSimpleName())),
|
||||
DATA_TYPES(plugin -> Component.join(
|
||||
JoinConfiguration.commas(true),
|
||||
plugin.getRegisteredDataTypes().stream().map(i -> Component.textOfChildren(Component.text(i.toString())
|
||||
.appendSpace().append(Component.text(i.isEnabled() ? '✔' : '❌')))
|
||||
.color(i.isEnabled() ? NamedTextColor.GREEN : NamedTextColor.RED)
|
||||
.hoverEvent(HoverEvent.showText(
|
||||
Component.text(i.isEnabled() ? "Enabled" : "Disabled")
|
||||
.append(Component.newline())
|
||||
.append(Component.text("Dependencies: %s".formatted(i.getDependencies()
|
||||
.isEmpty() ? "(None)" : i.getDependencies().stream()
|
||||
.map(d -> "%s (%s)".formatted(
|
||||
d.getKey().value(), d.isRequired() ? "Required" : "Optional"
|
||||
)).collect(Collectors.joining(", ")))
|
||||
).color(NamedTextColor.GRAY))
|
||||
))).toList()
|
||||
));
|
||||
|
||||
private final Function<HuskSync, Component> supplier;
|
||||
|
||||
StatusLine(@NotNull Function<HuskSync, Component> supplier) {
|
||||
this.supplier = supplier;
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public Component get(@NotNull HuskSync plugin) {
|
||||
return Component
|
||||
.text("•").appendSpace()
|
||||
.append(Component.text(
|
||||
WordUtils.capitalizeFully(name().replaceAll("_", " ")),
|
||||
TextColor.color(0x848484)
|
||||
))
|
||||
.append(Component.text(':')).append(Component.space().color(NamedTextColor.WHITE))
|
||||
.append(supplier.apply(plugin));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public String getValue(@NotNull HuskSync plugin) {
|
||||
return PlainTextComponentSerializer.plainText().serialize(supplier.apply(plugin));
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static Component getBoolean(boolean value) {
|
||||
return Component.text(value ? "Yes" : "No", value ? NamedTextColor.GREEN : NamedTextColor.RED);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
private static Component getLocalhostBoolean(@NotNull String value) {
|
||||
return getBoolean(value.equals("127.0.0.1") || value.equals("0.0.0.0")
|
||||
|| value.equals("localhost") || value.equals("::1"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
/*
|
||||
* 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 lombok.AccessLevel;
|
||||
import lombok.AllArgsConstructor;
|
||||
import net.william278.husksync.HuskSync;
|
||||
import net.william278.husksync.data.DataSnapshot;
|
||||
import net.william278.husksync.user.User;
|
||||
import net.william278.toilet.web.Flusher;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.Locale;
|
||||
import java.util.StringJoiner;
|
||||
import java.util.logging.Level;
|
||||
|
||||
import static net.william278.husksync.util.DumpProvider.BYTEBIN_URL;
|
||||
|
||||
/**
|
||||
* Utility class for dumping {@link DataSnapshot}s to a file or as a paste on the web
|
||||
*/
|
||||
@AllArgsConstructor(access = AccessLevel.PRIVATE)
|
||||
public class UserDataDumper implements Flusher {
|
||||
|
||||
private static final String PASTE_VIEWER_URL = "https://pastes.dev";
|
||||
|
||||
private final DataSnapshot.Packed snapshot;
|
||||
private final User user;
|
||||
private final HuskSync plugin;
|
||||
|
||||
/**
|
||||
* Create a {@link UserDataDumper} of the given {@link DataSnapshot}
|
||||
*
|
||||
* @param snapshot The {@link DataSnapshot} to dump
|
||||
* @param user The {@link User} whose data is being dumped
|
||||
* @param plugin The implementing {@link HuskSync} plugin
|
||||
* @return A {@link UserDataDumper} for the given {@link DataSnapshot}
|
||||
*/
|
||||
@NotNull
|
||||
public static UserDataDumper create(@NotNull DataSnapshot.Packed snapshot, @NotNull User user,
|
||||
@NotNull HuskSync plugin) {
|
||||
return new UserDataDumper(snapshot, user, plugin);
|
||||
}
|
||||
|
||||
@NotNull
|
||||
public String toWeb() {
|
||||
try {
|
||||
return "%s/%s".formatted(PASTE_VIEWER_URL, uploadDump(toString(), BYTEBIN_URL, "husksync"));
|
||||
} catch (Throwable e) {
|
||||
plugin.log(Level.SEVERE, "Failed to upload data.", e);
|
||||
}
|
||||
return "(Failed to upload. Try dumping to a file instead.)";
|
||||
}
|
||||
|
||||
/**
|
||||
* Dump the {@link DataSnapshot} to a file and return the file name
|
||||
*
|
||||
* @return the relative path of the file the data was dumped to
|
||||
*/
|
||||
@NotNull
|
||||
public String toFile() throws IOException {
|
||||
final Path filePath = getFilePath();
|
||||
try (final FileWriter writer = new FileWriter(filePath.toFile(), StandardCharsets.UTF_8, false)) {
|
||||
writer.write(toString()); // Write the data from #getString to the file using a writer
|
||||
return filePath.toString();
|
||||
} catch (IOException e) {
|
||||
throw new IOException("Failed to write dump to file", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file path to dump the data to
|
||||
*
|
||||
* @return the file path
|
||||
* @throws IOException if the prerequisite dumps parent folder could not be created
|
||||
*/
|
||||
@NotNull
|
||||
private Path getFilePath() throws IOException {
|
||||
return getDumpsFolder().resolve(getFileName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the folder to dump the data to and create it if it does not exist
|
||||
*
|
||||
* @return the dumps folder
|
||||
* @throws IOException if the folder could not be created
|
||||
*/
|
||||
@NotNull
|
||||
private Path getDumpsFolder() throws IOException {
|
||||
final Path dumps = plugin.getConfigDirectory().resolve("dumps");
|
||||
if (!Files.exists(dumps)) {
|
||||
Files.createDirectory(dumps);
|
||||
}
|
||||
return dumps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the file to dump the data snapshot to
|
||||
*
|
||||
* @return the file name
|
||||
*/
|
||||
@NotNull
|
||||
private String getFileName() {
|
||||
return new StringJoiner("_")
|
||||
.add(user.getName())
|
||||
.add(snapshot.getTimestamp().format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss")))
|
||||
.add(snapshot.getSaveCause().name().toLowerCase(Locale.ENGLISH))
|
||||
.add(snapshot.getShortId())
|
||||
+ ".json";
|
||||
}
|
||||
|
||||
/**
|
||||
* Dumps the data snapshot to a string
|
||||
*
|
||||
* @return the data snapshot as a string
|
||||
*/
|
||||
@Override
|
||||
@NotNull
|
||||
public String toString() {
|
||||
return snapshot.asJson(plugin);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -29,4 +29,28 @@ CREATE TABLE IF NOT EXISTS `%user_data_table%`
|
||||
FOREIGN KEY (`player_uuid`) REFERENCES `%users_table%` (`uuid`) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci;
|
||||
COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
-- Create the map data table if it does not exist
|
||||
CREATE TABLE IF NOT EXISTS `%map_data_table%`
|
||||
(
|
||||
`server_name` varchar(32) NOT NULL,
|
||||
`map_id` int NOT NULL,
|
||||
`data` longblob NOT NULL,
|
||||
PRIMARY KEY (`server_name`, `map_id`)
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
-- Create the map ids table if it does not exist
|
||||
CREATE TABLE IF NOT EXISTS `%map_ids_table%`
|
||||
(
|
||||
`from_server_name` varchar(32) NOT NULL,
|
||||
`from_id` int NOT NULL,
|
||||
`to_server_name` varchar(32) NOT NULL,
|
||||
`to_id` int NOT NULL,
|
||||
PRIMARY KEY (`from_server_name`, `from_id`, `to_server_name`),
|
||||
FOREIGN KEY (`from_server_name`, `from_id`) REFERENCES `%map_data_table%` (`server_name`, `map_id`) ON DELETE CASCADE
|
||||
) ENGINE = InnoDB
|
||||
DEFAULT CHARSET = utf8mb4
|
||||
COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user