Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
424aa67b6e | ||
|
|
549a4ad81b | ||
|
|
7726b9aa4e | ||
|
|
a90aad5b67 | ||
|
|
806e023657 | ||
|
|
39fb971202 | ||
|
|
adc4245c71 | ||
|
|
a4a0b07dfb | ||
|
|
713957c382 | ||
|
|
16a1c283f2 | ||
|
|
f0cb770372 | ||
|
|
44b85b2c17 | ||
|
|
4b962cf55e | ||
|
|
867b7b3484 | ||
|
|
54c2bdc301 | ||
|
|
b0f6330d6e | ||
|
|
ffbacd2962 | ||
|
|
7cd2664ed4 | ||
|
|
9ea2cdb378 | ||
|
|
d99e037f9a | ||
|
|
2a8cd059b8 | ||
|
|
85c075ad4d | ||
|
|
3b5a27e71b | ||
|
|
5c18ee6e20 | ||
|
|
d87a97bb08 | ||
|
|
4a303c1768 | ||
|
|
0926bcc942 | ||
|
|
0d8dbaf9d7 | ||
|
|
d25fcb3a1f | ||
|
|
0edd1f28e5 | ||
|
|
065df5755f | ||
|
|
d73fc25c96 | ||
|
|
19db638891 | ||
|
|
bfab25826f | ||
|
|
435ee95094 | ||
|
|
6a48a17873 | ||
|
|
14ac8ee526 | ||
|
|
4ee9566f10 | ||
|
|
1d67bac8c8 | ||
|
|
f565ce54b9 | ||
|
|
e328f959a6 | ||
|
|
b91103ba9a | ||
|
|
807622c418 | ||
|
|
8a97f07c5a | ||
|
|
d1bfffe49b | ||
|
|
f33cf9fb0d | ||
|
|
66decb36ae | ||
|
|
16f04aab1a | ||
|
|
a5d698a266 | ||
|
|
a16cda7c22 | ||
|
|
599a7de7fc | ||
|
|
0eab4f7f4d | ||
|
|
179cb69146 | ||
|
|
6d4472cb0e | ||
|
|
2ec966b567 | ||
|
|
c2c697dc4a | ||
|
|
f615f689f6 | ||
|
|
bb1d951c73 | ||
|
|
a9308a5aac | ||
|
|
f7135fc9a6 | ||
|
|
6f27717d88 | ||
|
|
178c464cae | ||
|
|
0c6cce2a4f | ||
|
|
4194f674ad | ||
|
|
e640d0ee1d | ||
|
|
9001db4a5c | ||
|
|
1ae9b5553b | ||
|
|
8b0787f87e | ||
|
|
8eed5bd45c | ||
|
|
af6b0994cb | ||
|
|
dbb36ec696 | ||
|
|
78144fd7e2 | ||
|
|
65c6a2a0a2 | ||
|
|
68a005267a | ||
|
|
15284f166f | ||
|
|
12f86d4240 | ||
|
|
ea53f8e5b3 | ||
|
|
c7d61d13ba | ||
|
|
e77cfcb378 | ||
|
|
99f18fd6f7 | ||
|
|
3977d02e2d | ||
|
|
fe20445e53 | ||
|
|
4670b60b9a | ||
|
|
e772713328 | ||
|
|
ae5dab6475 | ||
|
|
a5c53d7ec5 | ||
|
|
e66c3fa92c | ||
|
|
ffa95e5d54 | ||
|
|
683ebe4ac4 | ||
|
|
1ce3e6099e | ||
|
|
1fd4319454 | ||
|
|
8061c4b267 | ||
|
|
bb59fbc898 | ||
|
|
420f963fa9 |
No files matched your search
@@ -10,14 +10,14 @@ jobs:
|
||||
steps:
|
||||
# Download code from repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Setup Java
|
||||
- name: Setup JDK
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
|
||||
# Build plugin
|
||||
- name: Build with Maven
|
||||
|
||||
+33
-29
@@ -6,23 +6,23 @@ jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 8
|
||||
continue-on-error: ${{ matrix.version == '1.19.3' }}
|
||||
continue-on-error: ${{ matrix.version == '1.20.4' }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
flavor: ['bukkit', 'spigot', 'paper', 'purpur']
|
||||
version: ['1.19.3', '1.18.2', '1.17.1', '1.16.5']
|
||||
version: ['1.20.4', '1.19.4', '1.18.2', '1.17.1', '1.16.5']
|
||||
steps:
|
||||
# Download code from repository
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Setup Java (for building)
|
||||
- name: Setup JDK (for building)
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: '17'
|
||||
java-version: '21'
|
||||
|
||||
# Build plugin
|
||||
- name: Build with Maven
|
||||
@@ -30,16 +30,23 @@ jobs:
|
||||
|
||||
# Setup Java (for testing)
|
||||
- name: Setup JDK (for testing)
|
||||
uses: actions/setup-java@v3
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: 'temurin'
|
||||
java-version: ${{ startsWith(matrix.version, '1.16') && '11' || (startsWith(matrix.version, '1.17') && '16' || '17') }}
|
||||
java-version: |
|
||||
${{
|
||||
startsWith(matrix.version, '1.16') && '11' ||
|
||||
startsWith(matrix.version, '1.17') && '16' ||
|
||||
startsWith(matrix.version, '1.18') && '17' ||
|
||||
startsWith(matrix.version, '1.19') && '17' ||
|
||||
'21'
|
||||
}}
|
||||
|
||||
# Setup Node.js
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v3
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: '17'
|
||||
node-version: '20'
|
||||
|
||||
# Setup automata
|
||||
- name: Setup automata
|
||||
@@ -67,26 +74,21 @@ jobs:
|
||||
wget -nv "$url" -O server.jar
|
||||
java -jar server.jar || true
|
||||
sed -i 's/eula=false/eula=true/g' eula.txt
|
||||
echo "online-mode=false" >> server.properties
|
||||
echo "enable-rcon=true" >> server.properties
|
||||
echo "rcon.password=rcon" >> server.properties
|
||||
echo "allow-nether=false" >> server.properties
|
||||
echo "allow-end=false" >> server.properties
|
||||
echo 'online-mode=false' >> server.properties
|
||||
echo 'enable-rcon=true' >> server.properties
|
||||
echo 'rcon.password=rcon' >> server.properties
|
||||
echo 'allow-nether=false' >> server.properties
|
||||
echo 'allow-end=false' >> server.properties
|
||||
echo 'generate-structures=false' >> server.properties
|
||||
echo "level-type=flat" >> server.properties
|
||||
echo 'level-type=flat' >> server.properties
|
||||
|
||||
# Install dependencies
|
||||
- name: Install plugin dependencies
|
||||
env:
|
||||
VERSION: ${{ matrix.version }}
|
||||
run: |
|
||||
if [ $VERSION == "1.19.3" ]; then
|
||||
url="https://ci.dmulloy2.net/job/ProtocolLib/lastStableBuild/artifact/target/ProtocolLib.jar"
|
||||
else
|
||||
url="https://github.com/dmulloy2/ProtocolLib/releases/download/4.8.0/ProtocolLib.jar"
|
||||
fi
|
||||
mkdir -p ./server/plugins
|
||||
url="https://ci.dmulloy2.net/job/ProtocolLib/lastSuccessfulBuild/artifact/build/libs/ProtocolLib.jar"
|
||||
mkdir -p ./server/plugins/ProtocolLib
|
||||
wget -q "$url" -O ./server/plugins/ProtocolLib.jar
|
||||
echo -e "global:\n auto updater:\n notify: false\n download: false" > ./server/plugins/ProtocolLib/config.yml
|
||||
|
||||
# Install plugin
|
||||
- name: Install plugin
|
||||
@@ -101,10 +103,12 @@ jobs:
|
||||
# Test plugin
|
||||
- name: Run plugin in server
|
||||
working-directory: ./server
|
||||
env:
|
||||
FLAVOR: ${{ matrix.flavor }}
|
||||
run: |
|
||||
(cd ../automata && npm run start) &
|
||||
timeout -s SIGINT -k 110s 90s java -DIReallyKnowWhatIAmDoingISwear -jar server.jar nogui | tee server.log || true
|
||||
if ! grep -Fq '[YamipaPlugin] Loading YamipaPlugin' server.log; then
|
||||
if ! grep -Fq '[YamipaPlugin] ' server.log; then
|
||||
echo "Plugin did not load"
|
||||
exit 1
|
||||
fi
|
||||
@@ -112,23 +116,23 @@ jobs:
|
||||
echo "Plugin did not enabled verbose mode"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] Found 5 file(s) in images directory' server.log; then
|
||||
if ! grep -Fq '[YamipaPlugin] [ImageStorage] Found 5 file(s) in images directory' server.log; then
|
||||
echo "Plugin did not read image directory"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] Fixed command permissions' server.log; then
|
||||
if ! grep -Fq '[YamipaPlugin] [ImageCommandBridge] Fixed command permissions' server.log; then
|
||||
echo "Plugin did not fixed command permissions"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] Created FakeImage' server.log; then
|
||||
if ! grep -Fq '[YamipaPlugin] [FakeImage] Created FakeImage' server.log; then
|
||||
echo "Plugin did not place the fake image"
|
||||
exit 1
|
||||
fi
|
||||
if ! grep -Fq '[YamipaPlugin] Invalidated FakeImage' server.log; then
|
||||
if ! grep -Fq '[YamipaPlugin] [FakeImage] Invalidated FakeImage' server.log; then
|
||||
echo "Plugin did not remove the fake image"
|
||||
exit 1
|
||||
fi
|
||||
if grep -iFq 'exception' server.log; then
|
||||
if [ "$FLAVOR" != "bukkit" ] && grep -iFq 'exception' server.log; then
|
||||
echo "Server threw an exception"
|
||||
exit 1
|
||||
fi
|
||||
@@ -5,7 +5,7 @@
|
||||
[](https://bstats.org/plugin/bukkit/Yamipa/10243)
|
||||
[](LICENSE)
|
||||
|
||||
Yamipa is an Spigot plugin that allows players to place images (even **animated**!) on any surface in your Minecraft server
|
||||
Yamipa is a Spigot plugin that allows players to place images (even **animated**!) on any surface in your Minecraft server
|
||||
without having to install any local client mod.
|
||||
|
||||
It is designed with performance and compatibility in mind, so even the most low-specs servers should be able to run it.
|
||||
@@ -24,8 +24,8 @@ Download the JAR file for the [latest release](https://github.com/josemmo/yamipa
|
||||
### Requirements
|
||||
Before installing Yamipa make sure you meet the following requirements:
|
||||
|
||||
- CraftBukkit, Spigot or PaperMC v1.16 or higher
|
||||
- [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/) v4.6.1 or higher
|
||||
- CraftBukkit, Spigot or PaperMC 1.16 or higher
|
||||
- [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/) v5.0.0 or higher
|
||||
|
||||
Here are the Minecraft distributions where Yamipa should be able to run:
|
||||
| Minecraft version | CraftBukkit | Spigot | PaperMC |
|
||||
@@ -34,6 +34,7 @@ Here are the Minecraft distributions where Yamipa should be able to run:
|
||||
| 1.17 & 1.17.1 | ✅ | ✅ | ✅ |
|
||||
| 1.18 & 1.18.x | ✅ | ✅ | ✅ |
|
||||
| 1.19 & 1.19.x | ✅ | ✅ | ✅ |
|
||||
| 1.20 & 1.20.x | ✅ | ✅ | ✅ |
|
||||
|
||||
## Configuration
|
||||
Yamipa is ready-to-go right out of the box. By default, it creates the following files and directories under the
|
||||
@@ -44,15 +45,50 @@ Yamipa is ready-to-go right out of the box. By default, it creates the following
|
||||
- `images.dat`: A file holding the list and properties (e.g. coordinates) of all placed images in your server. You
|
||||
shouldn't modify its contents.
|
||||
|
||||
You can change the default path of these files by creating a `config.yml` file in the plugin configuration directory:
|
||||
You can change the path of these files by creating a `config.yml` file in the plugin configuration directory.
|
||||
Here are the default configuration values if you don't specify them:
|
||||
```yaml
|
||||
verbose: false # Set to "true" to enable more verbose logging
|
||||
animate-images: true # Set to "false" to disable GIF support
|
||||
images-path: images # Path to images directory
|
||||
cache-path: cache # Path to cache directory
|
||||
data-path: images.dat # Path to placed images database file
|
||||
verbose: false # Set to "true" to enable more verbose logging
|
||||
animate-images: true # Set to "false" to disable GIF support
|
||||
images-path: images # Path to images directory
|
||||
cache-path: cache # Path to cache directory
|
||||
data-path: images.dat # Path to placed images database file
|
||||
allowed-paths: null # Set to a RegExp to limit accessible images to players
|
||||
max-image-dimension: 30 # Maximum width or height in blocks allowed in images
|
||||
```
|
||||
|
||||
For more information on how to set a different `allowed-paths` or `max-image-dimension` value per player, see the
|
||||
[Player variables](#player-variables) section.
|
||||
|
||||
### Allowed paths
|
||||
The variable `allowed-paths` is a regular expression that determines whether a player is allowed to see or download
|
||||
an image file. If the desired path relative to the images directory matches this expression, then the player is allowed
|
||||
to continue.
|
||||
|
||||
If `allowed-paths` is an empty string ("") or null, then the player can read any image file or download to any path
|
||||
inside the images directory.
|
||||
|
||||
This regular expression must follow
|
||||
[the syntax used by Java](https://docs.oracle.com/javase/8/docs/api/java/util/regex/Pattern.html). You can test your
|
||||
expression beforehand using an online tool like [regex101](https://regex101.com/).
|
||||
In addition, you can make use of the following special tokens:
|
||||
|
||||
- `#player#`: Player name
|
||||
- `#uuid#`: Player UUID (with hyphens)
|
||||
|
||||
For example, if you want every player in your server to have their own subdirectory for storing files that only they
|
||||
can access, plus a shared public directory, you can use the following `allowed-paths` value:
|
||||
```regexp
|
||||
^(private/#player#|public)/
|
||||
```
|
||||
|
||||
That way, the player "john" can see the image file at "private/john/something.jpg", but "jane" cannot.
|
||||
|
||||
> **IMPORTANT!**\
|
||||
> Note that these restrictions **also apply to other entities** like NPCs, command blocks or the server console.
|
||||
> However, special tokens will always match in non-player contexts (e.g., "#player#" will be interpreted as ".+").
|
||||
|
||||
### bStats
|
||||
This library uses bStats to anonymously report the number of installs. If you don't like this, feel free to
|
||||
disable it at any time by adding `enabled: false` to the
|
||||
[bStats configuration file](https://bstats.org/getting-started#:~:text=Disabling%20bStats) (it's ok, no hard feelings).
|
||||
@@ -74,17 +110,17 @@ This plugin adds the following commands:
|
||||
- Show help\
|
||||
`/image`
|
||||
- Download an image from a URL and save it with another name\
|
||||
`/image download "https://www.example.com/a/b/c/1234.jpg" imagename.jpg`
|
||||
`/image download "https://www.example.com/a/b/c/1234.jpg" "imagename.jpg"`
|
||||
- Give 10 image items to "TestPlayer" for the "test.jpg" image (3x5 blocks)\
|
||||
`/image give TestPlayer test.jpg 10 3 5`
|
||||
`/image give TestPlayer "test.jpg" 10 3 5`
|
||||
- Give 10 image items to "TestPlayer" that will not drop an image item when removed\
|
||||
`/image give TestPlayer test.jpg 10 3 5 -DROP`
|
||||
`/image give TestPlayer "test.jpg" 10 3 5 -DROP`
|
||||
- Start the dialog to place an image with a width of 3 blocks and auto height\
|
||||
`/image place imagename.jpg 3`
|
||||
`/image place "imagename.jpg" 3`
|
||||
- Start the dialog to place a 3-blocks wide and 2-blocks high image\
|
||||
`/image place imagename.jpg 3 2`
|
||||
`/image place "imagename.jpg" 3 2`
|
||||
- Start the dialog to place an image that glows in the dark\
|
||||
`/image place imagename.jpg 3 2 +GLOW`
|
||||
`/image place "imagename.jpg" 3 2 +GLOW`
|
||||
- Start the dialog to remove a placed image while keeping the original file\
|
||||
`/image remove`
|
||||
- Remove all placed images in a radius of 5 blocks around the spawn\
|
||||
@@ -95,16 +131,16 @@ This plugin adds the following commands:
|
||||
`/image clear 0 0 world 9999999 00000000-0000-0000-0000-000000000000`
|
||||
|
||||
## Permissions
|
||||
If you want more granular control over the players who can use a particular set of commands, permissions are the way to go!
|
||||
If you want more granular control over the features exposed to players, permissions are the way to go!
|
||||
|
||||
Yamipa defines the following permissions:
|
||||
|
||||
| Command | Default | Description |
|
||||
| Permission | Default | Description |
|
||||
|:----------------------------|:-----------:|:----------------------------------------------------------------|
|
||||
| `yamipa.command.clear` | OPs | To use the `/image clear` command |
|
||||
| `yamipa.command.describe` | OPs | To use the `/image describe` command |
|
||||
| `yamipa.command.download` | OPs | To use the `/image download` command |
|
||||
| `yamipa.command.give` | OPs | To use the `/image give` download |
|
||||
| `yamipa.command.give` | OPs | To use the `/image give` command |
|
||||
| `yamipa.command.list` | OPs | To use the `/image list` command |
|
||||
| `yamipa.command.place` | OPs | To use the `/image place` command |
|
||||
| `yamipa.command.remove` | OPs | To use the `/image remove` command |
|
||||
@@ -122,6 +158,28 @@ You can change which roles or players are granted these commands by using a perm
|
||||
such as [LuckPerms](https://luckperms.net/) or [GroupManager](https://elgarl.github.io/GroupManager/).
|
||||
Both these plugins have been tested to work with Yamipa, although any similar one should work just fine.
|
||||
|
||||
## Player variables
|
||||
Some permission plugins like LuckPerms allow server operators to assign
|
||||
[key-value pairs](https://luckperms.net/wiki/Meta-Commands) to entities as if they were permissions.
|
||||
This is useful for granting different capabilities to different players or groups.
|
||||
|
||||
Yamipa looks for the following variables which, if found, override the default configuration value that applies to all
|
||||
players:
|
||||
|
||||
| Variable (key) | Overrides | Description |
|
||||
|:-----------------------------|:----------------------|:----------------------------------------------------------------------------------|
|
||||
| `yamipa-allowed-paths` | `allowed-paths` | Regular expression that limits which paths in the images directory are accessible |
|
||||
| `yamipa-max-image-dimension` | `max-image-dimension` | Maximum width or height of images and image items issued by this player or group |
|
||||
|
||||
For example, if you want to limit the image size to 5x5 blocks just for the "test" player, you can run this command:
|
||||
```sh
|
||||
# Using LuckPerms
|
||||
/lp user test meta set yamipa-max-image-dimension 5
|
||||
|
||||
# Using GroupManager
|
||||
/manuaddv test yamipa-max-image-dimension 5
|
||||
```
|
||||
|
||||
## Protecting areas
|
||||
In large servers, letting your players place and remove images wherever they want might not be the most sensible idea.
|
||||
For those cases, Yamipa is compatible with other Bukkit plugins that allow creating and managing world areas.
|
||||
@@ -132,6 +190,8 @@ The supported plugins are:
|
||||
|
||||
- [WorldGuard](https://enginehub.org/worldguard/)
|
||||
- [GriefPrevention](https://www.spigotmc.org/resources/griefprevention.1884/)
|
||||
- [Towny Advanced](https://townyadvanced.github.io/)
|
||||
- [Lands](https://www.spigotmc.org/resources/53313/)
|
||||
|
||||
## Flags
|
||||
Images from this plugin have a set of boolean attributes called "flags" that modify its behavior. Possible values are:
|
||||
|
||||
Generated
+327
-249
File diff suppressed because it is too large.
Load diff
@@ -6,7 +6,7 @@
|
||||
"start": "node index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"mineflayer": "^4.5.1",
|
||||
"rcon-client": "^4.2.3"
|
||||
"mineflayer": "^4.20.1",
|
||||
"rcon-client": "^4.2.4"
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>io.josemmo.bukkit.plugin</groupId>
|
||||
<artifactId>YamipaPlugin</artifactId>
|
||||
<version>1.2.7</version>
|
||||
<version>1.3.2-SNAPSHOT-b2</version>
|
||||
|
||||
<properties>
|
||||
<maven.compiler.source>8</maven.compiler.source>
|
||||
@@ -38,45 +38,90 @@
|
||||
</repositories>
|
||||
|
||||
<dependencies>
|
||||
<!-- https://hub.spigotmc.org/nexus/content/groups/public/org/spigotmc/spigot-api/ -->
|
||||
<dependency>
|
||||
<groupId>org.spigotmc</groupId>
|
||||
<artifactId>spigot-api</artifactId>
|
||||
<version>1.19.3-R0.1-SNAPSHOT</version>
|
||||
<version>1.20.6-R0.1-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://repo.dmulloy2.net/service/rest/repository/browse/public/com/comphenix/protocol/ProtocolLib/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.dmulloy2</groupId>
|
||||
<groupId>com.comphenix.protocol</groupId>
|
||||
<artifactId>ProtocolLib</artifactId>
|
||||
<version>eebb99fa37</version>
|
||||
<version>5.2.0-SNAPSHOT</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://github.com/Mojang/brigadier/releases -->
|
||||
<dependency>
|
||||
<groupId>com.mojang</groupId>
|
||||
<artifactId>brigadier</artifactId>
|
||||
<version>1.0.18</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://central.sonatype.com/artifact/org.bstats/bstats-bukkit -->
|
||||
<dependency>
|
||||
<groupId>org.bstats</groupId>
|
||||
<artifactId>bstats-bukkit</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<version>3.0.2</version>
|
||||
</dependency>
|
||||
|
||||
<!-- https://central.sonatype.com/artifact/net.luckperms/api -->
|
||||
<dependency>
|
||||
<groupId>net.luckperms</groupId>
|
||||
<artifactId>api</artifactId>
|
||||
<version>5.4</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/ElgarL/groupmanager/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.ElgarL</groupId>
|
||||
<artifactId>groupmanager</artifactId>
|
||||
<version>3.2</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://maven.enginehub.org/artifactory/repo/com/sk89q/worldguard/worldguard-bukkit/ -->
|
||||
<dependency>
|
||||
<groupId>com.sk89q.worldguard</groupId>
|
||||
<artifactId>worldguard-bukkit</artifactId>
|
||||
<version>7.0.7</version>
|
||||
<version>7.0.9</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/TechFortress/GriefPrevention/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.TechFortress</groupId>
|
||||
<artifactId>GriefPrevention</artifactId>
|
||||
<version>16.18</version>
|
||||
<version>16.18.3</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/TownyAdvanced/towny/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.TownyAdvanced</groupId>
|
||||
<artifactId>towny</artifactId>
|
||||
<version>0.100.2.11</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://jitpack.io/com/github/Angeschossen/LandsAPI/ -->
|
||||
<dependency>
|
||||
<groupId>com.github.angeschossen</groupId>
|
||||
<artifactId>LandsAPI</artifactId>
|
||||
<version>7.1.12</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
|
||||
<!-- https://central.sonatype.com/artifact/org.jetbrains/annotations -->
|
||||
<dependency>
|
||||
<groupId>org.jetbrains</groupId>
|
||||
<artifactId>annotations</artifactId>
|
||||
<version>23.0.0</version>
|
||||
<version>24.1.0</version>
|
||||
<scope>provided</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
@@ -93,7 +138,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-shade-plugin</artifactId>
|
||||
<version>3.2.4</version>
|
||||
<version>3.5.1</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<id>shade</id>
|
||||
@@ -125,7 +170,7 @@
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-antrun-plugin</artifactId>
|
||||
<version>3.0.0</version>
|
||||
<version>3.1.0</version>
|
||||
<executions>
|
||||
<execution>
|
||||
<phase>package</phase>
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package io.josemmo.bukkit.plugin;
|
||||
|
||||
import io.josemmo.bukkit.plugin.commands.ImageCommandBridge;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeEntity;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.renderer.ImageRenderer;
|
||||
import io.josemmo.bukkit.plugin.renderer.ItemService;
|
||||
import io.josemmo.bukkit.plugin.renderer.*;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageStorage;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bstats.bukkit.Metrics;
|
||||
import org.bstats.charts.SimplePie;
|
||||
import org.bukkit.Bukkit;
|
||||
@@ -13,27 +11,31 @@ import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.plugin.java.JavaPlugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.awt.Color;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.function.Function;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public class YamipaPlugin extends JavaPlugin {
|
||||
public static final int BSTATS_PLUGIN_ID = 10243;
|
||||
private static YamipaPlugin instance;
|
||||
private static final Logger LOGGER = Logger.getLogger();
|
||||
private static @Nullable YamipaPlugin INSTANCE;
|
||||
private boolean verbose;
|
||||
private ImageStorage storage;
|
||||
private ImageRenderer renderer;
|
||||
private ItemService itemService;
|
||||
private ScheduledExecutorService scheduler;
|
||||
private @Nullable ImageStorage storage;
|
||||
private @Nullable ImageRenderer renderer;
|
||||
private @Nullable ItemService itemService;
|
||||
private @Nullable ScheduledExecutorService scheduler;
|
||||
private @Nullable Metrics metrics;
|
||||
|
||||
/**
|
||||
* Get plugin instance
|
||||
* @return Plugin instance
|
||||
*/
|
||||
public static @NotNull YamipaPlugin getInstance() {
|
||||
return instance;
|
||||
Objects.requireNonNull(INSTANCE, "Cannot get plugin instance if plugin is not running");
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,6 +43,7 @@ public class YamipaPlugin extends JavaPlugin {
|
||||
* @return Image storage instance
|
||||
*/
|
||||
public @NotNull ImageStorage getStorage() {
|
||||
Objects.requireNonNull(storage, "Cannot get storage instance if plugin is not running");
|
||||
return storage;
|
||||
}
|
||||
|
||||
@@ -49,6 +52,7 @@ public class YamipaPlugin extends JavaPlugin {
|
||||
* @return Image renderer instance
|
||||
*/
|
||||
public @NotNull ImageRenderer getRenderer() {
|
||||
Objects.requireNonNull(renderer, "Cannot get renderer instance if plugin is not running");
|
||||
return renderer;
|
||||
}
|
||||
|
||||
@@ -57,31 +61,29 @@ public class YamipaPlugin extends JavaPlugin {
|
||||
* @return Tasks scheduler
|
||||
*/
|
||||
public @NotNull ScheduledExecutorService getScheduler() {
|
||||
Objects.requireNonNull(scheduler, "Cannot get scheduler instance if plugin is not running");
|
||||
return scheduler;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get configuration value
|
||||
* @param path Configuration key path
|
||||
* @param defaultValue Default value
|
||||
* @return Configuration value
|
||||
* Is verbose
|
||||
* @return Whether plugin is running in verbose mode
|
||||
*/
|
||||
private @NotNull String getConfigValue(@NotNull String path, @NotNull String defaultValue) {
|
||||
String value = getConfig().getString(path);
|
||||
return (value == null) ? defaultValue : value;
|
||||
public boolean isVerbose() {
|
||||
return verbose;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onLoad() {
|
||||
instance = this;
|
||||
INSTANCE = this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onEnable() {
|
||||
// Initialize logger
|
||||
verbose = getConfig().getBoolean("verbose");
|
||||
verbose = getConfig().getBoolean("verbose", false);
|
||||
if (verbose) {
|
||||
info("Running on VERBOSE mode");
|
||||
LOGGER.info("Running on VERBOSE mode");
|
||||
}
|
||||
|
||||
// Register plugin commands
|
||||
@@ -89,28 +91,28 @@ public class YamipaPlugin extends JavaPlugin {
|
||||
|
||||
// Read plugin configuration paths
|
||||
Path basePath = getDataFolder().toPath();
|
||||
String imagesPath = getConfigValue("images-path", "images");
|
||||
String cachePath = getConfigValue("cache-path", "cache");
|
||||
String dataPath = getConfigValue("data-path", "images.dat");
|
||||
String imagesPath = getConfig().getString("images-path", "images");
|
||||
String cachePath = getConfig().getString("cache-path", "cache");
|
||||
String dataPath = getConfig().getString("data-path", "images.dat");
|
||||
|
||||
// Create image storage
|
||||
String allowedPaths = getConfig().getString("allowed-paths", "");
|
||||
storage = new ImageStorage(
|
||||
basePath.resolve(imagesPath).toString(),
|
||||
basePath.resolve(cachePath).toString()
|
||||
basePath.resolve(imagesPath).toAbsolutePath().normalize(),
|
||||
basePath.resolve(cachePath).toAbsolutePath().normalize(),
|
||||
allowedPaths
|
||||
);
|
||||
try {
|
||||
storage.start();
|
||||
} catch (Exception e) {
|
||||
log(Level.SEVERE, "Failed to initialize image storage", e);
|
||||
LOGGER.severe("Failed to initialize image storage", e);
|
||||
}
|
||||
|
||||
// Create image renderer
|
||||
boolean animateImages = getConfig().getBoolean("animate-images", true);
|
||||
if (animateImages) {
|
||||
FakeImage.enableAnimation();
|
||||
info("Enabled image animation support");
|
||||
}
|
||||
renderer = new ImageRenderer(basePath.resolve(dataPath).toString());
|
||||
LOGGER.info(animateImages ? "Enabled image animation support" : "Image animation support is disabled");
|
||||
int maxImageDimension = getConfig().getInt("max-image-dimension", 30);
|
||||
renderer = new ImageRenderer(basePath.resolve(dataPath), animateImages, maxImageDimension);
|
||||
renderer.start();
|
||||
|
||||
// Create image item service
|
||||
@@ -120,12 +122,14 @@ public class YamipaPlugin extends JavaPlugin {
|
||||
// Create thread pool
|
||||
scheduler = Executors.newScheduledThreadPool(6);
|
||||
|
||||
// Warm-up ProtocolLib
|
||||
fine("Waiting for ProtocolLib to be ready...");
|
||||
// Warm-up plugin dependencies
|
||||
LOGGER.fine("Waiting for ProtocolLib to be ready...");
|
||||
scheduler.execute(() -> {
|
||||
FakeEntity.waitForProtocolLib();
|
||||
fine("ProtocolLib is now ready");
|
||||
LOGGER.fine("ProtocolLib is now ready");
|
||||
});
|
||||
LOGGER.fine("Triggered map color cache warm-up");
|
||||
FakeMap.pixelToIndex(Color.RED.getRGB()); // Ask for a color index to force cache generation
|
||||
|
||||
// Initialize bStats
|
||||
Function<Integer, String> toStats = number -> {
|
||||
@@ -136,82 +140,49 @@ public class YamipaPlugin extends JavaPlugin {
|
||||
if (number >= 10) return "10-49";
|
||||
return "0-9";
|
||||
};
|
||||
Metrics metrics = new Metrics(this, BSTATS_PLUGIN_ID);
|
||||
metrics.addCustomChart(new SimplePie("animate_images", () -> FakeImage.isAnimationEnabled() ? "true" : "false"));
|
||||
metrics = new Metrics(this, BSTATS_PLUGIN_ID);
|
||||
metrics.addCustomChart(new SimplePie("animate_images", () -> animateImages ? "true" : "false"));
|
||||
metrics.addCustomChart(new SimplePie("number_of_image_files", () -> toStats.apply(storage.size())));
|
||||
metrics.addCustomChart(new SimplePie("number_of_placed_images", () -> toStats.apply(renderer.size())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onDisable() {
|
||||
// Stop plugin components
|
||||
storage.stop();
|
||||
renderer.stop();
|
||||
itemService.stop();
|
||||
storage = null;
|
||||
renderer = null;
|
||||
itemService = null;
|
||||
// Stop metrics
|
||||
if (metrics != null) {
|
||||
metrics.shutdown();
|
||||
metrics = null;
|
||||
}
|
||||
|
||||
// Stop item service
|
||||
if (itemService != null) {
|
||||
itemService.stop();
|
||||
itemService = null;
|
||||
}
|
||||
|
||||
// Stop image renderer
|
||||
if (renderer != null) {
|
||||
renderer.stop();
|
||||
renderer = null;
|
||||
}
|
||||
|
||||
// Stop image storage
|
||||
if (storage != null) {
|
||||
storage.stop();
|
||||
storage = null;
|
||||
}
|
||||
|
||||
// Stop internal scheduler
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
if (scheduler != null) {
|
||||
scheduler.shutdownNow();
|
||||
scheduler = null;
|
||||
}
|
||||
|
||||
// Remove Bukkit listeners and tasks
|
||||
HandlerList.unregisterAll(this);
|
||||
Bukkit.getScheduler().cancelTasks(this);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message
|
||||
* @param level Record level
|
||||
* @param message Message
|
||||
* @param e Throwable instance, NULL to ignore
|
||||
*/
|
||||
public void log(@NotNull Level level, @NotNull String message, @Nullable Throwable e) {
|
||||
// Fix log level
|
||||
if (level.intValue() < Level.INFO.intValue()) {
|
||||
if (!verbose) return;
|
||||
level = Level.INFO;
|
||||
}
|
||||
|
||||
// Proxy record to real logger
|
||||
if (e == null) {
|
||||
getLogger().log(level, message);
|
||||
} else {
|
||||
getLogger().log(level, message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message
|
||||
* @param level Record level
|
||||
* @param message Message
|
||||
*/
|
||||
public void log(@NotNull Level level, @NotNull String message) {
|
||||
log(level, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @param message Message
|
||||
*/
|
||||
public void warning(@NotNull String message) {
|
||||
log(Level.WARNING, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
* @param message Message
|
||||
*/
|
||||
public void info(@NotNull String message) {
|
||||
log(Level.INFO, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log fine message
|
||||
* @param message Message
|
||||
*/
|
||||
public void fine(@NotNull String message) {
|
||||
log(Level.FINE, message);
|
||||
// Unlink reference to instance
|
||||
INSTANCE = null;
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import org.bukkit.ChatColor;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.function.BiConsumer;
|
||||
@@ -19,8 +20,8 @@ public class Command {
|
||||
private final String name;
|
||||
private final List<Argument> arguments = new ArrayList<>();
|
||||
private Predicate<CommandSender> requirementHandler = __ -> true;
|
||||
private BiConsumer<CommandSender, Object[]> executesHandler = null;
|
||||
private BiConsumer<Player, Object[]> executesPlayerHandler = null;
|
||||
private @Nullable BiConsumer<CommandSender, Object[]> executesHandler;
|
||||
private @Nullable BiConsumer<Player, Object[]> executesPlayerHandler;
|
||||
private final List<Command> subcommands = new ArrayList<>();
|
||||
|
||||
/**
|
||||
@@ -82,6 +83,7 @@ public class Command {
|
||||
* @param handler Command handler
|
||||
* @return This instance
|
||||
*/
|
||||
@SuppressWarnings("UnusedReturnValue")
|
||||
public @NotNull Command executesPlayer(@NotNull BiConsumer<Player, Object[]> handler) {
|
||||
executesPlayerHandler = handler;
|
||||
return this;
|
||||
@@ -127,7 +129,12 @@ public class Command {
|
||||
|
||||
// Chain command elements from the bottom-up
|
||||
if (argIndex < arguments.size()) {
|
||||
parent.then(buildElement(arguments.get(argIndex).build(), argIndex+1)).executes(ctx -> {
|
||||
Argument argument = arguments.get(argIndex);
|
||||
ArgumentBuilder argumentBuilder = argument.build().suggests((ctx, builder) -> {
|
||||
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
|
||||
return argument.suggest(sender, builder);
|
||||
});
|
||||
parent.then(buildElement(argumentBuilder, argIndex+1)).executes(ctx -> {
|
||||
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
|
||||
sender.sendMessage(ChatColor.RED + "Missing required arguments");
|
||||
return 1;
|
||||
|
||||
@@ -5,6 +5,8 @@ import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.renderer.ImageRenderer;
|
||||
import io.josemmo.bukkit.plugin.renderer.ItemService;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageStorage;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import io.josemmo.bukkit.plugin.utils.Permissions;
|
||||
import io.josemmo.bukkit.plugin.utils.SelectBlockTask;
|
||||
import io.josemmo.bukkit.plugin.utils.ActionBar;
|
||||
@@ -23,13 +25,15 @@ import java.net.MalformedURLException;
|
||||
import java.net.URL;
|
||||
import java.net.URLConnection;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.text.SimpleDateFormat;
|
||||
import java.util.*;
|
||||
|
||||
public class ImageCommand {
|
||||
public static final int ITEMS_PER_PAGE = 9;
|
||||
private static final int ITEMS_PER_PAGE = 9;
|
||||
private static final int MAX_PATH_DEPTH = 10;
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageCommand");
|
||||
|
||||
public static void showHelp(@NotNull CommandSender s, @NotNull String commandName) {
|
||||
String cmd = "/" + commandName;
|
||||
@@ -62,8 +66,9 @@ public class ImageCommand {
|
||||
}
|
||||
|
||||
public static void listImages(@NotNull CommandSender sender, int page) {
|
||||
String[] filenames = YamipaPlugin.getInstance().getStorage().getAllFilenames();
|
||||
int numOfImages = filenames.length;
|
||||
ImageStorage storage = YamipaPlugin.getInstance().getStorage();
|
||||
List<String> filenames = storage.getFilenames(sender);
|
||||
int numOfImages = filenames.size();
|
||||
|
||||
// Are there any images available?
|
||||
if (numOfImages == 0) {
|
||||
@@ -85,24 +90,42 @@ public class ImageCommand {
|
||||
sender.sendMessage("=== Page " + page + " out of " + maxPage + " ===");
|
||||
}
|
||||
for (int i=firstImageIndex; i<stopImageIndex; ++i) {
|
||||
sender.sendMessage("" + ChatColor.GOLD + filenames[i]);
|
||||
sender.sendMessage(ChatColor.GOLD + filenames.get(i));
|
||||
}
|
||||
}
|
||||
|
||||
public static void downloadImage(@NotNull CommandSender sender, @NotNull String rawUrl, @NotNull String filename) {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
ImageStorage storage = plugin.getStorage();
|
||||
Path basePath = storage.getBasePath();
|
||||
|
||||
// Resolve destination path
|
||||
Path destPath;
|
||||
try {
|
||||
destPath = basePath.resolve(filename).normalize();
|
||||
} catch (InvalidPathException e) {
|
||||
sender.sendMessage(ChatColor.RED + "Malformed destination path");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate destination file
|
||||
Path basePath = Paths.get(plugin.getStorage().getBasePath());
|
||||
Path destPath = basePath.resolve(filename);
|
||||
if (!destPath.getParent().equals(basePath)) {
|
||||
if (!destPath.startsWith(basePath)) {
|
||||
sender.sendMessage(ChatColor.RED + "Not a valid destination filename");
|
||||
return;
|
||||
}
|
||||
if (!storage.isPathAllowed(destPath, sender)) {
|
||||
sender.sendMessage(ChatColor.RED + "Not allowed to download a file here");
|
||||
return;
|
||||
}
|
||||
if (destPath.toFile().exists()) {
|
||||
sender.sendMessage(ChatColor.RED + "There's already a file with that name");
|
||||
return;
|
||||
}
|
||||
int depth = destPath.getNameCount() - basePath.getNameCount();
|
||||
if (depth > MAX_PATH_DEPTH) {
|
||||
sender.sendMessage(ChatColor.RED + "Destination path has too many directories");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate and fix remote URL
|
||||
URL url;
|
||||
@@ -149,6 +172,7 @@ public class ImageCommand {
|
||||
|
||||
// Download file
|
||||
sender.sendMessage("Downloading file...");
|
||||
Files.createDirectories(destPath.getParent());
|
||||
Files.copy(conn.getInputStream(), destPath);
|
||||
|
||||
// Validate downloaded file
|
||||
@@ -160,10 +184,10 @@ public class ImageCommand {
|
||||
sender.sendMessage(ChatColor.GREEN + "Done!");
|
||||
} catch (IOException e) {
|
||||
sender.sendMessage(ChatColor.RED + "An error occurred trying to download the remote file");
|
||||
plugin.warning("Failed to download file from \"" + finalUrl + "\": " + e.getClass().getName());
|
||||
LOGGER.warning("Failed to download file from \"" + finalUrl + "\": " + e.getClass().getName());
|
||||
} catch (IllegalArgumentException e) {
|
||||
if (Files.exists(destPath) && !destPath.toFile().delete()) {
|
||||
plugin.warning("Failed to delete corrupted file \"" + destPath + "\"");
|
||||
LOGGER.warning("Failed to delete corrupted file \"" + destPath + "\"");
|
||||
}
|
||||
sender.sendMessage(ChatColor.RED + e.getMessage());
|
||||
}
|
||||
@@ -183,13 +207,11 @@ public class ImageCommand {
|
||||
player.sendMessage(ChatColor.RED + "The requested file is not a valid image");
|
||||
return;
|
||||
}
|
||||
final int finalHeight = (height == 0) ? FakeImage.getProportionalHeight(sizeInPixels, width) : height;
|
||||
final int finalHeight = (height == 0) ? FakeImage.getProportionalHeight(sizeInPixels, player, width) : height;
|
||||
|
||||
// Ask player where to place image
|
||||
SelectBlockTask task = new SelectBlockTask(player);
|
||||
task.onSuccess((location, face) -> {
|
||||
placeImage(player, image, width, finalHeight, flags, location, face);
|
||||
});
|
||||
task.onSuccess((location, face) -> placeImage(player, image, width, finalHeight, flags, location, face));
|
||||
task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image placing canceled"));
|
||||
task.run("Right click a block to continue");
|
||||
}
|
||||
@@ -207,12 +229,12 @@ public class ImageCommand {
|
||||
|
||||
// Create new fake image instance
|
||||
Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation());
|
||||
FakeImage fakeImage = new FakeImage(image.getName(), location, face, rotation,
|
||||
FakeImage fakeImage = new FakeImage(image.getFilename(), location, face, rotation,
|
||||
width, height, new Date(), player, flags);
|
||||
|
||||
// Make sure image can be placed
|
||||
for (Location loc : fakeImage.getAllLocations()) {
|
||||
if (!Permissions.canEditBlock(player, loc)) {
|
||||
if (!Permissions.canBuild(player, loc)) {
|
||||
ActionBar.send(player, ChatColor.RED + "You're not allowed to place an image here!");
|
||||
return false;
|
||||
}
|
||||
@@ -260,7 +282,7 @@ public class ImageCommand {
|
||||
public static boolean removeImage(@NotNull Player player, @NotNull FakeImage image) {
|
||||
// Check block permissions
|
||||
for (Location loc : image.getAllLocations()) {
|
||||
if (!Permissions.canEditBlock(player, loc)) {
|
||||
if (!Permissions.canDestroy(player, loc)) {
|
||||
ActionBar.send(player, ChatColor.RED + "You're not allowed to remove this image!");
|
||||
return false;
|
||||
}
|
||||
@@ -281,7 +303,7 @@ public class ImageCommand {
|
||||
|
||||
// Get images in area
|
||||
Set<FakeImage> images = renderer.getImages(
|
||||
origin.getWorld(),
|
||||
Objects.requireNonNull(origin.getWorld()),
|
||||
origin.getBlockX()-radius+1,
|
||||
origin.getBlockX()+radius-1,
|
||||
origin.getBlockZ()-radius+1,
|
||||
@@ -299,7 +321,7 @@ public class ImageCommand {
|
||||
Player senderAsPlayer = (Player) sender;
|
||||
images.removeIf(image -> {
|
||||
for (Location loc : image.getAllLocations()) {
|
||||
if (!Permissions.canEditBlock(senderAsPlayer, loc)) {
|
||||
if (!Permissions.canDestroy(senderAsPlayer, loc)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -446,7 +468,7 @@ public class ImageCommand {
|
||||
return;
|
||||
}
|
||||
if (height == 0) {
|
||||
height = FakeImage.getProportionalHeight(sizeInPixels, width);
|
||||
height = FakeImage.getProportionalHeight(sizeInPixels, sender, width);
|
||||
}
|
||||
|
||||
// Create item stack
|
||||
|
||||
@@ -7,6 +7,7 @@ import io.josemmo.bukkit.plugin.commands.arguments.*;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
@@ -15,8 +16,9 @@ import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class ImageCommandBridge {
|
||||
public static final String COMMAND_NAME = "yamipa";
|
||||
public static final String[] COMMAND_ALIASES = new String[] {"image", "images"};
|
||||
private static final String COMMAND_NAME = "yamipa";
|
||||
private static final String[] COMMAND_ALIASES = new String[] {"image", "images"};
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageCommandBridge");
|
||||
|
||||
/**
|
||||
* Register command
|
||||
@@ -42,7 +44,7 @@ public class ImageCommandBridge {
|
||||
);
|
||||
dispatcher.getRoot().addChild(aliasNode);
|
||||
}
|
||||
plugin.fine("Registered plugin command and aliases");
|
||||
LOGGER.fine("Registered plugin command and aliases");
|
||||
|
||||
// Fix "minecraft.command.*" permissions
|
||||
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> {
|
||||
@@ -50,10 +52,11 @@ public class ImageCommandBridge {
|
||||
for (String alias : COMMAND_ALIASES) {
|
||||
fixPermissions(alias);
|
||||
}
|
||||
plugin.fine("Fixed command permissions");
|
||||
LOGGER.fine("Fixed command permissions");
|
||||
});
|
||||
}
|
||||
|
||||
@SuppressWarnings("CodeBlock2Expr")
|
||||
private static @NotNull Command getRootCommand() {
|
||||
Command root = new Command(COMMAND_NAME);
|
||||
|
||||
@@ -117,8 +120,8 @@ public class ImageCommandBridge {
|
||||
.withArgument(new OnlinePlayerArgument("player"))
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("amount", 1, 64))
|
||||
.withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_GIVE_FLAGS))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3],
|
||||
@@ -129,8 +132,8 @@ public class ImageCommandBridge {
|
||||
.withArgument(new OnlinePlayerArgument("player"))
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("amount", 1, 64))
|
||||
.withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3],
|
||||
(int) args[4], (int) args[5], FakeImage.DEFAULT_GIVE_FLAGS);
|
||||
@@ -140,7 +143,7 @@ public class ImageCommandBridge {
|
||||
.withArgument(new OnlinePlayerArgument("player"))
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("amount", 1, 64))
|
||||
.withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.executes((sender, args) -> {
|
||||
ImageCommand.giveImageItems(sender, (Player) args[1], (ImageFile) args[2], (int) args[3],
|
||||
(int) args[4], 0, FakeImage.DEFAULT_GIVE_FLAGS);
|
||||
@@ -164,8 +167,8 @@ public class ImageCommandBridge {
|
||||
root.addSubcommand("place")
|
||||
.withPermission("yamipa.command.place", "yamipa.place")
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.withArgument(new ImageFlagsArgument("flags", FakeImage.DEFAULT_PLACE_FLAGS))
|
||||
.executesPlayer((player, args) -> {
|
||||
ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3], (int) args[4]);
|
||||
@@ -173,8 +176,8 @@ public class ImageCommandBridge {
|
||||
root.addSubcommand("place")
|
||||
.withPermission("yamipa.command.place", "yamipa.place")
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.withArgument(new ImageDimensionArgument("height"))
|
||||
.executesPlayer((player, args) -> {
|
||||
ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], (int) args[3],
|
||||
FakeImage.DEFAULT_PLACE_FLAGS);
|
||||
@@ -182,7 +185,7 @@ public class ImageCommandBridge {
|
||||
root.addSubcommand("place")
|
||||
.withPermission("yamipa.command.place", "yamipa.place")
|
||||
.withArgument(new ImageFileArgument("filename"))
|
||||
.withArgument(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
|
||||
.withArgument(new ImageDimensionArgument("width"))
|
||||
.executesPlayer((player, args) -> {
|
||||
ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], 0,
|
||||
FakeImage.DEFAULT_PLACE_FLAGS);
|
||||
|
||||
@@ -4,8 +4,11 @@ import com.mojang.brigadier.LiteralMessage;
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.exceptions.SimpleCommandExceptionType;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
|
||||
public abstract class Argument {
|
||||
protected final String name;
|
||||
@@ -28,10 +31,20 @@ public abstract class Argument {
|
||||
|
||||
/**
|
||||
* Build argument
|
||||
* @return Required Argument Builder instance
|
||||
* @return Argument builder instance
|
||||
*/
|
||||
public abstract @NotNull RequiredArgumentBuilder<?, ?> build();
|
||||
|
||||
/**
|
||||
* Suggest argument values
|
||||
* @param sender Command sender
|
||||
* @param builder Suggestions builder instance
|
||||
* @return Suggestions
|
||||
*/
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse argument value
|
||||
* @param sender Command sender
|
||||
@@ -42,6 +55,11 @@ public abstract class Argument {
|
||||
return rawValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new syntax exception
|
||||
* @param message Message to show
|
||||
* @return Syntax exception
|
||||
*/
|
||||
protected @NotNull CommandSyntaxException newException(@NotNull String message) {
|
||||
return new SimpleCommandExceptionType(new LiteralMessage(message)).create();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
|
||||
public class ImageDimensionArgument extends IntegerArgument {
|
||||
/**
|
||||
* Dimension Argument constructor
|
||||
* @param name Argument name
|
||||
*/
|
||||
public ImageDimensionArgument(@NotNull String name) {
|
||||
super(name, 1);
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
int maxDimension = FakeImage.getMaxImageDimension(sender);
|
||||
int value = (int) rawValue;
|
||||
if (value > maxDimension) {
|
||||
throw newException("Image cannot be larger than " + maxDimension + "x" + maxDimension);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageStorage;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
@@ -21,26 +21,21 @@ public class ImageFileArgument extends StringArgument {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return super.build().suggests(this::getSuggestions);
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
for (String filename : YamipaPlugin.getInstance().getStorage().getFilenames(sender)) {
|
||||
builder.suggest(StringArgumentType.escapeIfRequired(filename));
|
||||
}
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
|
||||
ImageFile imageFile = YamipaPlugin.getInstance().getStorage().get((String) rawValue);
|
||||
if (imageFile == null) {
|
||||
String filename = (String) rawValue;
|
||||
ImageStorage storage = YamipaPlugin.getInstance().getStorage();
|
||||
ImageFile imageFile = storage.get(filename);
|
||||
if (imageFile == null || !storage.isPathAllowed(filename, sender)) {
|
||||
throw newException("Image file does not exist");
|
||||
}
|
||||
return imageFile;
|
||||
}
|
||||
|
||||
private @NotNull CompletableFuture<Suggestions> getSuggestions(
|
||||
@NotNull CommandContext<?> ctx,
|
||||
@NotNull SuggestionsBuilder builder
|
||||
) {
|
||||
for (String filename : YamipaPlugin.getInstance().getStorage().getAllFilenames()) {
|
||||
builder.suggest(filename);
|
||||
}
|
||||
return builder.buildFuture();
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
@@ -28,13 +27,11 @@ public class ImageFlagsArgument extends Argument {
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return RequiredArgumentBuilder.argument(name, StringArgumentType.greedyString()).suggests(this::getSuggestions);
|
||||
return RequiredArgumentBuilder.argument(name, StringArgumentType.greedyString());
|
||||
}
|
||||
|
||||
private @NotNull CompletableFuture<Suggestions> getSuggestions(
|
||||
@NotNull CommandContext<?> ctx,
|
||||
@NotNull SuggestionsBuilder builder
|
||||
) {
|
||||
@Override
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
String input = builder.getRemaining().replaceAll("[^A-Z+\\-,]", "");
|
||||
int lastIndex = Collections.max(
|
||||
Arrays.asList(input.lastIndexOf(","), input.lastIndexOf("+"), input.lastIndexOf("-"))
|
||||
|
||||
+3
-12
@@ -1,7 +1,5 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
@@ -23,8 +21,9 @@ public class OnlinePlayerArgument extends StringArgument {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return super.build().suggests(this::getSuggestions);
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
getAllowedValues().keySet().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -36,14 +35,6 @@ public class OnlinePlayerArgument extends StringArgument {
|
||||
return player;
|
||||
}
|
||||
|
||||
private @NotNull CompletableFuture<Suggestions> getSuggestions(
|
||||
@NotNull CommandContext<?> ctx,
|
||||
@NotNull SuggestionsBuilder builder
|
||||
) {
|
||||
getAllowedValues().keySet().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
private @NotNull Map<String, Player> getAllowedValues() {
|
||||
Map<String, Player> values = new HashMap<>();
|
||||
for (Player player : Bukkit.getOnlinePlayers()) {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
@@ -24,8 +22,9 @@ public class PlacedByArgument extends StringArgument {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return super.build().suggests(this::getSuggestions);
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
getAllowedValues().keySet().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -37,14 +36,6 @@ public class PlacedByArgument extends StringArgument {
|
||||
return player;
|
||||
}
|
||||
|
||||
private @NotNull CompletableFuture<Suggestions> getSuggestions(
|
||||
@NotNull CommandContext<?> ctx,
|
||||
@NotNull SuggestionsBuilder builder
|
||||
) {
|
||||
getAllowedValues().keySet().forEach(builder::suggest);
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
private @NotNull Map<String, OfflinePlayer> getAllowedValues() {
|
||||
Map<String, OfflinePlayer> values = new HashMap<>();
|
||||
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
package io.josemmo.bukkit.plugin.commands.arguments;
|
||||
|
||||
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
|
||||
import com.mojang.brigadier.context.CommandContext;
|
||||
import com.mojang.brigadier.exceptions.CommandSyntaxException;
|
||||
import com.mojang.brigadier.suggestion.Suggestions;
|
||||
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
|
||||
@@ -21,8 +19,11 @@ public class WorldArgument extends StringArgument {
|
||||
}
|
||||
|
||||
@Override
|
||||
public @NotNull RequiredArgumentBuilder<?, ?> build() {
|
||||
return super.build().suggests(this::getSuggestions);
|
||||
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
builder.suggest(world.getName());
|
||||
}
|
||||
return builder.buildFuture();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -33,14 +34,4 @@ public class WorldArgument extends StringArgument {
|
||||
}
|
||||
return world;
|
||||
}
|
||||
|
||||
private @NotNull CompletableFuture<Suggestions> getSuggestions(
|
||||
@NotNull CommandContext<?> ctx,
|
||||
@NotNull SuggestionsBuilder builder
|
||||
) {
|
||||
for (World world : Bukkit.getWorlds()) {
|
||||
builder.suggest(world.getName());
|
||||
}
|
||||
return builder.buildFuture();
|
||||
}
|
||||
}
|
||||
@@ -1,36 +1,39 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import com.comphenix.protocol.PacketType;
|
||||
import com.comphenix.protocol.ProtocolLibrary;
|
||||
import com.comphenix.protocol.ProtocolManager;
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.injector.player.PlayerInjectionHandler;
|
||||
import com.comphenix.protocol.wrappers.WrappedDataWatcher;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public abstract class FakeEntity {
|
||||
protected static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private static final ProtocolManager connection = ProtocolLibrary.getProtocolManager();
|
||||
private static PlayerInjectionHandler playerInjectionHandler = null;
|
||||
private static boolean ready = false;
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeEntity");
|
||||
private static final ProtocolManager CONNECTION = ProtocolLibrary.getProtocolManager();
|
||||
private static @Nullable PlayerInjectionHandler PLAYER_INJECTION_HANDLER;
|
||||
private static boolean READY = false;
|
||||
|
||||
static {
|
||||
try {
|
||||
for (Field field : connection.getClass().getDeclaredFields()) {
|
||||
for (Field field : CONNECTION.getClass().getDeclaredFields()) {
|
||||
if (field.getType().equals(PlayerInjectionHandler.class)) {
|
||||
field.setAccessible(true);
|
||||
playerInjectionHandler = (PlayerInjectionHandler) field.get(connection);
|
||||
PLAYER_INJECTION_HANDLER = (PlayerInjectionHandler) field.get(CONNECTION);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (playerInjectionHandler == null) {
|
||||
if (PLAYER_INJECTION_HANDLER == null) {
|
||||
throw new RuntimeException("No valid candidate field found in ProtocolManager");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.SEVERE, "Failed to get PlayerInjectionHandler from ProtocolLib", e);
|
||||
LOGGER.severe("Failed to get PlayerInjectionHandler from ProtocolLib", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,7 +43,7 @@ public abstract class FakeEntity {
|
||||
* NOTE: Will wait synchronously, blocking the invoker thread
|
||||
*/
|
||||
public static synchronized void waitForProtocolLib() {
|
||||
if (ready) {
|
||||
if (READY) {
|
||||
// ProtocolLib is ready
|
||||
return;
|
||||
}
|
||||
@@ -49,7 +52,7 @@ public abstract class FakeEntity {
|
||||
while (true) {
|
||||
try {
|
||||
WrappedDataWatcher.Registry.get(Byte.class);
|
||||
ready = true;
|
||||
READY = true;
|
||||
break;
|
||||
} catch (Exception e) {
|
||||
if (++retry > 20) {
|
||||
@@ -82,15 +85,32 @@ public abstract class FakeEntity {
|
||||
*/
|
||||
protected static void tryToSendPacket(@NotNull Player player, @NotNull PacketContainer packet) {
|
||||
try {
|
||||
if (playerInjectionHandler == null) { // Use single-threaded packet sending if reflection failed
|
||||
connection.sendServerPacket(player, packet);
|
||||
if (PLAYER_INJECTION_HANDLER == null) { // Use single-threaded packet sending if reflection failed
|
||||
CONNECTION.sendServerPacket(player, packet);
|
||||
} else { // Use non-blocking packet sending if available (faster, the expected case)
|
||||
playerInjectionHandler.sendServerPacket(player, packet, null, false);
|
||||
PLAYER_INJECTION_HANDLER.sendServerPacket(player, packet, null, false);
|
||||
}
|
||||
} catch (IllegalStateException e) {
|
||||
// Server is shutting down and cannot send the packet, ignore
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.SEVERE, "Failed to send FakeEntity packet", e);
|
||||
LOGGER.severe("Failed to send FakeEntity packet", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to send several packets
|
||||
* @param player Player who will receive the packets
|
||||
* @param packets Packets to send
|
||||
*/
|
||||
protected static void tryToSendPackets(@NotNull Player player, @NotNull Iterable<PacketContainer> packets) {
|
||||
if (Internals.MINECRAFT_VERSION < 19.4f) {
|
||||
for (PacketContainer packet : packets) {
|
||||
tryToSendPacket(player, packet);
|
||||
}
|
||||
} else {
|
||||
PacketContainer container = new PacketContainer(PacketType.Play.Server.BUNDLE);
|
||||
container.getPacketBundles().write(0, packets);
|
||||
tryToSendPacket(player, container);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +119,6 @@ public abstract class FakeEntity {
|
||||
* @param callback Callback to execute
|
||||
*/
|
||||
protected static void tryToRunAsyncTask(@NotNull Runnable callback) {
|
||||
plugin.getScheduler().execute(callback);
|
||||
YamipaPlugin.getInstance().getScheduler().execute(callback);
|
||||
}
|
||||
}
|
||||
@@ -1,23 +1,32 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.storage.CachedMapsFile;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.utils.DirectionUtils;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import io.josemmo.bukkit.plugin.utils.Permissions;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.OfflinePlayer;
|
||||
import org.bukkit.Rotation;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.util.Vector;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.awt.*;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ScheduledFuture;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.BiFunction;
|
||||
|
||||
public class FakeImage extends FakeEntity {
|
||||
public static final int MAX_DIMENSION = 30; // In blocks
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeImage");
|
||||
|
||||
// Image constants
|
||||
public static final int MAX_STEPS = 500; // For animated images
|
||||
public static final int MIN_DELAY = 1; // Minimum step delay in 50ms intervals (50ms / 50ms)
|
||||
public static final int MAX_DELAY = 50; // Maximum step delay in 50ms intervals (5000ms / 50ms)
|
||||
@@ -43,7 +52,7 @@ public class FakeImage extends FakeEntity {
|
||||
private final int flags;
|
||||
private final BiFunction<Integer, Integer, Vector> getLocationVector;
|
||||
private final Set<Player> observingPlayers = new HashSet<>();
|
||||
private Runnable onLoadedListener = null;
|
||||
private @Nullable Runnable onLoadedListener = null;
|
||||
|
||||
// Generated values
|
||||
private boolean loading = false;
|
||||
@@ -52,25 +61,9 @@ public class FakeImage extends FakeEntity {
|
||||
private int numOfSteps = -1; // Total number of animation steps
|
||||
|
||||
// Animation task attributes
|
||||
private static boolean animateImages = false;
|
||||
private ScheduledFuture<?> task;
|
||||
private @Nullable ScheduledFuture<?> task;
|
||||
private int currentStep = -1; // Current animation step
|
||||
|
||||
/**
|
||||
* Enable plugin-wide image animation support
|
||||
*/
|
||||
public static void enableAnimation() {
|
||||
animateImages = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is animation enabled
|
||||
* @return Is animation enabled
|
||||
*/
|
||||
public static boolean isAnimationEnabled() {
|
||||
return animateImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image rotation from player eyesight
|
||||
* @param face Image block face
|
||||
@@ -97,16 +90,36 @@ public class FakeImage extends FakeEntity {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum image dimension
|
||||
* @param sender Sender instance
|
||||
* @return Maximum image dimension in blocks
|
||||
*/
|
||||
public static int getMaxImageDimension(@NotNull CommandSender sender) {
|
||||
if (sender instanceof Player) {
|
||||
String rawValue = Permissions.getVariable("yamipa-max-image-dimension", (Player) sender);
|
||||
if (rawValue != null) {
|
||||
try {
|
||||
return Integer.parseInt(rawValue);
|
||||
} catch (NumberFormatException __) {
|
||||
LOGGER.warning("Max. image dimension for " + sender + " is not a valid integer: \"" + rawValue + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
return YamipaPlugin.getInstance().getRenderer().getMaxImageDimension();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get proportional height
|
||||
* @param sizeInPixels Image file dimension in pixels
|
||||
* @param sender Sender instance
|
||||
* @param width Desired width in blocks
|
||||
* @return Height in blocks (capped at <code>FakeImage.MAX_DIMENSION</code>)
|
||||
* @return Height in blocks (capped at maximum image dimension for sender)
|
||||
*/
|
||||
public static int getProportionalHeight(@NotNull Dimension sizeInPixels, int width) {
|
||||
public static int getProportionalHeight(@NotNull Dimension sizeInPixels, @NotNull CommandSender sender, int width) {
|
||||
float imageRatio = (float) sizeInPixels.height / sizeInPixels.width;
|
||||
int height = Math.round(width * imageRatio);
|
||||
height = Math.min(height, MAX_DIMENSION);
|
||||
height = Math.min(height, getMaxImageDimension(sender));
|
||||
return height;
|
||||
}
|
||||
|
||||
@@ -174,7 +187,7 @@ public class FakeImage extends FakeEntity {
|
||||
}
|
||||
}
|
||||
|
||||
plugin.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
|
||||
LOGGER.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,7 +203,7 @@ public class FakeImage extends FakeEntity {
|
||||
* @return Image file instance or NULL if not found
|
||||
*/
|
||||
public @Nullable ImageFile getFile() {
|
||||
return plugin.getStorage().get(filename);
|
||||
return YamipaPlugin.getInstance().getStorage().get(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -307,6 +320,7 @@ public class FakeImage extends FakeEntity {
|
||||
* @param face Block face
|
||||
* @return TRUE for contained, FALSE otherwise
|
||||
*/
|
||||
@SuppressWarnings("RedundantIfStatement")
|
||||
public boolean contains(@NotNull Location location, @NotNull BlockFace face) {
|
||||
// Is point facing the same plane as the image?
|
||||
if (face != this.face) {
|
||||
@@ -347,18 +361,18 @@ public class FakeImage extends FakeEntity {
|
||||
*/
|
||||
private void load() {
|
||||
ImageFile file = getFile();
|
||||
FakeMapsContainer container;
|
||||
if (file == null) {
|
||||
container = FakeMap.getErrorMatrix(width, height);
|
||||
plugin.warning("File \"" + filename + "\" does not exist");
|
||||
} else {
|
||||
container = file.getMapsAndSubscribe(this);
|
||||
}
|
||||
|
||||
// Extract data from container
|
||||
FakeMap[][][] maps = container.getFakeMaps();
|
||||
// Get maps to use
|
||||
FakeMap[][][] maps;
|
||||
if (file == null) {
|
||||
maps = FakeMap.getErrorMatrix(width, height);
|
||||
LOGGER.warning("File \"" + filename + "\" does not exist");
|
||||
} else {
|
||||
CachedMapsFile cachedMapsFile = file.getMapsAndSubscribe(this);
|
||||
maps = cachedMapsFile.getMaps();
|
||||
delay = cachedMapsFile.getDelay();
|
||||
}
|
||||
numOfSteps = maps[0][0].length;
|
||||
delay = container.getDelay();
|
||||
|
||||
// Generate frames
|
||||
FakeItemFrame[] newFrames = new FakeItemFrame[width*height];
|
||||
@@ -372,14 +386,16 @@ public class FakeImage extends FakeEntity {
|
||||
frames = newFrames;
|
||||
|
||||
// Start animation task (if needed)
|
||||
if (animateImages && task == null && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
boolean isAnimationEnabled = plugin.getRenderer().isAnimationEnabled();
|
||||
if (isAnimationEnabled && task == null && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) {
|
||||
task = plugin.getScheduler().scheduleAtFixedRate(
|
||||
this::nextStep,
|
||||
0,
|
||||
delay*50L,
|
||||
TimeUnit.MILLISECONDS
|
||||
);
|
||||
plugin.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")");
|
||||
LOGGER.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")");
|
||||
}
|
||||
|
||||
// Notify listener
|
||||
@@ -394,7 +410,7 @@ public class FakeImage extends FakeEntity {
|
||||
* @param player Player instance
|
||||
*/
|
||||
public void spawn(@NotNull Player player) {
|
||||
plugin.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName());
|
||||
LOGGER.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName());
|
||||
|
||||
// Send pixels if instance is already loaded
|
||||
if (frames != null) {
|
||||
@@ -428,11 +444,19 @@ public class FakeImage extends FakeEntity {
|
||||
* @param player Player instance
|
||||
*/
|
||||
private void spawnOnceLoaded(@NotNull Player player) {
|
||||
String playerName = player.getName();
|
||||
observingPlayers.add(player);
|
||||
|
||||
// Prepare packets to send
|
||||
List<PacketContainer> packets = new ArrayList<>();
|
||||
for (FakeItemFrame frame : frames) {
|
||||
frame.spawn(player);
|
||||
frame.render(player, 0);
|
||||
packets.add(frame.getSpawnPacket());
|
||||
packets.addAll(frame.getRenderPackets(player, 0));
|
||||
LOGGER.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName);
|
||||
}
|
||||
|
||||
// Send packets
|
||||
tryToSendPackets(player, packets);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -449,7 +473,7 @@ public class FakeImage extends FakeEntity {
|
||||
* @param player Player instance or NULL for all observing players
|
||||
*/
|
||||
public void destroy(@Nullable Player player) {
|
||||
plugin.fine(
|
||||
LOGGER.fine(
|
||||
"Received request to destroy FakeImage#(" + location + "," + face + ") for " +
|
||||
(player == null ? "all players" : "Player#" + player.getName())
|
||||
);
|
||||
@@ -458,9 +482,13 @@ public class FakeImage extends FakeEntity {
|
||||
if (frames != null) {
|
||||
Set<Player> targets = (player == null) ? observingPlayers : Collections.singleton(player);
|
||||
for (Player target : targets) {
|
||||
String targetName = target.getName();
|
||||
List<PacketContainer> packets = new ArrayList<>();
|
||||
for (FakeItemFrame frame : frames) {
|
||||
frame.destroy(target);
|
||||
packets.add(frame.getDestroyPacket());
|
||||
LOGGER.fine("Destroyed FakeItemFrame#" + frame.getId() + " for Player#" + targetName);
|
||||
}
|
||||
tryToSendPackets(target, packets);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -499,12 +527,13 @@ public class FakeImage extends FakeEntity {
|
||||
task.cancel(true);
|
||||
task = null;
|
||||
currentStep = -1;
|
||||
plugin.fine("Destroyed animation task for FakeImage#(" + location + "," + face + ")");
|
||||
LOGGER.fine("Destroyed animation task for FakeImage#(" + location + "," + face + ")");
|
||||
}
|
||||
|
||||
// Free array of fake item frames
|
||||
frames = null;
|
||||
plugin.fine("Invalidated FakeImage#(" + location + "," + face + ")");
|
||||
loading = false;
|
||||
LOGGER.fine("Invalidated FakeImage#(" + location + "," + face + ")");
|
||||
|
||||
// Notify invalidation to source ImageFile
|
||||
ImageFile file = getFile();
|
||||
@@ -518,10 +547,18 @@ public class FakeImage extends FakeEntity {
|
||||
*/
|
||||
private void nextStep() {
|
||||
currentStep = (currentStep + 1) % numOfSteps;
|
||||
for (Player player : observingPlayers) {
|
||||
for (FakeItemFrame frame : frames) {
|
||||
frame.render(player, currentStep);
|
||||
try {
|
||||
for (Player player : observingPlayers) {
|
||||
List<PacketContainer> packets = new ArrayList<>();
|
||||
for (FakeItemFrame frame : frames) {
|
||||
packets.addAll(frame.getRenderPackets(player, currentStep));
|
||||
}
|
||||
tryToSendPackets(player, packets);
|
||||
}
|
||||
} catch (ConcurrentModificationException e) {
|
||||
// We can safely ignore this exception as it will just result
|
||||
// in a dropped step (all `observingPlayers` modifications are
|
||||
// called from the same thread).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import com.comphenix.protocol.events.PacketContainer;
|
||||
import com.comphenix.protocol.utility.MinecraftReflection;
|
||||
import com.comphenix.protocol.wrappers.nbt.NbtCompound;
|
||||
import com.comphenix.protocol.wrappers.nbt.NbtFactory;
|
||||
@@ -7,6 +8,7 @@ import io.josemmo.bukkit.plugin.packets.DestroyEntityPacket;
|
||||
import io.josemmo.bukkit.plugin.packets.EntityMetadataPacket;
|
||||
import io.josemmo.bukkit.plugin.packets.SpawnEntityPacket;
|
||||
import io.josemmo.bukkit.plugin.utils.Internals;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.Rotation;
|
||||
@@ -15,13 +17,16 @@ import org.bukkit.entity.EntityType;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.inventory.ItemStack;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class FakeItemFrame extends FakeEntity {
|
||||
public static final int MIN_FRAME_ID = Integer.MAX_VALUE / 4;
|
||||
public static final int MAX_FRAME_ID = Integer.MAX_VALUE;
|
||||
private static final boolean SUPPORTS_GLOWING = Internals.MINECRAFT_VERSION >= 17;
|
||||
private static final AtomicInteger lastFrameId = new AtomicInteger(MAX_FRAME_ID);
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeItemFrame");
|
||||
private static final AtomicInteger LAST_FRAME_ID = new AtomicInteger(MAX_FRAME_ID);
|
||||
private final int id;
|
||||
private final Location location;
|
||||
private final BlockFace face;
|
||||
@@ -34,8 +39,8 @@ public class FakeItemFrame extends FakeEntity {
|
||||
* @return Next unused item frame ID
|
||||
*/
|
||||
private static int getNextId() {
|
||||
return lastFrameId.updateAndGet(lastId -> {
|
||||
if (lastId >= MAX_FRAME_ID) {
|
||||
return LAST_FRAME_ID.updateAndGet(lastId -> {
|
||||
if (lastId == MAX_FRAME_ID) {
|
||||
return MIN_FRAME_ID;
|
||||
}
|
||||
return lastId + 1;
|
||||
@@ -63,14 +68,22 @@ public class FakeItemFrame extends FakeEntity {
|
||||
this.rotation = rotation;
|
||||
this.glowing = glowing;
|
||||
this.maps = maps;
|
||||
plugin.fine("Created FakeItemFrame#" + this.id + " using " + this.maps.length + " FakeMap(s)");
|
||||
LOGGER.fine("Created FakeItemFrame#" + this.id + " using " + this.maps.length + " FakeMap(s)");
|
||||
}
|
||||
|
||||
/**
|
||||
* Spawn empty item frame in player's client
|
||||
* @param player Player instance
|
||||
* Get frame ID
|
||||
* @return Frame ID
|
||||
*/
|
||||
public void spawn(@NotNull Player player) {
|
||||
public int getId() {
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get entity spawn packet
|
||||
* @return Spawn packet
|
||||
*/
|
||||
public @NotNull SpawnEntityPacket getSpawnPacket() {
|
||||
// Calculate frame position in relation to target block
|
||||
double x = location.getBlockX();
|
||||
double y = location.getBlockY();
|
||||
@@ -115,18 +128,23 @@ public class FakeItemFrame extends FakeEntity {
|
||||
.setPosition(x, y, z)
|
||||
.setRotation(pitch, yaw)
|
||||
.setData(orientation);
|
||||
tryToSendPacket(player, framePacket);
|
||||
plugin.fine("Spawned FakeItemFrame#" + this.id + " for Player#" + player.getName());
|
||||
|
||||
return framePacket;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send frame of animation to player
|
||||
* @param player Player instance
|
||||
* @param step Map step to send
|
||||
* Get frame of animation packets
|
||||
* @param player Player who is expected to receive packets (for caching reasons)
|
||||
* @param step Map step
|
||||
*/
|
||||
public void render(@NotNull Player player, int step) {
|
||||
// Send map pixels
|
||||
maps[step].sendPixels(player);
|
||||
public @NotNull List<PacketContainer> getRenderPackets(@NotNull Player player, int step) {
|
||||
List<PacketContainer> packets = new ArrayList<>(2);
|
||||
|
||||
// Enqueue map pixels packet (if needed)
|
||||
boolean mustSendPixels = maps[step].requestResend(player);
|
||||
if (mustSendPixels) {
|
||||
packets.add(maps[step].getPixelsPacket());
|
||||
}
|
||||
|
||||
// Create and attach filled map
|
||||
ItemStack itemStack = MinecraftReflection.getBukkitItemStack(new ItemStack(Material.FILLED_MAP));
|
||||
@@ -135,25 +153,24 @@ public class FakeItemFrame extends FakeEntity {
|
||||
NbtFactory.setItemTag(itemStack, itemStackNbt);
|
||||
|
||||
// Build entity metadata packet
|
||||
EntityMetadataPacket mapPacket = new EntityMetadataPacket();
|
||||
mapPacket.setId(id)
|
||||
EntityMetadataPacket metadataPacket = new EntityMetadataPacket();
|
||||
metadataPacket.setId(id)
|
||||
.setInvisible(true)
|
||||
.setItem(itemStack)
|
||||
.setRotation(rotation)
|
||||
.build();
|
||||
packets.add(metadataPacket);
|
||||
|
||||
// Send animation status update
|
||||
tryToSendPacket(player, mapPacket);
|
||||
return packets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy item frame from player's client
|
||||
* @param player Player instance
|
||||
* Get destroy item frame packet
|
||||
* @return Destroy packet
|
||||
*/
|
||||
public void destroy(@NotNull Player player) {
|
||||
public @NotNull DestroyEntityPacket getDestroyPacket() {
|
||||
DestroyEntityPacket destroyPacket = new DestroyEntityPacket();
|
||||
destroyPacket.setId(id);
|
||||
tryToSendPacket(player, destroyPacket);
|
||||
plugin.fine("Destroyed FakeItemFrame#" + this.id + " for Player#" + player.getName());
|
||||
return destroyPacket;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import io.josemmo.bukkit.plugin.packets.MapDataPacket;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.map.MapPalette;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.awt.*;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
@@ -14,11 +16,12 @@ import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
public class FakeMap extends FakeEntity {
|
||||
public static final int DIMENSION = 128;
|
||||
public static final int MIN_MAP_ID = Integer.MAX_VALUE / 4;
|
||||
public static final int MAX_MAP_ID = Integer.MAX_VALUE;
|
||||
public static final int RESEND_THRESHOLD = 60*5; // Seconds after sending pixels when resending should be avoided
|
||||
private static final AtomicInteger lastMapId = new AtomicInteger(-1);
|
||||
private static FakeMap errorInstance;
|
||||
private static final int MIN_MAP_ID = Integer.MAX_VALUE / 4;
|
||||
private static final int MAX_MAP_ID = Integer.MAX_VALUE;
|
||||
private static final int RESEND_THRESHOLD = 60*5; // Seconds after sending pixels when resending should be avoided
|
||||
private static final Logger LOGGER = Logger.getLogger("FakeMap");
|
||||
private static final AtomicInteger LAST_MAP_ID = new AtomicInteger(MIN_MAP_ID);
|
||||
private static @Nullable FakeMap ERROR_INSTANCE;
|
||||
private final int id;
|
||||
private final byte[] pixels;
|
||||
private final ConcurrentMap<UUID, Long> lastPlayerSendTime = new ConcurrentHashMap<>();
|
||||
@@ -28,8 +31,8 @@ public class FakeMap extends FakeEntity {
|
||||
* @return Next unused map ID
|
||||
*/
|
||||
private static int getNextId() {
|
||||
return lastMapId.updateAndGet(lastId -> {
|
||||
if (lastId <= MIN_MAP_ID) {
|
||||
return LAST_MAP_ID.updateAndGet(lastId -> {
|
||||
if (lastId == MIN_MAP_ID) {
|
||||
return MAX_MAP_ID;
|
||||
}
|
||||
return lastId - 1;
|
||||
@@ -51,27 +54,27 @@ public class FakeMap extends FakeEntity {
|
||||
* @return Error instance
|
||||
*/
|
||||
private static @NotNull FakeMap getErrorInstance() {
|
||||
if (errorInstance == null) {
|
||||
if (ERROR_INSTANCE == null) {
|
||||
byte[] pixels = new byte[DIMENSION * DIMENSION];
|
||||
Arrays.fill(pixels, pixelToIndex(Color.RED.getRGB()));
|
||||
errorInstance = new FakeMap(pixels);
|
||||
ERROR_INSTANCE = new FakeMap(pixels);
|
||||
}
|
||||
return errorInstance;
|
||||
return ERROR_INSTANCE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get matrix of error maps
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @return Fake maps container
|
||||
* @return Fake maps
|
||||
*/
|
||||
public static @NotNull FakeMapsContainer getErrorMatrix(int width, int height) {
|
||||
public static @NotNull FakeMap[][][] getErrorMatrix(int width, int height) {
|
||||
FakeMap[] errorMaps = new FakeMap[] {getErrorInstance()};
|
||||
FakeMap[][][] matrix = new FakeMap[width][height][1];
|
||||
for (FakeMap[][] column : matrix) {
|
||||
Arrays.fill(column, errorMaps);
|
||||
}
|
||||
return new FakeMapsContainer(matrix, 0);
|
||||
return matrix;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,7 +93,7 @@ public class FakeMap extends FakeEntity {
|
||||
System.arraycopy(pixels, startX+(startY+y)*scanSize, this.pixels, y*DIMENSION, DIMENSION);
|
||||
}
|
||||
|
||||
plugin.fine("Created FakeMap#" + this.id);
|
||||
LOGGER.fine("Created FakeMap#" + this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,7 +103,7 @@ public class FakeMap extends FakeEntity {
|
||||
public FakeMap(byte[] pixels) {
|
||||
this.id = getNextId();
|
||||
this.pixels = pixels;
|
||||
plugin.fine("Created FakeMap#" + this.id);
|
||||
LOGGER.fine("Created FakeMap#" + this.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -120,30 +123,37 @@ public class FakeMap extends FakeEntity {
|
||||
}
|
||||
|
||||
/**
|
||||
* Send map pixels to player
|
||||
* @param player Player instance
|
||||
* Request re-send of map pixels
|
||||
* @param player Player who is expected to receive pixels
|
||||
* @return Whether re-send authorization was granted or not
|
||||
*/
|
||||
public void sendPixels(@NotNull Player player) {
|
||||
public boolean requestResend(@NotNull Player player) {
|
||||
UUID uuid = player.getUniqueId();
|
||||
long now = Instant.now().getEpochSecond();
|
||||
|
||||
// Avoid re-sending pixels too frequently
|
||||
// Has enough time passed since last re-send?
|
||||
long last = lastPlayerSendTime.getOrDefault(uuid, 0L);
|
||||
if ((now-last) <= RESEND_THRESHOLD && (player.getLastPlayed()/1000) < last) {
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Create map data packet
|
||||
// Authorize re-send and update latest timestamp
|
||||
lastPlayerSendTime.put(uuid, now);
|
||||
LOGGER.fine("Granted sending pixels for FakeMap#" + id + " to Player#" + player.getName());
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get map pixels packet
|
||||
* @return Map pixels packet
|
||||
*/
|
||||
public @NotNull MapDataPacket getPixelsPacket() {
|
||||
MapDataPacket mapDataPacket = new MapDataPacket();
|
||||
mapDataPacket.setId(id)
|
||||
.setScale(0) // Fully zoomed-in
|
||||
.setLocked(true)
|
||||
.setArea(DIMENSION, DIMENSION, 0, 0)
|
||||
.setPixels(pixels);
|
||||
|
||||
// Send packet
|
||||
tryToSendPacket(player, mapDataPacket);
|
||||
lastPlayerSendTime.put(uuid, now);
|
||||
plugin.fine("Sent pixels for FakeMap#" + id + " to Player#" + player.getName());
|
||||
return mapDataPacket;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
public class FakeMapsContainer {
|
||||
private final FakeMap[][][] fakeMaps;
|
||||
private final int delay;
|
||||
|
||||
public FakeMapsContainer(FakeMap[][][] fakeMaps, int delay) {
|
||||
this.fakeMaps = fakeMaps;
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fake maps
|
||||
* @return Tri-dimensional array of maps (column, row, step)
|
||||
*/
|
||||
public FakeMap[][][] getFakeMaps() {
|
||||
return fakeMaps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delay between steps
|
||||
* @return Delay in 50ms intervals or <code>0</code> if not applicable
|
||||
*/
|
||||
public int getDelay() {
|
||||
return delay;
|
||||
}
|
||||
}
|
||||
@@ -2,31 +2,35 @@ package io.josemmo.bukkit.plugin.renderer;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.utils.CsvConfiguration;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.BlockFace;
|
||||
import org.bukkit.entity.Entity;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.event.EventHandler;
|
||||
import org.bukkit.event.EventPriority;
|
||||
import org.bukkit.event.HandlerList;
|
||||
import org.bukkit.event.Listener;
|
||||
import org.bukkit.event.player.*;
|
||||
import org.bukkit.event.vehicle.VehicleMoveEvent;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ConcurrentMap;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.logging.Level;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
public class ImageRenderer implements Listener {
|
||||
public static final long SAVE_INTERVAL = 20L * 90; // In server ticks
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private final String configPath;
|
||||
private static final long SAVE_INTERVAL = 20L * 90; // In server ticks
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageRenderer");
|
||||
private final Path configPath;
|
||||
private final boolean animateImages;
|
||||
private final int maxImageDimension;
|
||||
private BukkitTask saveTask;
|
||||
private final AtomicBoolean hasConfigChanged = new AtomicBoolean(false);
|
||||
private final ConcurrentMap<WorldAreaId, Set<FakeImage>> images = new ConcurrentHashMap<>();
|
||||
@@ -35,10 +39,30 @@ public class ImageRenderer implements Listener {
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param configPath Path to configuration file
|
||||
* @param configPath Path to configuration file
|
||||
* @param animateImages Whether to animate images or not
|
||||
* @param maxImageDimension Maximum image dimension in blocks
|
||||
*/
|
||||
public ImageRenderer(@NotNull String configPath) {
|
||||
public ImageRenderer(@NotNull Path configPath, boolean animateImages, int maxImageDimension) {
|
||||
this.configPath = configPath;
|
||||
this.animateImages = animateImages;
|
||||
this.maxImageDimension = maxImageDimension;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is animation enabled
|
||||
* @return Is animation enabled
|
||||
*/
|
||||
public boolean isAnimationEnabled() {
|
||||
return animateImages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maximum image dimension
|
||||
* @return Maximum image dimension in blocks
|
||||
*/
|
||||
public int getMaxImageDimension() {
|
||||
return maxImageDimension;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -46,6 +70,7 @@ public class ImageRenderer implements Listener {
|
||||
*/
|
||||
public void start() {
|
||||
loadConfig();
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
plugin.getServer().getPluginManager().registerEvents(this, plugin);
|
||||
saveTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::saveConfig, SAVE_INTERVAL, SAVE_INTERVAL);
|
||||
}
|
||||
@@ -79,8 +104,8 @@ public class ImageRenderer implements Listener {
|
||||
* Load configuration from disk
|
||||
*/
|
||||
private void loadConfig() {
|
||||
if (!Files.isRegularFile(Paths.get(configPath))) {
|
||||
plugin.info("No placed fake images configuration file found");
|
||||
if (!Files.isRegularFile(configPath)) {
|
||||
LOGGER.info("No placed fake images configuration file found");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,7 +114,7 @@ public class ImageRenderer implements Listener {
|
||||
try {
|
||||
config.load(configPath);
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to load placed fake images from disk", e);
|
||||
LOGGER.severe("Failed to load placed fake images from disk", e);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -97,19 +122,19 @@ public class ImageRenderer implements Listener {
|
||||
for (String[] row : config.getRows()) {
|
||||
try {
|
||||
String filename = row[0];
|
||||
World world = Objects.requireNonNull(plugin.getServer().getWorld(row[1]));
|
||||
World world = Objects.requireNonNull(YamipaPlugin.getInstance().getServer().getWorld(row[1]));
|
||||
double x = Integer.parseInt(row[2]);
|
||||
double y = Integer.parseInt(row[3]);
|
||||
double z = Integer.parseInt(row[4]);
|
||||
Location location = new Location(world, x, y, z);
|
||||
BlockFace face = BlockFace.valueOf(row[5]);
|
||||
Rotation rotation = Rotation.valueOf(row[6]);
|
||||
int width = Math.min(FakeImage.MAX_DIMENSION, Math.abs(Integer.parseInt(row[7])));
|
||||
int height = Math.min(FakeImage.MAX_DIMENSION, Math.abs(Integer.parseInt(row[8])));
|
||||
Date placedAt = (row.length > 9 && !row[9].equals("")) ?
|
||||
int width = Math.abs(Integer.parseInt(row[7]));
|
||||
int height = Math.abs(Integer.parseInt(row[8]));
|
||||
Date placedAt = (row.length > 9 && !row[9].isEmpty()) ?
|
||||
new Date(Long.parseLong(row[9])*1000L) :
|
||||
null;
|
||||
UUID placedById = (row.length > 10 && !row[10].equals("")) ?
|
||||
UUID placedById = (row.length > 10 && !row[10].isEmpty()) ?
|
||||
UUID.fromString(row[10]) :
|
||||
FakeImage.UNKNOWN_PLAYER_ID;
|
||||
OfflinePlayer placedBy = Bukkit.getOfflinePlayer(placedById);
|
||||
@@ -120,7 +145,7 @@ public class ImageRenderer implements Listener {
|
||||
placedAt, placedBy, flags);
|
||||
addImage(fakeImage, true);
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.SEVERE, "Invalid fake image properties: " + String.join(";", row), e);
|
||||
LOGGER.severe("Invalid fake image properties: " + String.join(";", row), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,6 +153,7 @@ public class ImageRenderer implements Listener {
|
||||
/**
|
||||
* Save configuration to disk
|
||||
*/
|
||||
@SuppressWarnings("ExtractMethodRecommender")
|
||||
private void saveConfig() {
|
||||
if (!hasConfigChanged.get()) return;
|
||||
|
||||
@@ -165,9 +191,9 @@ public class ImageRenderer implements Listener {
|
||||
// Write to disk
|
||||
try {
|
||||
config.save(configPath);
|
||||
plugin.info("Saved placed fake images to disk");
|
||||
LOGGER.info("Saved placed fake images to disk");
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to save placed fake images to disk", e);
|
||||
LOGGER.severe("Failed to save placed fake images to disk", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +208,7 @@ public class ImageRenderer implements Listener {
|
||||
// Add image to renderer
|
||||
for (WorldAreaId worldAreaId : imageWorldAreaIds) {
|
||||
images.computeIfAbsent(worldAreaId, __ -> {
|
||||
plugin.fine("Created WorldArea#(" + worldAreaId + ")");
|
||||
LOGGER.fine("Created WorldArea#(" + worldAreaId + ")");
|
||||
return ConcurrentHashMap.newKeySet();
|
||||
}).add(image);
|
||||
}
|
||||
@@ -241,7 +267,7 @@ public class ImageRenderer implements Listener {
|
||||
* @param maxX Maximum X coordinate
|
||||
* @param minZ Minimum Z coordinate
|
||||
* @param maxZ Maximum Z coordinate
|
||||
* @return List of found images
|
||||
* @return Set of found images
|
||||
*/
|
||||
public @NotNull Set<FakeImage> getImages(@NotNull World world, int minX, int maxX, int minZ, int maxZ) {
|
||||
Set<FakeImage> response = new HashSet<>();
|
||||
@@ -272,7 +298,7 @@ public class ImageRenderer implements Listener {
|
||||
Set<FakeImage> worldAreaImages = images.get(worldAreaId);
|
||||
worldAreaImages.remove(image);
|
||||
if (worldAreaImages.isEmpty()) {
|
||||
plugin.fine("Destroyed WorldArea#(" + worldAreaId + ")");
|
||||
LOGGER.fine("Destroyed WorldArea#(" + worldAreaId + ")");
|
||||
images.remove(worldAreaId);
|
||||
}
|
||||
}
|
||||
@@ -364,7 +390,7 @@ public class ImageRenderer implements Listener {
|
||||
private void onPlayerLocationChange(@NotNull Player player, @NotNull Location location) {
|
||||
// Ignore NPC events from other plugins
|
||||
if (player.hasMetadata("NPC")) {
|
||||
plugin.fine("Ignored NPC event from Player#" + player.getName());
|
||||
LOGGER.fine("Ignored NPC event from Player#" + player.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -375,7 +401,7 @@ public class ImageRenderer implements Listener {
|
||||
return;
|
||||
}
|
||||
playersLocation.put(player, worldAreaId);
|
||||
plugin.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")");
|
||||
LOGGER.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")");
|
||||
|
||||
// Get images that should be spawned/destroyed
|
||||
Set<FakeImage> desiredState = getImagesInViewDistance(worldAreaId);
|
||||
@@ -426,9 +452,8 @@ public class ImageRenderer implements Listener {
|
||||
|
||||
// Wait until next server tick before handling location change
|
||||
// This is necessary as teleport events get fired *before* teleporting the player
|
||||
Bukkit.getScheduler().runTask(plugin, () -> {
|
||||
onPlayerLocationChange(event.getPlayer(), event.getTo());
|
||||
});
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
Bukkit.getScheduler().runTask(plugin, () -> onPlayerLocationChange(event.getPlayer(), event.getTo()));
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
@@ -437,4 +462,16 @@ public class ImageRenderer implements Listener {
|
||||
if (event.getFrom().getChunk().equals(event.getTo().getChunk())) return;
|
||||
onPlayerLocationChange(event.getPlayer(), event.getTo());
|
||||
}
|
||||
|
||||
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
|
||||
public void onVehicleMove(@NotNull VehicleMoveEvent event) {
|
||||
List<Entity> passengers = event.getVehicle().getPassengers();
|
||||
if (passengers.isEmpty()) return;
|
||||
if (event.getFrom().getChunk().equals(event.getTo().getChunk())) return;
|
||||
for (Entity passenger : passengers) {
|
||||
if (passenger instanceof Player) {
|
||||
onPlayerLocationChange((Player) passenger, event.getTo());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import io.josemmo.bukkit.plugin.commands.ImageCommand;
|
||||
import io.josemmo.bukkit.plugin.storage.ImageFile;
|
||||
import io.josemmo.bukkit.plugin.utils.ActionBar;
|
||||
import io.josemmo.bukkit.plugin.utils.InteractWithEntityListener;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.bukkit.*;
|
||||
import org.bukkit.block.Block;
|
||||
import org.bukkit.block.BlockFace;
|
||||
@@ -27,11 +28,19 @@ import java.util.Collections;
|
||||
import java.util.Objects;
|
||||
|
||||
public class ItemService extends InteractWithEntityListener implements Listener {
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private static final NamespacedKey NSK_FILENAME = new NamespacedKey(plugin, "filename");
|
||||
private static final NamespacedKey NSK_WIDTH = new NamespacedKey(plugin, "width");
|
||||
private static final NamespacedKey NSK_HEIGHT = new NamespacedKey(plugin, "height");
|
||||
private static final NamespacedKey NSK_FLAGS = new NamespacedKey(plugin, "flags");
|
||||
private static final Logger LOGGER = Logger.getLogger("ItemService");
|
||||
private static final NamespacedKey NSK_FILENAME;
|
||||
private static final NamespacedKey NSK_WIDTH;
|
||||
private static final NamespacedKey NSK_HEIGHT;
|
||||
private static final NamespacedKey NSK_FLAGS;
|
||||
|
||||
static {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance(); // Only used for getting namespace, reference will be freed
|
||||
NSK_FILENAME = new NamespacedKey(plugin, "filename");
|
||||
NSK_WIDTH = new NamespacedKey(plugin, "width");
|
||||
NSK_HEIGHT = new NamespacedKey(plugin, "height");
|
||||
NSK_FLAGS = new NamespacedKey(plugin, "flags");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image item
|
||||
@@ -48,9 +57,9 @@ public class ItemService extends InteractWithEntityListener implements Listener
|
||||
|
||||
// Set metadata
|
||||
PersistentDataContainer itemData = itemMeta.getPersistentDataContainer();
|
||||
itemMeta.setDisplayName(image.getName() + ChatColor.AQUA + " (" + width + "x" + height + ")");
|
||||
itemMeta.setDisplayName(image.getFilename() + ChatColor.AQUA + " (" + width + "x" + height + ")");
|
||||
itemMeta.setLore(Collections.singletonList("Yamipa image"));
|
||||
itemData.set(NSK_FILENAME, PersistentDataType.STRING, image.getName());
|
||||
itemData.set(NSK_FILENAME, PersistentDataType.STRING, image.getFilename());
|
||||
itemData.set(NSK_WIDTH, PersistentDataType.INTEGER, width);
|
||||
itemData.set(NSK_HEIGHT, PersistentDataType.INTEGER, height);
|
||||
itemData.set(NSK_FLAGS, PersistentDataType.INTEGER, flags);
|
||||
@@ -64,6 +73,7 @@ public class ItemService extends InteractWithEntityListener implements Listener
|
||||
*/
|
||||
public void start() {
|
||||
register();
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
plugin.getServer().getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
@@ -132,14 +142,14 @@ public class ItemService extends InteractWithEntityListener implements Listener
|
||||
return;
|
||||
}
|
||||
if (width == null || height == null || flags == null) {
|
||||
plugin.warning(player + " tried to place corrupted image item (missing width/height/flags properties)");
|
||||
LOGGER.warning(player + " tried to place corrupted image item (missing width/height/flags properties)");
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate filename
|
||||
ImageFile image = YamipaPlugin.getInstance().getStorage().get(filename);
|
||||
if (image == null) {
|
||||
plugin.warning(player + " tried to place corrupted image item (\"" + filename + "\" no longer exists)");
|
||||
LOGGER.warning(player + " tried to place corrupted image item (\"" + filename + "\" no longer exists)");
|
||||
ActionBar.send(player, ChatColor.RED + "Image file \"" + filename + "\" no longer exists");
|
||||
return;
|
||||
}
|
||||
@@ -176,7 +186,7 @@ public class ItemService extends InteractWithEntityListener implements Listener
|
||||
|
||||
@Override
|
||||
public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
|
||||
ImageRenderer renderer = plugin.getRenderer();
|
||||
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
|
||||
Location location = block.getLocation();
|
||||
|
||||
// Has the player clicked a removable placed image?
|
||||
@@ -207,9 +217,8 @@ public class ItemService extends InteractWithEntityListener implements Listener
|
||||
ImageFile imageFile = Objects.requireNonNull(image.getFile());
|
||||
ItemStack imageItem = getImageItem(imageFile, 1, image.getWidth(), image.getHeight(), image.getFlags());
|
||||
Location dropLocation = location.clone().add(0.5, -0.5, 0.5).add(face.getDirection());
|
||||
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> {
|
||||
block.getWorld().dropItem(dropLocation, imageItem);
|
||||
});
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> block.getWorld().dropItem(dropLocation, imageItem));
|
||||
}
|
||||
|
||||
return false;
|
||||
|
||||
@@ -6,6 +6,8 @@ import org.bukkit.Location;
|
||||
import org.bukkit.World;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
@@ -13,6 +15,7 @@ import java.util.Objects;
|
||||
*/
|
||||
public class WorldAreaId {
|
||||
private static boolean USE_WORLD_VIEW_DISTANCE = true;
|
||||
private static final Map<String, Integer> SIZES_PER_WORLD = new HashMap<>();
|
||||
private final World world;
|
||||
private final int x;
|
||||
private final int z;
|
||||
@@ -58,7 +61,7 @@ public class WorldAreaId {
|
||||
|
||||
/**
|
||||
* Get nearby world area IDs in view distance (plus this one)
|
||||
* @return List of neighbors
|
||||
* @return Array of neighbors
|
||||
*/
|
||||
public @NotNull WorldAreaId[] getNeighborhood() {
|
||||
// Get value from cache (if available)
|
||||
@@ -67,18 +70,133 @@ public class WorldAreaId {
|
||||
}
|
||||
|
||||
// Calculate neighborhood size from world's view distance
|
||||
int size = (USE_WORLD_VIEW_DISTANCE ? world.getViewDistance() : Bukkit.getServer().getViewDistance()) / 4;
|
||||
// NOTE: World size is cached to prevent issues with plugins that modify it at runtime
|
||||
int size = SIZES_PER_WORLD.computeIfAbsent(world.getName(), (__) -> {
|
||||
int distance = USE_WORLD_VIEW_DISTANCE ? world.getViewDistance() : Bukkit.getServer().getViewDistance();
|
||||
return distance / 4;
|
||||
});
|
||||
|
||||
// Size 3 (16-chunks radius)
|
||||
// ···XXX···
|
||||
// ·XXXXXXX·
|
||||
// ·XXXXXXX·
|
||||
// XXXXXXXXX
|
||||
// Size 0 (1+1+1=3)
|
||||
// ·|·
|
||||
// XOX
|
||||
// ·|·
|
||||
if (size == 0) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x, z-1),
|
||||
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
|
||||
new WorldAreaId(world, x, z+1),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size 1 (2+1+2=5)
|
||||
// ·X|X·
|
||||
// XX|XX
|
||||
// XXOXX
|
||||
// XX|XX
|
||||
// ·X|X·
|
||||
if (size == 1) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size 2 (3+1+3=7)
|
||||
// ··X|X··
|
||||
// ·XX|XX·
|
||||
// XXX|XXX
|
||||
// XXXOXXX
|
||||
// XXX|XXX
|
||||
// ·XX|XX·
|
||||
// ··X|X··
|
||||
if (size == 2) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-3),
|
||||
new WorldAreaId(world, x, z-3),
|
||||
new WorldAreaId(world, x+1, z-3),
|
||||
|
||||
new WorldAreaId(world, x-2, z-2),
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
new WorldAreaId(world, x+2, z-2),
|
||||
|
||||
new WorldAreaId(world, x-3, z-1),
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
new WorldAreaId(world, x+3, z-1),
|
||||
|
||||
new WorldAreaId(world, x-3, z),
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
new WorldAreaId(world, x+3, z),
|
||||
|
||||
new WorldAreaId(world, x-3, z+1),
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
new WorldAreaId(world, x+3, z+1),
|
||||
|
||||
new WorldAreaId(world, x-2, z+2),
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
new WorldAreaId(world, x+2, z+2),
|
||||
|
||||
new WorldAreaId(world, x-1, z+3),
|
||||
new WorldAreaId(world, x, z+3),
|
||||
new WorldAreaId(world, x+1, z+3),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size 3 (4+1+4=9)
|
||||
// ···X|X···
|
||||
// ·XXX|XXX·
|
||||
// ·XXX|XXX·
|
||||
// XXXX|XXXX
|
||||
// XXXXOXXXX
|
||||
// XXXXXXXXX
|
||||
// ·XXXXXXX·
|
||||
// ·XXXXXXX·
|
||||
// ···XXX···
|
||||
// XXXX|XXXX
|
||||
// ·XXX|XXX·
|
||||
// ·XXX|XXX·
|
||||
// ···X|X···
|
||||
if (size == 3) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-4),
|
||||
@@ -154,112 +272,126 @@ public class WorldAreaId {
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
// Size 2 (12-chunks radius)
|
||||
// ··XXX··
|
||||
// ·XXXXX·
|
||||
// XXXXXXX
|
||||
// XXXOXXX
|
||||
// XXXXXXX
|
||||
// ·XXXXX·
|
||||
// ··XXX··
|
||||
if (size == 2) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-3),
|
||||
new WorldAreaId(world, x, z-3),
|
||||
new WorldAreaId(world, x+1, z-3),
|
||||
|
||||
new WorldAreaId(world, x-2, z-2),
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
new WorldAreaId(world, x+2, z-2),
|
||||
|
||||
new WorldAreaId(world, x-3, z-1),
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
new WorldAreaId(world, x+3, z-1),
|
||||
|
||||
new WorldAreaId(world, x-3, z),
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
new WorldAreaId(world, x+3, z),
|
||||
|
||||
new WorldAreaId(world, x-3, z+1),
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
new WorldAreaId(world, x+3, z+1),
|
||||
|
||||
new WorldAreaId(world, x-2, z+2),
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
new WorldAreaId(world, x+2, z+2),
|
||||
|
||||
new WorldAreaId(world, x-1, z+3),
|
||||
new WorldAreaId(world, x, z+3),
|
||||
new WorldAreaId(world, x+1, z+3),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
//// Size 1 (8-chunks radius)
|
||||
// ·XXX·
|
||||
// XXXXX
|
||||
// XXOXX
|
||||
// XXXXX
|
||||
// ·XXX·
|
||||
if (size == 1) {
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
//// Size 0 (4x4 chunks)
|
||||
// ·X·
|
||||
// XOX
|
||||
// ·X·
|
||||
// Size ≥4 (5+1+5=11)
|
||||
// ···XX|XX···
|
||||
// ··XXX|XXX··
|
||||
// ·XXXX|XXXX·
|
||||
// XXXXX|XXXXX
|
||||
// XXXXX|XXXXX
|
||||
// XXXXXOXXXXX
|
||||
// XXXXX|XXXXX
|
||||
// XXXXX|XXXXX
|
||||
// ·XXXX|XXXX·
|
||||
// ··XXX|XXX··
|
||||
// ···XX|XX···
|
||||
neighborhood = new WorldAreaId[]{
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x-2, z-5),
|
||||
new WorldAreaId(world, x-1, z-5),
|
||||
new WorldAreaId(world, x, z-5),
|
||||
new WorldAreaId(world, x+1, z-5),
|
||||
new WorldAreaId(world, x+2, z-5),
|
||||
|
||||
new WorldAreaId(world, x-3, z-4),
|
||||
new WorldAreaId(world, x-2, z-4),
|
||||
new WorldAreaId(world, x-1, z-4),
|
||||
new WorldAreaId(world, x, z-4),
|
||||
new WorldAreaId(world, x+1, z-4),
|
||||
new WorldAreaId(world, x+2, z-4),
|
||||
new WorldAreaId(world, x+3, z-4),
|
||||
|
||||
new WorldAreaId(world, x-4, z-3),
|
||||
new WorldAreaId(world, x-3, z-3),
|
||||
new WorldAreaId(world, x-2, z-3),
|
||||
new WorldAreaId(world, x-1, z-3),
|
||||
new WorldAreaId(world, x, z-3),
|
||||
new WorldAreaId(world, x+1, z-3),
|
||||
new WorldAreaId(world, x+2, z-3),
|
||||
new WorldAreaId(world, x+3, z-3),
|
||||
new WorldAreaId(world, x+4, z-3),
|
||||
|
||||
new WorldAreaId(world, x-5, z-2),
|
||||
new WorldAreaId(world, x-4, z-2),
|
||||
new WorldAreaId(world, x-3, z-2),
|
||||
new WorldAreaId(world, x-2, z-2),
|
||||
new WorldAreaId(world, x-1, z-2),
|
||||
new WorldAreaId(world, x, z-2),
|
||||
new WorldAreaId(world, x+1, z-2),
|
||||
new WorldAreaId(world, x+2, z-2),
|
||||
new WorldAreaId(world, x+3, z-2),
|
||||
new WorldAreaId(world, x+4, z-2),
|
||||
new WorldAreaId(world, x+5, z-2),
|
||||
|
||||
new WorldAreaId(world, x-5, z-1),
|
||||
new WorldAreaId(world, x-4, z-1),
|
||||
new WorldAreaId(world, x-3, z-1),
|
||||
new WorldAreaId(world, x-2, z-1),
|
||||
new WorldAreaId(world, x-1, z-1),
|
||||
new WorldAreaId(world, x, z-1),
|
||||
new WorldAreaId(world, x+1, z-1),
|
||||
new WorldAreaId(world, x+2, z-1),
|
||||
new WorldAreaId(world, x+3, z-1),
|
||||
new WorldAreaId(world, x+4, z-1),
|
||||
new WorldAreaId(world, x+5, z-1),
|
||||
|
||||
new WorldAreaId(world, x-5, z),
|
||||
new WorldAreaId(world, x-4, z),
|
||||
new WorldAreaId(world, x-3, z),
|
||||
new WorldAreaId(world, x-2, z),
|
||||
new WorldAreaId(world, x-1, z),
|
||||
this,
|
||||
new WorldAreaId(world, x+1, z),
|
||||
new WorldAreaId(world, x+2, z),
|
||||
new WorldAreaId(world, x+3, z),
|
||||
new WorldAreaId(world, x+4, z),
|
||||
new WorldAreaId(world, x+5, z),
|
||||
|
||||
new WorldAreaId(world, x-5, z+1),
|
||||
new WorldAreaId(world, x-4, z+1),
|
||||
new WorldAreaId(world, x-3, z+1),
|
||||
new WorldAreaId(world, x-2, z+1),
|
||||
new WorldAreaId(world, x-1, z+1),
|
||||
new WorldAreaId(world, x, z+1),
|
||||
new WorldAreaId(world, x+1, z+1),
|
||||
new WorldAreaId(world, x+2, z+1),
|
||||
new WorldAreaId(world, x+3, z+1),
|
||||
new WorldAreaId(world, x+4, z+1),
|
||||
new WorldAreaId(world, x+5, z+1),
|
||||
|
||||
new WorldAreaId(world, x-5, z+2),
|
||||
new WorldAreaId(world, x-4, z+2),
|
||||
new WorldAreaId(world, x-3, z+2),
|
||||
new WorldAreaId(world, x-2, z+2),
|
||||
new WorldAreaId(world, x-1, z+2),
|
||||
new WorldAreaId(world, x, z+2),
|
||||
new WorldAreaId(world, x+1, z+2),
|
||||
new WorldAreaId(world, x+2, z+2),
|
||||
new WorldAreaId(world, x+3, z+2),
|
||||
new WorldAreaId(world, x+4, z+2),
|
||||
new WorldAreaId(world, x+5, z+2),
|
||||
|
||||
new WorldAreaId(world, x-4, z+3),
|
||||
new WorldAreaId(world, x-3, z+3),
|
||||
new WorldAreaId(world, x-2, z+3),
|
||||
new WorldAreaId(world, x-1, z+3),
|
||||
new WorldAreaId(world, x, z+3),
|
||||
new WorldAreaId(world, x+1, z+3),
|
||||
new WorldAreaId(world, x+2, z+3),
|
||||
new WorldAreaId(world, x+3, z+3),
|
||||
new WorldAreaId(world, x+4, z+3),
|
||||
|
||||
new WorldAreaId(world, x-3, z+4),
|
||||
new WorldAreaId(world, x-2, z+4),
|
||||
new WorldAreaId(world, x-1, z+4),
|
||||
new WorldAreaId(world, x, z+4),
|
||||
new WorldAreaId(world, x+1, z+4),
|
||||
new WorldAreaId(world, x+2, z+4),
|
||||
new WorldAreaId(world, x+3, z+4),
|
||||
|
||||
new WorldAreaId(world, x-2, z+5),
|
||||
new WorldAreaId(world, x-1, z+5),
|
||||
new WorldAreaId(world, x, z+5),
|
||||
new WorldAreaId(world, x+1, z+5),
|
||||
new WorldAreaId(world, x+2, z+5),
|
||||
};
|
||||
return neighborhood;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeMap;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.metadata.IIOMetadata;
|
||||
import javax.imageio.metadata.IIOMetadataNode;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class CachedMapsFile extends SynchronizedFile {
|
||||
private static final String CACHE_EXT = "cache";
|
||||
private static final byte[] CACHE_SIGNATURE = new byte[] {0x59, 0x4d, 0x50}; // "YMP"
|
||||
private static final int CACHE_VERSION = 1;
|
||||
private static final Logger LOGGER = Logger.getLogger("CachedMapsFile");
|
||||
private final ImageFile imageFile;
|
||||
private final int width;
|
||||
private final int height;
|
||||
private FakeMap[][][] maps;
|
||||
private int delay;
|
||||
|
||||
/**
|
||||
* Create instance from image file
|
||||
* @param imageFile Image file instance
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @return Cached maps instance
|
||||
*/
|
||||
public static @NotNull CachedMapsFile from(@NotNull ImageFile imageFile, int width, int height) {
|
||||
Path cachePath = YamipaPlugin.getInstance().getStorage().getCachePath();
|
||||
Path path = cachePath.resolve(imageFile.getFilename() + "." + width + "-" + height + "." + CACHE_EXT);
|
||||
return new CachedMapsFile(path, imageFile, width, height);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete all cached maps files associated to an image file
|
||||
* @param imageFile Image file instance
|
||||
*/
|
||||
public static void deleteAll(@NotNull ImageFile imageFile) {
|
||||
String relativeFilename = imageFile.getFilename();
|
||||
Path cachePath = YamipaPlugin.getInstance().getStorage().getCachePath();
|
||||
File baseDirectory = cachePath.resolve(relativeFilename).getParent().toFile();
|
||||
String cachePattern = Pattern.quote(Paths.get(relativeFilename).getFileName().toString()) +
|
||||
"\\.[0-9]+-[0-9]+\\." + CACHE_EXT;
|
||||
|
||||
// Find cache files to delete
|
||||
if (!baseDirectory.exists()) {
|
||||
// Cache subdirectory does not exist, no need to delete files
|
||||
return;
|
||||
}
|
||||
File[] files = baseDirectory.listFiles((__, item) -> item.matches(cachePattern));
|
||||
if (files == null) {
|
||||
LOGGER.warning("An error occurred when listing cache files for image \"" + relativeFilename + "\"");
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete disk cache files
|
||||
for (File file : files) {
|
||||
if (!file.delete()) {
|
||||
LOGGER.warning("Failed to delete cache file \"" + file.getAbsolutePath() + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param path Path to cached maps file in disk
|
||||
* @param imageFile Image file associated to these maps
|
||||
* @param width Width in blocks
|
||||
* @param height Height blocks
|
||||
*/
|
||||
private CachedMapsFile(@NotNull Path path, @NotNull ImageFile imageFile, int width, int height) {
|
||||
super(path);
|
||||
this.imageFile = imageFile;
|
||||
this.width = width;
|
||||
this.height = height;
|
||||
load();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maps
|
||||
* @return Tri-dimensional array of maps (column, row, step)
|
||||
*/
|
||||
public @NotNull FakeMap[][][] getMaps() {
|
||||
return maps;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get delay between steps
|
||||
* @return Delay in 50ms intervals or <code>0</code> if not applicable
|
||||
*/
|
||||
public int getDelay() {
|
||||
return delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load maps
|
||||
*/
|
||||
private void load() {
|
||||
// Try to load maps from disk
|
||||
if (exists() && getLastModified() > imageFile.getLastModified()) {
|
||||
LOGGER.fine("Found warm cache file \"" + path + "\"");
|
||||
try {
|
||||
loadFromDisk();
|
||||
return;
|
||||
} catch (IllegalArgumentException e) {
|
||||
LOGGER.info("Cache file \"" + path + "\" is outdated and will be overwritten");
|
||||
} catch (Exception e) {
|
||||
LOGGER.warning("Cache file \"" + path + "\" is corrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate maps from image file
|
||||
LOGGER.fine("Missed cache file \"" + path + "\"");
|
||||
try {
|
||||
generateFromImage();
|
||||
tryToWriteToDisk();
|
||||
return;
|
||||
} catch (Exception e) {
|
||||
LOGGER.severe("Failed to render image step(s) from file \"" + path + "\"", e);
|
||||
}
|
||||
|
||||
// Fallback to error matrix
|
||||
maps = FakeMap.getErrorMatrix(width, height);
|
||||
delay = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load data from disk
|
||||
* @throws IllegalArgumentException if cache file is outdated
|
||||
* @throws IOException if cache file is corrupted
|
||||
*/
|
||||
private void loadFromDisk() throws IllegalArgumentException, IOException {
|
||||
try (RandomAccessFile stream = read()) {
|
||||
// Validate file signature
|
||||
for (byte expectedByte : CACHE_SIGNATURE) {
|
||||
if ((byte) stream.read() != expectedByte) {
|
||||
throw new IllegalArgumentException("Invalid file signature");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate version number
|
||||
if ((byte) stream.read() != CACHE_VERSION) {
|
||||
throw new IllegalArgumentException("Incompatible file format version");
|
||||
}
|
||||
|
||||
// Get number of animation steps
|
||||
int numOfSteps = stream.read() | (stream.read() << 8);
|
||||
if (numOfSteps < 1 || numOfSteps > FakeImage.MAX_STEPS) {
|
||||
throw new IOException("Invalid number of animation steps: " + numOfSteps);
|
||||
}
|
||||
|
||||
// Get delay between steps
|
||||
int delay = 0;
|
||||
if (numOfSteps > 1) {
|
||||
delay = stream.read();
|
||||
if (delay < FakeImage.MIN_DELAY || delay > FakeImage.MAX_DELAY) {
|
||||
throw new IOException("Invalid delay between steps: " + delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Read pixels
|
||||
FakeMap[][][] maps = new FakeMap[width][height][numOfSteps];
|
||||
for (int col=0; col<width; ++col) {
|
||||
for (int row=0; row<height; ++row) {
|
||||
for (int step=0; step<numOfSteps; ++step) {
|
||||
byte[] buffer = new byte[FakeMap.DIMENSION*FakeMap.DIMENSION];
|
||||
stream.read(buffer);
|
||||
maps[col][row][step] = new FakeMap(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update instance state
|
||||
this.maps = maps;
|
||||
this.delay = delay;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate data from image
|
||||
* @throws IOException if an I/O error occurred
|
||||
* @throws RuntimeException if failed to render image steps
|
||||
*/
|
||||
private void generateFromImage() throws IOException, RuntimeException {
|
||||
int widthInPixels = width * FakeMap.DIMENSION;
|
||||
int heightInPixels = height * FakeMap.DIMENSION;
|
||||
|
||||
// Render image steps in Minecraft color palette
|
||||
List<byte[]> renderedImages = new ArrayList<>();
|
||||
Map<Integer, Integer> delays = new HashMap<>();
|
||||
try (ImageInputStream inputStream = ImageIO.createImageInputStream(imageFile.read())) {
|
||||
ImageReader reader = ImageIO.getImageReaders(inputStream).next();
|
||||
reader.setInput(inputStream);
|
||||
String format = reader.getFormatName().toLowerCase();
|
||||
|
||||
// Create temporary canvas
|
||||
int originalWidth = reader.getWidth(0);
|
||||
int originalHeight = reader.getHeight(0);
|
||||
BufferedImage tmpImage = new BufferedImage(originalWidth, originalHeight, BufferedImage.TYPE_4BYTE_ABGR);
|
||||
Graphics2D tmpGraphics = tmpImage.createGraphics();
|
||||
tmpGraphics.setBackground(new Color(0, 0, 0, 0));
|
||||
|
||||
// Create temporary scaled canvas (for resizing)
|
||||
BufferedImage tmpScaledImage = new BufferedImage(widthInPixels, heightInPixels, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D tmpScaledGraphics = tmpScaledImage.createGraphics();
|
||||
tmpScaledGraphics.setBackground(new Color(0, 0, 0, 0));
|
||||
|
||||
// Read images from file
|
||||
for (int step=0; step<FakeImage.MAX_STEPS; ++step) {
|
||||
try {
|
||||
// Extract step metadata
|
||||
int imageLeft = 0;
|
||||
int imageTop = 0;
|
||||
boolean disposePrevious = false;
|
||||
if (format.equals("gif")) {
|
||||
IIOMetadata metadata = reader.getImageMetadata(step);
|
||||
String formatName = metadata.getNativeMetadataFormatName();
|
||||
IIOMetadataNode metadataRoot = (IIOMetadataNode) metadata.getAsTree(formatName);
|
||||
for (int i=0; i<metadataRoot.getLength(); ++i) {
|
||||
String nodeName = metadataRoot.item(i).getNodeName();
|
||||
if (nodeName.equalsIgnoreCase("ImageDescriptor")) {
|
||||
IIOMetadataNode descriptorNode = (IIOMetadataNode) metadataRoot.item(i);
|
||||
imageLeft = Integer.parseInt(descriptorNode.getAttribute("imageLeftPosition"));
|
||||
imageTop = Integer.parseInt(descriptorNode.getAttribute("imageTopPosition"));
|
||||
} else if (nodeName.equalsIgnoreCase("GraphicControlExtension")) {
|
||||
IIOMetadataNode controlExtensionNode = (IIOMetadataNode) metadataRoot.item(i);
|
||||
int delay = Integer.parseInt(controlExtensionNode.getAttribute("delayTime"));
|
||||
delays.compute(delay, (__, count) -> (count == null) ? 1 : count + 1);
|
||||
disposePrevious = controlExtensionNode.getAttribute("disposalMethod").startsWith("restore");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear temporary canvases (if needed)
|
||||
if (disposePrevious) {
|
||||
tmpGraphics.clearRect(0, 0, originalWidth, originalHeight);
|
||||
tmpScaledGraphics.clearRect(0, 0, widthInPixels, heightInPixels);
|
||||
}
|
||||
|
||||
// Paint step image over temporary canvas
|
||||
BufferedImage image = reader.read(step);
|
||||
tmpGraphics.drawImage(image, imageLeft, imageTop, null);
|
||||
image.flush();
|
||||
|
||||
// Resize image and get pixels
|
||||
tmpScaledGraphics.drawImage(tmpImage, 0, 0, widthInPixels, heightInPixels, null);
|
||||
int[] rgbaPixels = ((DataBufferInt) tmpScaledImage.getRaster().getDataBuffer()).getData();
|
||||
|
||||
// Convert RGBA pixels to Minecraft color indexes
|
||||
byte[] renderedImage = new byte[widthInPixels * heightInPixels];
|
||||
IntStream.range(0, rgbaPixels.length)
|
||||
.parallel()
|
||||
.forEach(pixelIndex -> renderedImage[pixelIndex] = FakeMap.pixelToIndex(rgbaPixels[pixelIndex]));
|
||||
renderedImages.add(renderedImage);
|
||||
} catch (IndexOutOfBoundsException __) {
|
||||
// No more steps to read
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Free resources
|
||||
reader.dispose();
|
||||
tmpGraphics.dispose();
|
||||
tmpImage.flush();
|
||||
tmpScaledGraphics.dispose();
|
||||
tmpScaledImage.flush();
|
||||
}
|
||||
|
||||
// Get most occurring delay (mode)
|
||||
int delay = 0;
|
||||
if (renderedImages.size() > 1) {
|
||||
delay = Collections.max(delays.entrySet(), Map.Entry.comparingByValue()).getKey();
|
||||
delay = Math.round(delay * 0.2f); // (delay * 10) / 50
|
||||
delay = Math.min(Math.max(delay, FakeImage.MIN_DELAY), FakeImage.MAX_DELAY);
|
||||
}
|
||||
|
||||
// Instantiate fake maps from image steps
|
||||
FakeMap[][][] maps = new FakeMap[width][height][renderedImages.size()];
|
||||
IntStream.range(0, renderedImages.size()).forEach(i -> {
|
||||
for (int col=0; col<width; col++) {
|
||||
for (int row=0; row<height; row++) {
|
||||
maps[col][row][i] = new FakeMap(
|
||||
renderedImages.get(i),
|
||||
widthInPixels,
|
||||
col*FakeMap.DIMENSION,
|
||||
row*FakeMap.DIMENSION
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Update instance state
|
||||
this.maps = maps;
|
||||
this.delay = delay;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to write data to disk
|
||||
*/
|
||||
private void tryToWriteToDisk() {
|
||||
mkdirs();
|
||||
try (RandomAccessFile stream = write()) {
|
||||
int numOfSteps = maps[0][0].length;
|
||||
|
||||
// Add file header
|
||||
stream.write(CACHE_SIGNATURE); // "YMP" signature
|
||||
stream.write(CACHE_VERSION); // Format version
|
||||
stream.write(numOfSteps & 0xff); // Number of animation steps (first byte)
|
||||
stream.write((numOfSteps >> 8) & 0xff); // Number of animation steps (second byte)
|
||||
if (numOfSteps > 1) {
|
||||
stream.write(delay);
|
||||
}
|
||||
|
||||
// Add pixels
|
||||
//noinspection ForLoopReplaceableByForEach
|
||||
for (int col=0; col<maps.length; ++col) {
|
||||
for (int row=0; row<maps[0].length; ++row) {
|
||||
for (int step=0; step<numOfSteps; ++step) {
|
||||
stream.write(maps[col][row][step].getPixels());
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Failed to write to cache file \"" + path + "\"", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import com.sun.nio.file.ExtendedWatchEventModifier;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.nio.file.attribute.BasicFileAttributes;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Service for detecting file events inside a given directory.
|
||||
* <p>
|
||||
* It supports recursive storage (<i>e.g.,</i> nested directories) and watches for file system changes in realtime
|
||||
* when supported by the OS.
|
||||
*/
|
||||
public abstract class FileSystemWatcher {
|
||||
private static final int MAX_DEPTH = 32;
|
||||
private static final int POLLING_INTERVAL = 4000;
|
||||
private static final String PROBE_FILENAME = ".inotify_test";
|
||||
private static final Logger LOGGER = Logger.getLogger("FileSystemWatcher");
|
||||
protected final Path basePath;
|
||||
/** Map of existing directories with the files they contain and their last modification timestamps */
|
||||
private final SortedMap<Path, Map<Path, Long>> fileTree = new TreeMap<>();
|
||||
private @Nullable Thread watcherThread;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param basePath Base path
|
||||
*/
|
||||
public FileSystemWatcher(@NotNull Path basePath) {
|
||||
this.basePath = basePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start watcher
|
||||
* @throws RuntimeException if failed to start
|
||||
*/
|
||||
protected void start() throws RuntimeException {
|
||||
// Prevent initializing more than once
|
||||
if (watcherThread != null) {
|
||||
throw new RuntimeException("File system watcher is already running");
|
||||
}
|
||||
|
||||
// Perform initial scan
|
||||
scan();
|
||||
|
||||
// Start watching files
|
||||
watcherThread = new WatcherThread();
|
||||
watcherThread.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop watcher
|
||||
*/
|
||||
protected void stop() {
|
||||
if (watcherThread != null) {
|
||||
watcherThread.interrupt();
|
||||
watcherThread = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On file created
|
||||
* @param path File path
|
||||
*/
|
||||
protected abstract void onFileCreated(@NotNull Path path);
|
||||
|
||||
/**
|
||||
* On file modified
|
||||
* @param path File path
|
||||
*/
|
||||
protected abstract void onFileModified(@NotNull Path path);
|
||||
|
||||
/**
|
||||
* On file deleted
|
||||
* @param path File path
|
||||
*/
|
||||
protected abstract void onFileDeleted(@NotNull Path path);
|
||||
|
||||
/**
|
||||
* Scan base directory recursively
|
||||
*/
|
||||
private void scan() {
|
||||
synchronized (fileTree) {
|
||||
// Assume all directories and files have been deleted, will discard existing items afterward
|
||||
Set<Path> deletedDirectories = new HashSet<>(fileTree.keySet());
|
||||
Set<Path> deletedFiles = fileTree.values().stream()
|
||||
.map(Map::keySet)
|
||||
.flatMap(Set::stream)
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
// Traverse file tree
|
||||
Set<FileVisitOption> options = EnumSet.noneOf(FileVisitOption.class);
|
||||
try {
|
||||
Files.walkFileTree(basePath, options, MAX_DEPTH, new SimpleFileVisitor<Path>() {
|
||||
@Override
|
||||
public FileVisitResult preVisitDirectory(Path path, BasicFileAttributes attrs) {
|
||||
fileTree.putIfAbsent(path, new HashMap<>());
|
||||
deletedDirectories.remove(path);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileVisitResult visitFile(Path path, BasicFileAttributes attrs) {
|
||||
Map<Path, Long> subtree = fileTree.get(path.getParent());
|
||||
Long oldModifiedAt = subtree.get(path);
|
||||
long newModifiedAt = attrs.lastModifiedTime().toMillis();
|
||||
if (oldModifiedAt == null) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileCreated(path);
|
||||
} else if (newModifiedAt > oldModifiedAt) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileModified(path);
|
||||
}
|
||||
deletedFiles.remove(path);
|
||||
return FileVisitResult.CONTINUE;
|
||||
}
|
||||
});
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Failed to list files in directory", e);
|
||||
}
|
||||
|
||||
// Process deleted files and directories
|
||||
for (Path path : deletedFiles) {
|
||||
fileTree.get(path.getParent()).remove(path);
|
||||
onFileDeleted(path);
|
||||
}
|
||||
for (Path path : deletedDirectories) {
|
||||
fileTree.remove(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class WatcherThread extends Thread {
|
||||
private final boolean IS_WINDOWS = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win");
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (isInotifySupported()) {
|
||||
runWithFileSystemEvents();
|
||||
} else {
|
||||
LOGGER.warning("Device does not support inotify, detection of file changes will be slower");
|
||||
runWithPolling();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Is inotify supported
|
||||
* @return Whether inotify is supported
|
||||
*/
|
||||
@SuppressWarnings("ResultOfMethodCallIgnored")
|
||||
private boolean isInotifySupported() {
|
||||
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
|
||||
// Start listening for events
|
||||
basePath.register(
|
||||
watchService,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY
|
||||
);
|
||||
|
||||
// Create and delete probe directory
|
||||
File testFile = basePath.resolve(PROBE_FILENAME).toFile();
|
||||
testFile.mkdir();
|
||||
testFile.delete();
|
||||
|
||||
// Check that at least one event was emitted
|
||||
WatchKey watchKey = watchService.poll();
|
||||
return (watchKey != null && !watchKey.pollEvents().isEmpty());
|
||||
} catch (IOException __) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run with polling
|
||||
*/
|
||||
@SuppressWarnings({"InfiniteLoopStatement", "BusyWait"})
|
||||
private void runWithPolling() {
|
||||
try {
|
||||
while (true) {
|
||||
Thread.sleep(POLLING_INTERVAL);
|
||||
scan();
|
||||
}
|
||||
} catch (InterruptedException __) {
|
||||
// Silently ignore exception, this is expected when service shuts down
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run with file system events
|
||||
*/
|
||||
private void runWithFileSystemEvents() {
|
||||
try (WatchService watchService = FileSystems.getDefault().newWatchService()) {
|
||||
// Register initial directories
|
||||
synchronized (fileTree) {
|
||||
for (Path path : fileTree.keySet()) {
|
||||
registerDirectory(watchService, path);
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for events
|
||||
WatchKey key;
|
||||
try {
|
||||
while ((key = watchService.take()) != null) {
|
||||
for (WatchEvent<?> event : key.pollEvents()) {
|
||||
WatchEvent.Kind<?> kind = event.kind();
|
||||
Path keyPath = (Path) key.watchable();
|
||||
Path path = keyPath.resolve((Path) event.context());
|
||||
handleWatchEvent(watchService, path, kind);
|
||||
}
|
||||
key.reset();
|
||||
}
|
||||
} catch (ClosedWatchServiceException | InterruptedException __) {
|
||||
// Silently ignore exception, this is expected when service shuts down
|
||||
}
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Unexpected error at watch service", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle watch event
|
||||
* @param watchService Watch service
|
||||
* @param path File or directory path
|
||||
* @param kind Event kind
|
||||
*/
|
||||
private void handleWatchEvent(@NotNull WatchService watchService, @NotNull Path path, @NotNull WatchEvent.Kind<?> kind) {
|
||||
synchronized (fileTree) {
|
||||
Map<Path, Long> subtree = fileTree.computeIfAbsent(path.getParent(), k -> new HashMap<>());
|
||||
|
||||
// Handle deletion of files and directories
|
||||
if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
|
||||
if (subtree.containsKey(path)) {
|
||||
subtree.remove(path);
|
||||
onFileDeleted(path);
|
||||
} else {
|
||||
unregisterDirectory(path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle creation of directories
|
||||
if (path.toFile().isDirectory()) {
|
||||
if (kind == StandardWatchEventKinds.ENTRY_CREATE) {
|
||||
registerDirectory(watchService, path);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle creation and modification of files
|
||||
// NOTE: in Windows, some file creation events are reported as modifications
|
||||
Long oldModifiedAt = subtree.get(path);
|
||||
long newModifiedAt = path.toFile().lastModified();
|
||||
if (oldModifiedAt == null) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileCreated(path);
|
||||
} else if (newModifiedAt > oldModifiedAt) {
|
||||
subtree.put(path, newModifiedAt);
|
||||
onFileModified(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register directory
|
||||
* @param watchService Watch service
|
||||
* @param path Directory path
|
||||
*/
|
||||
private void registerDirectory(@NotNull WatchService watchService, @NotNull Path path) {
|
||||
// Windows supports listing to events in the entire file tree,
|
||||
// in that case only allow registering the listener on the base path
|
||||
if (IS_WINDOWS && !path.equals(basePath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Start watching directory for events
|
||||
try {
|
||||
WatchEvent.Kind<?>[] events = new WatchEvent.Kind[]{
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY
|
||||
};
|
||||
WatchEvent.Modifier[] modifiers = IS_WINDOWS ?
|
||||
new WatchEvent.Modifier[]{ExtendedWatchEventModifier.FILE_TREE} :
|
||||
new WatchEvent.Modifier[0];
|
||||
path.register(watchService, events, modifiers);
|
||||
LOGGER.fine("Started watching directory at \"" + path + "\"");
|
||||
} catch (IOException e) {
|
||||
LOGGER.severe("Failed to register directory", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unregister directory
|
||||
* @param path Directory path
|
||||
*/
|
||||
private void unregisterDirectory(@NotNull Path path) {
|
||||
synchronized (fileTree) {
|
||||
if (!fileTree.containsKey(path)) {
|
||||
// Already unregistered, can skip work
|
||||
return;
|
||||
}
|
||||
boolean foundFirst = false;
|
||||
Iterator<Map.Entry<Path, Map<Path, Long>>> iter = fileTree.entrySet().iterator();
|
||||
while (iter.hasNext()) {
|
||||
Map.Entry<Path, Map<Path, Long>> entry = iter.next();
|
||||
if (entry.getKey().startsWith(path)) {
|
||||
for (Path childPath : entry.getValue().keySet()) {
|
||||
onFileDeleted(childPath);
|
||||
}
|
||||
foundFirst = true;
|
||||
iter.remove();
|
||||
} else if (foundFirst) {
|
||||
// We can break early because set is alphabetically sorted by key
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,204 +1,77 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import com.comphenix.protocol.wrappers.Pair;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeImage;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeMap;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeMapsContainer;
|
||||
import io.josemmo.bukkit.plugin.renderer.FakeItemFrame;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import javax.imageio.ImageIO;
|
||||
import javax.imageio.ImageReader;
|
||||
import javax.imageio.metadata.IIOMetadata;
|
||||
import javax.imageio.metadata.IIOMetadataNode;
|
||||
import javax.imageio.stream.ImageInputStream;
|
||||
import java.awt.*;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.awt.image.DataBufferInt;
|
||||
import java.io.File;
|
||||
import java.io.FileInputStream;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.*;
|
||||
import java.util.List;
|
||||
import java.nio.file.Path;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.locks.Lock;
|
||||
import java.util.concurrent.locks.ReentrantLock;
|
||||
import java.util.logging.Level;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.IntStream;
|
||||
|
||||
public class ImageFile {
|
||||
public static final String CACHE_EXT = "cache";
|
||||
public static final byte[] CACHE_SIGNATURE = new byte[] {0x59, 0x4d, 0x50}; // "YMP"
|
||||
public static final int CACHE_VERSION = 1;
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
public class ImageFile extends SynchronizedFile {
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageFile");
|
||||
private final ConcurrentHashMap<String, Lock> locks = new ConcurrentHashMap<>();
|
||||
private final Map<String, FakeMapsContainer> cache = new HashMap<>();
|
||||
private final Map<String, CachedMapsFile> cache = new HashMap<>();
|
||||
private final Map<String, Set<FakeImage>> subscribers = new HashMap<>();
|
||||
private final String name;
|
||||
private final String path;
|
||||
private final String filename;
|
||||
private @Nullable Dimension size;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param name Image file name
|
||||
* @param path Path to image file
|
||||
* @param filename Image filename
|
||||
* @param path Path to image file
|
||||
*/
|
||||
protected ImageFile(@NotNull String name, @NotNull String path) {
|
||||
this.name = name;
|
||||
this.path = path;
|
||||
protected ImageFile(@NotNull String filename, @NotNull Path path) {
|
||||
super(path);
|
||||
this.filename = filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image file name
|
||||
* @return Image file name
|
||||
* Get image filename
|
||||
* @return Image filename
|
||||
*/
|
||||
public @NotNull String getName() {
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image reader
|
||||
* @return Image reader instance
|
||||
* @throws IOException if failed to get suitable image reader
|
||||
*/
|
||||
private @NotNull ImageReader getImageReader() throws IOException {
|
||||
ImageInputStream inputStream = ImageIO.createImageInputStream(new File(path));
|
||||
ImageReader reader = ImageIO.getImageReaders(inputStream).next();
|
||||
reader.setInput(inputStream);
|
||||
return reader;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render images using Minecraft palette
|
||||
* @param width New width in pixels
|
||||
* @param height New height in pixels
|
||||
* @return Pair of bi-dimensional array of Minecraft images (step, pixel index) and delay between steps
|
||||
* @throws IOException if failed to render images from file
|
||||
*/
|
||||
private @NotNull Pair<byte[][], Integer> renderImages(int width, int height) throws IOException {
|
||||
ImageReader reader = getImageReader();
|
||||
String format = reader.getFormatName().toLowerCase();
|
||||
|
||||
// Create temporary canvas
|
||||
int originalWidth = reader.getWidth(0);
|
||||
int originalHeight = reader.getHeight(0);
|
||||
BufferedImage tmpImage = new BufferedImage(originalWidth, originalHeight, BufferedImage.TYPE_4BYTE_ABGR);
|
||||
Graphics2D tmpGraphics = tmpImage.createGraphics();
|
||||
tmpGraphics.setBackground(new Color(0, 0, 0, 0));
|
||||
|
||||
// Create temporary scaled canvas (for resizing)
|
||||
BufferedImage tmpScaledImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
|
||||
Graphics2D tmpScaledGraphics = tmpScaledImage.createGraphics();
|
||||
tmpScaledGraphics.setBackground(new Color(0, 0, 0, 0));
|
||||
|
||||
// Read images from file
|
||||
List<byte[]> renderedImages = new ArrayList<>();
|
||||
Map<Integer, Integer> delays = new HashMap<>();
|
||||
for (int step=0; step<FakeImage.MAX_STEPS; ++step) {
|
||||
try {
|
||||
// Extract step metadata
|
||||
int imageLeft = 0;
|
||||
int imageTop = 0;
|
||||
boolean disposePrevious = false;
|
||||
if (format.equals("gif")) {
|
||||
IIOMetadata metadata = reader.getImageMetadata(step);
|
||||
IIOMetadataNode metadataRoot = (IIOMetadataNode) metadata.getAsTree(metadata.getNativeMetadataFormatName());
|
||||
for (int i=0; i<metadataRoot.getLength(); ++i) {
|
||||
String nodeName = metadataRoot.item(i).getNodeName();
|
||||
if (nodeName.equalsIgnoreCase("ImageDescriptor")) {
|
||||
IIOMetadataNode descriptorNode = (IIOMetadataNode) metadataRoot.item(i);
|
||||
imageLeft = Integer.parseInt(descriptorNode.getAttribute("imageLeftPosition"));
|
||||
imageTop = Integer.parseInt(descriptorNode.getAttribute("imageTopPosition"));
|
||||
} else if (nodeName.equalsIgnoreCase("GraphicControlExtension")) {
|
||||
IIOMetadataNode controlExtensionNode = (IIOMetadataNode) metadataRoot.item(i);
|
||||
int delay = Integer.parseInt(controlExtensionNode.getAttribute("delayTime"));
|
||||
delays.compute(delay, (__, count) -> (count == null) ? 1 : count + 1);
|
||||
disposePrevious = controlExtensionNode.getAttribute("disposalMethod").startsWith("restore");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear temporary canvases (if needed)
|
||||
if (disposePrevious) {
|
||||
tmpGraphics.clearRect(0, 0, originalWidth, originalHeight);
|
||||
tmpScaledGraphics.clearRect(0, 0, width, height);
|
||||
}
|
||||
|
||||
// Paint step image over temporary canvas
|
||||
BufferedImage image = reader.read(step);
|
||||
tmpGraphics.drawImage(image, imageLeft, imageTop, null);
|
||||
image.flush();
|
||||
|
||||
// Resize image and get pixels
|
||||
tmpScaledGraphics.drawImage(tmpImage, 0, 0, width, height, null);
|
||||
int[] rgbaPixels = ((DataBufferInt) tmpScaledImage.getRaster().getDataBuffer()).getData();
|
||||
|
||||
// Convert RGBA pixels to Minecraft color indexes
|
||||
byte[] renderedImage = new byte[width*height];
|
||||
IntStream.range(0, rgbaPixels.length).parallel().forEach(pixelIndex -> {
|
||||
renderedImage[pixelIndex] = FakeMap.pixelToIndex(rgbaPixels[pixelIndex]);
|
||||
});
|
||||
renderedImages.add(renderedImage);
|
||||
} catch (IndexOutOfBoundsException __) {
|
||||
// No more steps to read
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Get most occurring delay (mode)
|
||||
int delay = 0;
|
||||
if (renderedImages.size() > 1) {
|
||||
delay = Collections.max(delays.entrySet(), Map.Entry.comparingByValue()).getKey();
|
||||
delay = Math.round(delay * 0.2f); // (delay * 10) / 50
|
||||
delay = Math.min(Math.max(delay, FakeImage.MIN_DELAY), FakeImage.MAX_DELAY);
|
||||
}
|
||||
|
||||
// Free resources
|
||||
reader.dispose();
|
||||
tmpGraphics.dispose();
|
||||
tmpImage.flush();
|
||||
tmpScaledGraphics.dispose();
|
||||
tmpScaledImage.flush();
|
||||
|
||||
return new Pair<>(renderedImages.toArray(new byte[0][0]), delay);
|
||||
public @NotNull String getFilename() {
|
||||
return filename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get original size in pixels
|
||||
* @return Dimension instance or NULL if not a valid image file
|
||||
*/
|
||||
public @Nullable Dimension getSize() {
|
||||
try {
|
||||
ImageReader reader = getImageReader();
|
||||
Dimension dimension = new Dimension(reader.getWidth(0), reader.getHeight(0));
|
||||
public synchronized @Nullable Dimension getSize() {
|
||||
if (size != null) {
|
||||
return size;
|
||||
}
|
||||
try (ImageInputStream inputStream = ImageIO.createImageInputStream(read())) {
|
||||
ImageReader reader = ImageIO.getImageReaders(inputStream).next();
|
||||
reader.setInput(inputStream);
|
||||
size = new Dimension(reader.getWidth(0), reader.getHeight(0));
|
||||
reader.dispose();
|
||||
return dimension;
|
||||
} catch (Exception e) {
|
||||
return size;
|
||||
} catch (IOException | RuntimeException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image last modified time
|
||||
* @return Last modified time in milliseconds, zero in case of error
|
||||
*/
|
||||
public long getLastModified() {
|
||||
try {
|
||||
return Files.getLastModifiedTime(Paths.get(path)).toMillis();
|
||||
} catch (Exception __) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maps and subscribe to maps cache
|
||||
* Get maps and subscribe to them
|
||||
* @param subscriber Fake image instance requesting the maps
|
||||
* @return Fake maps container
|
||||
* @return Cached maps
|
||||
*/
|
||||
public @NotNull FakeMapsContainer getMapsAndSubscribe(@NotNull FakeImage subscriber) {
|
||||
@Blocking
|
||||
public @NotNull CachedMapsFile getMapsAndSubscribe(@NotNull FakeImage subscriber) {
|
||||
int width = subscriber.getWidth();
|
||||
int height = subscriber.getHeight();
|
||||
String cacheKey = width + "-" + height;
|
||||
@@ -207,191 +80,38 @@ public class ImageFile {
|
||||
Lock lock = locks.computeIfAbsent(cacheKey, __ -> new ReentrantLock());
|
||||
lock.lock();
|
||||
|
||||
// Execute code
|
||||
FakeMapsContainer container;
|
||||
try {
|
||||
container = getMapsAndSubscribe(subscriber, cacheKey, width, height);
|
||||
} finally {
|
||||
// Get cached maps without locking this instance
|
||||
CachedMapsFile maps = cache.get(cacheKey);
|
||||
if (maps == null) {
|
||||
maps = CachedMapsFile.from(this, width, height);
|
||||
}
|
||||
|
||||
// Update state of this instance
|
||||
synchronized (this) {
|
||||
if (cache.get(cacheKey) != maps) {
|
||||
cache.put(cacheKey, maps);
|
||||
}
|
||||
subscribers.computeIfAbsent(cacheKey, __ -> new HashSet<>()).add(subscriber);
|
||||
lock.unlock();
|
||||
}
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get maps and subscribe to maps cache (internal)
|
||||
* @param subscriber Fake image instance requesting the maps
|
||||
* @param cacheKey Maps cache key
|
||||
* @param width Desired image width in pixels
|
||||
* @param height Desired image height in pixels
|
||||
* @return Fake maps container
|
||||
*/
|
||||
private @NotNull FakeMapsContainer getMapsAndSubscribe(
|
||||
@NotNull FakeImage subscriber,
|
||||
@NotNull String cacheKey,
|
||||
int width,
|
||||
int height
|
||||
) {
|
||||
// Update subscribers for given cached maps
|
||||
subscribers.computeIfAbsent(cacheKey, __ -> new HashSet<>()).add(subscriber);
|
||||
|
||||
// Try to get maps from memory cache
|
||||
if (cache.containsKey(cacheKey)) {
|
||||
return cache.get(cacheKey);
|
||||
}
|
||||
|
||||
// Try to get maps from disk cache
|
||||
String cacheFilename = name + "." + cacheKey + "." + CACHE_EXT;
|
||||
File cacheFile = Paths.get(plugin.getStorage().getCachePath(), cacheFilename).toFile();
|
||||
if (cacheFile.isFile() && cacheFile.lastModified() >= getLastModified()) {
|
||||
try {
|
||||
FakeMapsContainer container = readMapsFromCacheFile(cacheFile, width, height);
|
||||
cache.put(cacheKey, container);
|
||||
return container;
|
||||
} catch (IllegalArgumentException e) {
|
||||
plugin.info("Cache file \"" + cacheFile.getAbsolutePath() + "\" is outdated and will be overwritten");
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.WARNING, "Cache file \"" + cacheFile.getAbsolutePath() + "\" is corrupted", e);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate maps from original image
|
||||
FakeMapsContainer container;
|
||||
try {
|
||||
int widthInPixels = width*FakeMap.DIMENSION;
|
||||
int heightInPixels = height*FakeMap.DIMENSION;
|
||||
Pair<byte[][], Integer> res = renderImages(widthInPixels, heightInPixels);
|
||||
byte[][] images = res.getFirst();
|
||||
int delay = res.getSecond();
|
||||
|
||||
// Instantiate fake maps
|
||||
FakeMap[][][] matrix = new FakeMap[width][height][images.length];
|
||||
IntStream.range(0, images.length).forEach(i -> {
|
||||
for (int col=0; col<width; col++) {
|
||||
for (int row=0; row<height; row++) {
|
||||
matrix[col][row][i] = new FakeMap(
|
||||
images[i],
|
||||
widthInPixels,
|
||||
col*FakeMap.DIMENSION,
|
||||
row*FakeMap.DIMENSION
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Persist in disk cache
|
||||
container = new FakeMapsContainer(matrix, delay);
|
||||
try {
|
||||
writeMapsToCacheFile(container, cacheFile);
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.SEVERE, "Failed to write to cache file \"" + cacheFile.getAbsolutePath() + "\"", e);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
container = FakeMap.getErrorMatrix(width, height);
|
||||
plugin.log(Level.SEVERE, "Failed to render image(s) from file \"" + path + "\"", e);
|
||||
}
|
||||
|
||||
// Persist in memory cache and return
|
||||
cache.put(cacheKey, container);
|
||||
return container;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read maps from cache file
|
||||
* @param file Cache file
|
||||
* @param width Width in blocks
|
||||
* @param height Height in blocks
|
||||
* @return Fake maps container
|
||||
* @throws IllegalArgumentException if not a valid or outdated cache file
|
||||
* @throws IOException if failed to parse cache file
|
||||
*/
|
||||
private @NotNull FakeMapsContainer readMapsFromCacheFile(@NotNull File file, int width, int height) throws Exception {
|
||||
try (FileInputStream stream = new FileInputStream(file)) {
|
||||
// Validate file signature
|
||||
for (byte expectedByte : CACHE_SIGNATURE) {
|
||||
if ((byte) stream.read() != expectedByte) {
|
||||
throw new IllegalArgumentException("Invalid file signature");
|
||||
}
|
||||
}
|
||||
|
||||
// Validate version number
|
||||
if ((byte) stream.read() != CACHE_VERSION) {
|
||||
throw new IllegalArgumentException("Incompatible file format version");
|
||||
}
|
||||
|
||||
// Get number of animation steps
|
||||
int numOfSteps = stream.read() | (stream.read() << 8);
|
||||
if (numOfSteps < 1 || numOfSteps > FakeImage.MAX_STEPS) {
|
||||
throw new IOException("Invalid number of animation steps: " + numOfSteps);
|
||||
}
|
||||
|
||||
// Get delay between steps
|
||||
int delay = 0;
|
||||
if (numOfSteps > 1) {
|
||||
delay = stream.read();
|
||||
if (delay < FakeImage.MIN_DELAY || delay > FakeImage.MAX_DELAY) {
|
||||
throw new IOException("Invalid delay between steps: " + delay);
|
||||
}
|
||||
}
|
||||
|
||||
// Read pixels
|
||||
FakeMap[][][] maps = new FakeMap[width][height][numOfSteps];
|
||||
for (int col=0; col<width; ++col) {
|
||||
for (int row=0; row<height; ++row) {
|
||||
for (int step=0; step<numOfSteps; ++step) {
|
||||
byte[] buffer = new byte[FakeMap.DIMENSION*FakeMap.DIMENSION];
|
||||
stream.read(buffer);
|
||||
maps[col][row][step] = new FakeMap(buffer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new FakeMapsContainer(maps, delay);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write maps to cache file
|
||||
* @param container Fake maps container
|
||||
* @param file Cache file
|
||||
* @throws IOException if failed to write to cache file
|
||||
*/
|
||||
private void writeMapsToCacheFile(@NotNull FakeMapsContainer container, @NotNull File file) throws IOException {
|
||||
try (FileOutputStream stream = new FileOutputStream(file)) {
|
||||
FakeMap[][][] maps = container.getFakeMaps();
|
||||
int numOfSteps = maps[0][0].length;
|
||||
|
||||
// Add file header
|
||||
stream.write(CACHE_SIGNATURE); // "YMP" signature
|
||||
stream.write(CACHE_VERSION); // Format version
|
||||
stream.write(numOfSteps & 0xff); // Number of animation steps (first byte)
|
||||
stream.write((numOfSteps >> 8) & 0xff); // Number of animation steps (second byte)
|
||||
if (numOfSteps > 1) {
|
||||
stream.write(container.getDelay());
|
||||
}
|
||||
|
||||
// Add pixels
|
||||
for (int col=0; col<maps.length; ++col) {
|
||||
for (int row=0; row<maps[0].length; ++row) {
|
||||
for (int step=0; step<numOfSteps; ++step) {
|
||||
stream.write(maps[col][row][step].getPixels());
|
||||
}
|
||||
}
|
||||
}
|
||||
locks.remove(cacheKey);
|
||||
return maps;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from memory cache
|
||||
* <p>
|
||||
* This method is called by FakeImage instances when they get invalidated by a WorldArea.
|
||||
* By notifying their respective source ImageFile, the latter can clear cached maps from memory when no more
|
||||
* FakeItemFrames are using them.
|
||||
* This method is called by {@link FakeImage} instances when they get invalidated by a world area change.
|
||||
* By notifying their respective source {@link ImageFile}, the latter can clear cached maps from memory when no
|
||||
* more {@link FakeItemFrame}s are using them.
|
||||
* @param subscriber Fake image instance
|
||||
*/
|
||||
public synchronized void unsubscribe(@NotNull FakeImage subscriber) {
|
||||
String cacheKey = subscriber.getWidth() + "-" + subscriber.getHeight();
|
||||
if (!subscribers.containsKey(cacheKey)) return;
|
||||
if (!subscribers.containsKey(cacheKey)) {
|
||||
// Not subscribed to this image file
|
||||
return;
|
||||
}
|
||||
|
||||
// Remove subscriber
|
||||
Set<FakeImage> currentSubscribers = subscribers.get(cacheKey);
|
||||
@@ -401,31 +121,19 @@ public class ImageFile {
|
||||
if (currentSubscribers.isEmpty()) {
|
||||
subscribers.remove(cacheKey);
|
||||
cache.remove(cacheKey);
|
||||
plugin.fine("Invalidated cached maps \"" + cacheKey + "\" in ImageFile#(" + name + ")");
|
||||
LOGGER.fine("Invalidated cached maps \"" + cacheKey + "\" in ImageFile#(" + filename + ")");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate cache
|
||||
* <p>
|
||||
* Removes all references to pre-generated map sets.
|
||||
* Removes all references to cached map instances.
|
||||
* This way, next time an image is requested to be rendered, maps will be regenerated.
|
||||
*/
|
||||
public synchronized void invalidate() {
|
||||
size = null;
|
||||
cache.clear();
|
||||
|
||||
// Delete disk cache files
|
||||
File[] files = Paths.get(plugin.getStorage().getCachePath()).toFile().listFiles((__, filename) -> {
|
||||
return filename.matches(Pattern.quote(name) + "\\.[0-9]+-[0-9]+\\." + CACHE_EXT);
|
||||
});
|
||||
if (files == null) {
|
||||
plugin.warning("An error occurred when listing cache files for image \"" + name + "\"");
|
||||
return;
|
||||
}
|
||||
for (File file : files) {
|
||||
if (!file.delete()) {
|
||||
plugin.warning("Failed to delete cache file \"" + file.getAbsolutePath() + "\"");
|
||||
}
|
||||
}
|
||||
CachedMapsFile.deleteAll(this);
|
||||
}
|
||||
}
|
||||
@@ -1,42 +1,48 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import io.josemmo.bukkit.plugin.utils.Logger;
|
||||
import io.josemmo.bukkit.plugin.utils.Permissions;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.*;
|
||||
import java.util.Objects;
|
||||
import java.util.SortedMap;
|
||||
import java.util.TreeMap;
|
||||
import java.util.logging.Level;
|
||||
import java.util.*;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.regex.PatternSyntaxException;
|
||||
|
||||
public class ImageStorage {
|
||||
static public final long POLLING_INTERVAL = 20L * 5; // In server ticks
|
||||
static private final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private final String basePath;
|
||||
private final String cachePath;
|
||||
private final SortedMap<String, ImageFile> cachedImages = new TreeMap<>();
|
||||
private BukkitTask task;
|
||||
private WatchService watchService;
|
||||
/**
|
||||
* Service for keeping track of image images.
|
||||
* <p>
|
||||
* All files are indexed based on their <b>filename</b>.
|
||||
* Due to recursion, filenames can contain forward slashes (<i>i.e.,</i> "/") and act as relative paths to the base
|
||||
* directory.
|
||||
*/
|
||||
public class ImageStorage extends FileSystemWatcher {
|
||||
private static final Logger LOGGER = Logger.getLogger("ImageStorage");
|
||||
/** Map of registered files indexed by filename */
|
||||
private final SortedMap<String, ImageFile> files = new TreeMap<>();
|
||||
private final Path cachePath;
|
||||
private final String allowedPaths;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param basePath Path to directory containing the images
|
||||
* @param cachePath Path to directory containing the cached image maps
|
||||
* @param basePath Path to directory containing the images
|
||||
* @param cachePath Path to directory containing the cached image maps
|
||||
* @param allowedPaths Allowed paths pattern
|
||||
*/
|
||||
public ImageStorage(@NotNull String basePath, @NotNull String cachePath) {
|
||||
this.basePath = basePath;
|
||||
public ImageStorage(@NotNull Path basePath, @NotNull Path cachePath, @NotNull String allowedPaths) {
|
||||
super(basePath);
|
||||
this.cachePath = cachePath;
|
||||
this.allowedPaths = allowedPaths;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get base path
|
||||
* @return Base path
|
||||
*/
|
||||
public @NotNull String getBasePath() {
|
||||
public @NotNull Path getBasePath() {
|
||||
return basePath;
|
||||
}
|
||||
|
||||
@@ -44,94 +50,35 @@ public class ImageStorage {
|
||||
* Get cache path
|
||||
* @return Cache path
|
||||
*/
|
||||
public @NotNull String getCachePath() {
|
||||
public @NotNull Path getCachePath() {
|
||||
return cachePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start instance
|
||||
* @throws SecurityException if failed to access filesystem
|
||||
* @throws Exception if failed to start watch service
|
||||
* Start service
|
||||
* @throws RuntimeException if failed to start watch service
|
||||
*/
|
||||
public void start() throws Exception {
|
||||
// Create directories if necessary
|
||||
File directory = new File(basePath);
|
||||
if (directory.mkdirs()) {
|
||||
plugin.info("Created images directory as it did not exist");
|
||||
@Override
|
||||
public void start() throws RuntimeException {
|
||||
// Create base directories if necessary
|
||||
if (basePath.toFile().mkdirs()) {
|
||||
LOGGER.info("Created images directory as it did not exist");
|
||||
}
|
||||
if (new File(cachePath).mkdirs()) {
|
||||
plugin.info("Created cache directory as it did not exist");
|
||||
if (cachePath.toFile().mkdirs()) {
|
||||
LOGGER.info("Created cache directory as it did not exist");
|
||||
}
|
||||
|
||||
// Do initial directory listing
|
||||
for (File file : Objects.requireNonNull(directory.listFiles())) {
|
||||
if (file.isDirectory()) continue;
|
||||
String filename = file.getName();
|
||||
cachedImages.put(filename, new ImageFile(filename, file.getAbsolutePath()));
|
||||
}
|
||||
plugin.fine("Found " + cachedImages.size() + " file(s) in images directory");
|
||||
|
||||
// Prepare watch service
|
||||
watchService = FileSystems.getDefault().newWatchService();
|
||||
Path directoryPath = directory.toPath();
|
||||
directoryPath.register(
|
||||
watchService,
|
||||
StandardWatchEventKinds.ENTRY_CREATE,
|
||||
StandardWatchEventKinds.ENTRY_DELETE,
|
||||
StandardWatchEventKinds.ENTRY_MODIFY
|
||||
);
|
||||
|
||||
// Start watching for changes
|
||||
task = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> {
|
||||
WatchKey watchKey = watchService.poll();
|
||||
if (watchKey == null) return;
|
||||
|
||||
watchKey.pollEvents().forEach(event -> {
|
||||
WatchEvent.Kind<?> kind = event.kind();
|
||||
File file = directoryPath.resolve((Path) event.context()).toFile();
|
||||
if (file.isDirectory()) return;
|
||||
|
||||
String filename = file.getName();
|
||||
synchronized (this) {
|
||||
if (kind == StandardWatchEventKinds.ENTRY_DELETE) {
|
||||
ImageFile imageFile = cachedImages.get(filename);
|
||||
if (imageFile != null) {
|
||||
imageFile.invalidate();
|
||||
cachedImages.remove(filename);
|
||||
}
|
||||
plugin.fine("Detected file deletion at " + filename);
|
||||
} else if (cachedImages.containsKey(filename)) {
|
||||
cachedImages.get(filename).invalidate();
|
||||
plugin.fine("Detected file update at " + filename);
|
||||
} else {
|
||||
cachedImages.put(filename, new ImageFile(filename, file.getAbsolutePath()));
|
||||
plugin.fine("Detected file creation at " + filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
watchKey.reset();
|
||||
}, POLLING_INTERVAL, POLLING_INTERVAL);
|
||||
plugin.fine("Started watching for file changes in images directory");
|
||||
// Start file system watcher
|
||||
super.start();
|
||||
LOGGER.fine("Found " + files.size() + " file(s) in images directory");
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop instance
|
||||
* Stop service
|
||||
*/
|
||||
@Override
|
||||
public void stop() {
|
||||
// Cancel async task
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
}
|
||||
|
||||
// Close watch service
|
||||
if (watchService != null) {
|
||||
try {
|
||||
watchService.close();
|
||||
} catch (IOException e) {
|
||||
plugin.log(Level.WARNING, "Failed to close watch service", e);
|
||||
}
|
||||
}
|
||||
super.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,15 +86,70 @@ public class ImageStorage {
|
||||
* @return Number of images
|
||||
*/
|
||||
public synchronized int size() {
|
||||
return cachedImages.size();
|
||||
return files.size();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all image filenames
|
||||
* @return Sorted array of image filenames
|
||||
* Get image filenames
|
||||
* @param sender Sender instance to filter only allowed images
|
||||
* @return Allowed images
|
||||
*/
|
||||
public synchronized @NotNull String[] getAllFilenames() {
|
||||
return cachedImages.keySet().toArray(new String[0]);
|
||||
public synchronized @NotNull List<String> getFilenames(@NotNull CommandSender sender) {
|
||||
List<String> response = new ArrayList<>();
|
||||
for (String filename : files.keySet()) {
|
||||
if (isPathAllowed(filename, sender)) {
|
||||
response.add(filename);
|
||||
}
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Is path allowed
|
||||
* @param path Path instance
|
||||
* @param sender Sender instance
|
||||
* @return Whether sender is allowed to access path
|
||||
*/
|
||||
public boolean isPathAllowed(@NotNull Path path, @NotNull CommandSender sender) {
|
||||
return isPathAllowed(pathToFilename(path), sender);
|
||||
}
|
||||
|
||||
/**
|
||||
* Is path allowed
|
||||
* @param path Path relative to {@link ImageStorage#basePath}
|
||||
* @param sender Sender instance
|
||||
* @return Whether sender is allowed to access path
|
||||
*/
|
||||
public boolean isPathAllowed(@NotNull String path, @NotNull CommandSender sender) {
|
||||
// Find allowed paths pattern
|
||||
String rawPattern = null;
|
||||
if (sender instanceof Player) {
|
||||
rawPattern = Permissions.getVariable("yamipa-allowed-paths", (Player) sender);
|
||||
}
|
||||
if (rawPattern == null) {
|
||||
rawPattern = allowedPaths;
|
||||
}
|
||||
if (rawPattern.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Replace special tokens in pattern
|
||||
if (sender instanceof Player) {
|
||||
Player player = (Player) sender;
|
||||
rawPattern = rawPattern.replaceAll("#player#", Matcher.quoteReplacement(Pattern.quote(player.getName())));
|
||||
rawPattern = rawPattern.replaceAll("#uuid#", player.getUniqueId().toString());
|
||||
} else {
|
||||
rawPattern = rawPattern.replaceAll("#player#", ".+");
|
||||
rawPattern = rawPattern.replaceAll("#uuid#", ".+");
|
||||
}
|
||||
|
||||
// Perform partial match against pattern
|
||||
try {
|
||||
return Pattern.compile(rawPattern).matcher(path).find();
|
||||
} catch (PatternSyntaxException __) {
|
||||
LOGGER.warning("Invalid allowed paths pattern: " + rawPattern);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -156,6 +158,53 @@ public class ImageStorage {
|
||||
* @return Image instance or NULL if not found
|
||||
*/
|
||||
public synchronized @Nullable ImageFile get(@NotNull String filename) {
|
||||
return cachedImages.get(filename);
|
||||
return files.get(filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert path to filename
|
||||
* @param path File path
|
||||
* @return Relative path used for indexing
|
||||
*/
|
||||
private @NotNull String pathToFilename(@NotNull Path path) {
|
||||
return basePath.relativize(path).toString().replaceAll("\\\\", "/");
|
||||
}
|
||||
|
||||
/**
|
||||
* On file created
|
||||
* @param path File path
|
||||
*/
|
||||
protected synchronized void onFileCreated(@NotNull Path path) {
|
||||
String filename = pathToFilename(path);
|
||||
ImageFile imageFile = new ImageFile(filename, path);
|
||||
if (files.putIfAbsent(filename, imageFile) == null) {
|
||||
LOGGER.fine("Registered file \"" + filename + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On file modified
|
||||
* @param path File path
|
||||
*/
|
||||
protected synchronized void onFileModified(@NotNull Path path) {
|
||||
String filename = pathToFilename(path);
|
||||
ImageFile imageFile = files.get(filename);
|
||||
if (imageFile != null) {
|
||||
imageFile.invalidate();
|
||||
LOGGER.fine("Invalidated file \"" + filename + "\"");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* On file deleted
|
||||
* @param path File path
|
||||
*/
|
||||
protected synchronized void onFileDeleted(@NotNull Path path) {
|
||||
String filename = pathToFilename(path);
|
||||
ImageFile imageFile = files.remove(filename);
|
||||
if (imageFile != null) {
|
||||
imageFile.invalidate();
|
||||
LOGGER.fine("Unregistered file \"" + filename + "\"");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* An instance of a file stored in a shared filesystem (<i>e.g.,</i> a NFS drive).
|
||||
* All operations are performed in a blocking way.
|
||||
* That is, the instance will acquire a read or write lock before reading or modifying the file.
|
||||
* If the file is already locked by another resource, it will wait until the lock is released
|
||||
* <b>while blocking the thread</b>.
|
||||
*/
|
||||
public class SynchronizedFile {
|
||||
protected final Path path;
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param path Path to file
|
||||
*/
|
||||
public SynchronizedFile(@NotNull Path path) {
|
||||
this.path = path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check file exists
|
||||
* @return Whether file exists or not
|
||||
*/
|
||||
public boolean exists() {
|
||||
return Files.exists(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get image last modified time
|
||||
* @return Last modified time in milliseconds or <code>0</code> in case of error
|
||||
*/
|
||||
public long getLastModified() {
|
||||
try {
|
||||
return Files.getLastModifiedTime(path).toMillis();
|
||||
} catch (Exception __) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make directories
|
||||
* <p>
|
||||
* Creates the directory containing this file and its parents if they don't exist.
|
||||
*/
|
||||
public void mkdirs() {
|
||||
try {
|
||||
Files.createDirectories(path.getParent());
|
||||
} catch (IOException __) {
|
||||
// Silently ignore exception
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file reader
|
||||
* @return Readable stream of the file
|
||||
* @throws IOException if failed to acquire lock
|
||||
*/
|
||||
public RandomAccessFile read() throws IOException {
|
||||
return new SynchronizedFileStream(path, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file writer
|
||||
* @return Writable stream of the file
|
||||
* @throws IOException if failed to acquire lock
|
||||
*/
|
||||
public RandomAccessFile write() throws IOException {
|
||||
return new SynchronizedFileStream(path, false);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package io.josemmo.bukkit.plugin.storage;
|
||||
|
||||
import org.jetbrains.annotations.Blocking;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.io.IOException;
|
||||
import java.io.RandomAccessFile;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.channels.FileLock;
|
||||
import java.nio.channels.OverlappingFileLockException;
|
||||
import java.nio.file.Path;
|
||||
|
||||
/**
|
||||
* Blocking file stream implementing {@link RandomAccessFile} for reading and writing data.
|
||||
* It will lock the file for reading/writing when opened, and release the lock when closed.
|
||||
*/
|
||||
public class SynchronizedFileStream extends RandomAccessFile {
|
||||
/**
|
||||
* Class constructor
|
||||
* @param path Path to file
|
||||
* @param readOnly Whether to open stream in read-only mode
|
||||
* @throws IOException if failed to acquire lock
|
||||
*/
|
||||
public SynchronizedFileStream(@NotNull Path path, boolean readOnly) throws IOException {
|
||||
super(path.toFile(), readOnly ? "r" : "rw");
|
||||
waitForLock(readOnly);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for lock to be released
|
||||
* @param readOnly Whether to acquire read-only (shared) lock
|
||||
*/
|
||||
@Blocking
|
||||
private void waitForLock(boolean readOnly) throws IOException {
|
||||
FileChannel channel = getChannel();
|
||||
FileLock lock = null;
|
||||
while (lock == null) {
|
||||
try {
|
||||
lock = channel.lock(0L, Long.MAX_VALUE, readOnly);
|
||||
} catch (OverlappingFileLockException e) {
|
||||
try {
|
||||
Thread.sleep(20);
|
||||
} catch (InterruptedException __) {
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,9 @@ import org.bukkit.Bukkit;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.bukkit.scheduler.BukkitTask;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public class ActionBar {
|
||||
private static final Logger LOGGER = Logger.getLogger("ActionBar");
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private final Player player;
|
||||
private String message;
|
||||
@@ -72,7 +72,7 @@ public class ActionBar {
|
||||
try {
|
||||
ProtocolLibrary.getProtocolManager().sendServerPacket(player, actionBarPacket);
|
||||
} catch (Exception e) {
|
||||
plugin.log(Level.SEVERE, "Failed to send ActionBar to " + player.getName(), e);
|
||||
LOGGER.severe("Failed to send ActionBar to " + player.getName(), e);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.OutputStreamWriter;
|
||||
import java.nio.charset.Charset;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Paths;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* A class for parsing simple CSV files.
|
||||
* It's used for loading and writing Yamipa's configuration file ("image.dat" by default) and does not
|
||||
* support escaping of special characters, as this is not currently needed.
|
||||
*/
|
||||
public class CsvConfiguration {
|
||||
public static final Charset CHARSET = StandardCharsets.UTF_8;
|
||||
public static final String COLUMN_DELIMITER = "/";
|
||||
public static final String COLUMN_DELIMITER = "\t";
|
||||
private final List<String[]> data = new ArrayList<>();
|
||||
|
||||
/**
|
||||
@@ -38,14 +42,25 @@ public class CsvConfiguration {
|
||||
* @param path File path
|
||||
* @throws IOException if failed to read file
|
||||
*/
|
||||
public void load(@NotNull String path) throws IOException {
|
||||
Stream<String> stream = Files.lines(Paths.get(path), CHARSET);
|
||||
stream.forEach(line -> {
|
||||
line = line.trim();
|
||||
if (!line.isEmpty()) {
|
||||
public void load(@NotNull Path path) throws IOException {
|
||||
try (Stream<String> stream = Files.lines(path, CHARSET)) {
|
||||
stream.forEach(line -> {
|
||||
line = line.trim();
|
||||
|
||||
// Ignore empty lines
|
||||
if (line.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Migrate legacy format
|
||||
if (!line.contains(COLUMN_DELIMITER)) {
|
||||
line = line.replaceAll("/", COLUMN_DELIMITER);
|
||||
}
|
||||
|
||||
// Parse line
|
||||
addRow(line.split(COLUMN_DELIMITER));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,8 +68,8 @@ public class CsvConfiguration {
|
||||
* @param path File path
|
||||
* @throws IOException if failed to write file
|
||||
*/
|
||||
public void save(@NotNull String path) throws IOException {
|
||||
try (OutputStreamWriter writer = new OutputStreamWriter(new FileOutputStream(path), CHARSET)) {
|
||||
public void save(@NotNull Path path) throws IOException {
|
||||
try (OutputStreamWriter writer = new OutputStreamWriter(Files.newOutputStream(path), CHARSET)) {
|
||||
for (String[] row : getRows()) {
|
||||
writer.write(String.join(COLUMN_DELIMITER, row) + "\n");
|
||||
}
|
||||
|
||||
@@ -14,10 +14,10 @@ import org.bukkit.entity.Player;
|
||||
import org.bukkit.plugin.Plugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import java.util.List;
|
||||
import java.util.logging.Level;
|
||||
|
||||
public abstract class InteractWithEntityListener implements PacketListener {
|
||||
public static final int MAX_BLOCK_DISTANCE = 5; // Server should only accept entities within a 4-block radius
|
||||
private static final Logger LOGGER = Logger.getLogger("InteractWithEntityListener");
|
||||
|
||||
/**
|
||||
* On player attack listener
|
||||
@@ -91,7 +91,7 @@ public abstract class InteractWithEntityListener implements PacketListener {
|
||||
allowEvent = onInteract(player, targetBlock, targetBlockFace);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
YamipaPlugin.getInstance().log(Level.SEVERE, "Failed to notify entity listener handler", e);
|
||||
LOGGER.severe("Failed to notify entity listener handler", e);
|
||||
}
|
||||
|
||||
// Cancel event (if needed)
|
||||
|
||||
@@ -6,6 +6,7 @@ import org.bukkit.Server;
|
||||
import org.bukkit.command.CommandMap;
|
||||
import org.bukkit.command.CommandSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
|
||||
@@ -13,7 +14,7 @@ public class Internals {
|
||||
public static final float MINECRAFT_VERSION;
|
||||
private static final CommandDispatcher<?> DISPATCHER;
|
||||
private static final CommandMap COMMAND_MAP;
|
||||
private static final Method GET_BUKKIT_SENDER_METHOD;
|
||||
private static @Nullable Method GET_BUKKIT_SENDER_METHOD = null;
|
||||
|
||||
static {
|
||||
try {
|
||||
@@ -30,24 +31,30 @@ public class Internals {
|
||||
Object nmsInstance = obcClass.getDeclaredMethod("getServer").invoke(obcInstance);
|
||||
Class<?> nmsClass = nmsInstance.getClass().getSuperclass();
|
||||
|
||||
// Get "net.minecraft.server.CommandDispatcher" references
|
||||
Object nmsDispatcherInstance = nmsClass.getDeclaredField("vanillaCommandDispatcher").get(nmsInstance);
|
||||
Class<?> nmsDispatcherClass = nmsDispatcherInstance.getClass();
|
||||
if (MINECRAFT_VERSION >= 20.6) {
|
||||
// Get "net.minecraft.commands.Commands" references
|
||||
Object nmsCommandsInstance = nmsClass.getDeclaredMethod("getCommands").invoke(nmsInstance);
|
||||
Class<?> nmsCommandsClass = nmsCommandsInstance.getClass();
|
||||
|
||||
// Get Brigadier dispatcher instance
|
||||
Method getDispatcherMethod = nmsDispatcherClass.getDeclaredMethod("a");
|
||||
getDispatcherMethod.setAccessible(true);
|
||||
DISPATCHER = (CommandDispatcher<?>) getDispatcherMethod.invoke(nmsDispatcherInstance);
|
||||
// Get "com.mojang.brigadier.CommandDispatcher" instance
|
||||
Field nmsDispatcherField = nmsCommandsClass.getDeclaredField("dispatcher");
|
||||
nmsDispatcherField.setAccessible(true);
|
||||
DISPATCHER = (CommandDispatcher<?>) nmsDispatcherField.get(nmsCommandsInstance);
|
||||
} else {
|
||||
// Get "net.minecraft.server.CommandDispatcher" references
|
||||
Object nmsDispatcherInstance = nmsClass.getDeclaredField("vanillaCommandDispatcher").get(nmsInstance);
|
||||
Class<?> nmsDispatcherClass = nmsDispatcherInstance.getClass();
|
||||
|
||||
// Get "com.mojang.brigadier.CommandDispatcher" instance
|
||||
Method getDispatcherMethod = nmsDispatcherClass.getDeclaredMethod("a");
|
||||
getDispatcherMethod.setAccessible(true);
|
||||
DISPATCHER = (CommandDispatcher<?>) getDispatcherMethod.invoke(nmsDispatcherInstance);
|
||||
}
|
||||
|
||||
// Get command map instance
|
||||
Field commandMapField = obcClass.getDeclaredField("commandMap");
|
||||
commandMapField.setAccessible(true);
|
||||
COMMAND_MAP = (CommandMap) commandMapField.get(obcInstance);
|
||||
|
||||
// Get CommandListenerWrapper.getBukkitSender() method
|
||||
Class<?> clwClass = Class.forName(nmsDispatcherClass.getPackage().getName() + ".CommandListenerWrapper");
|
||||
GET_BUKKIT_SENDER_METHOD = clwClass.getDeclaredMethod("getBukkitSender");
|
||||
GET_BUKKIT_SENDER_METHOD.setAccessible(true);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to get internal classes due to incompatible Minecraft server", e);
|
||||
}
|
||||
@@ -76,9 +83,12 @@ public class Internals {
|
||||
*/
|
||||
public static @NotNull CommandSender getBukkitSender(@NotNull Object source) {
|
||||
try {
|
||||
if (GET_BUKKIT_SENDER_METHOD == null) {
|
||||
GET_BUKKIT_SENDER_METHOD = source.getClass().getDeclaredMethod("getBukkitSender");
|
||||
}
|
||||
return (CommandSender) GET_BUKKIT_SENDER_METHOD.invoke(source);
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to extract bukkit sender from source", e);
|
||||
throw new RuntimeException("Failed to extract Bukkit sender from source", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.logging.Level;
|
||||
|
||||
/**
|
||||
* Custom logging class used by components from the plugin.
|
||||
* It is <i>instance agnostic</i>, that is, the same logger instance will work regardless of whether the plugin
|
||||
* instance has changed (for example, because of the plugin being restarted by a plugin manager).
|
||||
*/
|
||||
public class Logger {
|
||||
private final @Nullable String name;
|
||||
|
||||
/**
|
||||
* Get logger instance
|
||||
* @param name Logger name
|
||||
* @return Logger instance
|
||||
*/
|
||||
public static @NotNull Logger getLogger(@NotNull String name) {
|
||||
return new Logger(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get logger instance
|
||||
* @return Logger instance
|
||||
*/
|
||||
public static @NotNull Logger getLogger() {
|
||||
return new Logger(null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Class constructor
|
||||
* @param name Optional logger name
|
||||
*/
|
||||
private Logger(@Nullable String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Log message
|
||||
* @param level Record level
|
||||
* @param message Message
|
||||
* @param e Optional throwable to log
|
||||
*/
|
||||
private void log(@NotNull Level level, @NotNull String message, @Nullable Throwable e) {
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
|
||||
// Handle verbose logging levels
|
||||
if (level.intValue() < Level.INFO.intValue()) {
|
||||
if (!plugin.isVerbose()) return;
|
||||
level = Level.INFO;
|
||||
}
|
||||
|
||||
// Add logger name to message
|
||||
if (name != null) {
|
||||
message = "[" + name + "] " + message;
|
||||
}
|
||||
|
||||
// Proxy record to real logger
|
||||
if (e == null) {
|
||||
plugin.getLogger().log(level, message);
|
||||
} else {
|
||||
plugin.getLogger().log(level, message, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Log severe message
|
||||
* @param message Message
|
||||
* @param e Throwable to log
|
||||
*/
|
||||
public void severe(@NotNull String message, @NotNull Throwable e) {
|
||||
log(Level.SEVERE, message, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @param message Message
|
||||
* @param e Throwable to log
|
||||
*/
|
||||
public void warning(@NotNull String message, @NotNull Throwable e) {
|
||||
log(Level.WARNING, message, e);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log warning message
|
||||
* @param message Message
|
||||
*/
|
||||
public void warning(@NotNull String message) {
|
||||
log(Level.WARNING, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log info message
|
||||
* @param message Message
|
||||
*/
|
||||
public void info(@NotNull String message) {
|
||||
log(Level.INFO, message, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Log fine message
|
||||
* @param message Message
|
||||
*/
|
||||
public void fine(@NotNull String message) {
|
||||
log(Level.FINE, message, null);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +1,55 @@
|
||||
package io.josemmo.bukkit.plugin.utils;
|
||||
|
||||
import com.palmergames.bukkit.towny.TownyAPI;
|
||||
import com.palmergames.bukkit.towny.object.TownyPermission;
|
||||
import com.palmergames.bukkit.towny.utils.PlayerCacheUtil;
|
||||
import com.sk89q.worldedit.bukkit.BukkitAdapter;
|
||||
import com.sk89q.worldguard.LocalPlayer;
|
||||
import com.sk89q.worldguard.WorldGuard;
|
||||
import com.sk89q.worldguard.bukkit.WorldGuardPlugin;
|
||||
import com.sk89q.worldguard.internal.platform.WorldGuardPlatform;
|
||||
import com.sk89q.worldguard.protection.flags.Flags;
|
||||
import com.sk89q.worldguard.protection.flags.StateFlag;
|
||||
import io.josemmo.bukkit.plugin.YamipaPlugin;
|
||||
import me.angeschossen.lands.api.LandsIntegration;
|
||||
import me.angeschossen.lands.api.flags.type.RoleFlag;
|
||||
import me.angeschossen.lands.api.land.LandWorld;
|
||||
import me.angeschossen.lands.api.player.LandPlayer;
|
||||
import me.ryanhamshire.GriefPrevention.GriefPrevention;
|
||||
import net.luckperms.api.LuckPerms;
|
||||
import net.luckperms.api.LuckPermsProvider;
|
||||
import org.anjocaido.groupmanager.GroupManager;
|
||||
import org.bukkit.Bukkit;
|
||||
import org.bukkit.Location;
|
||||
import org.bukkit.Material;
|
||||
import org.bukkit.entity.Player;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.jetbrains.annotations.Nullable;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.Callable;
|
||||
|
||||
public class Permissions {
|
||||
@Nullable private static WorldGuard worldGuard = null;
|
||||
@Nullable private static GriefPrevention griefPrevention = null;
|
||||
private static final Logger LOGGER = Logger.getLogger();
|
||||
private static @Nullable LuckPerms luckPerms;
|
||||
private static @Nullable GroupManager groupManager;
|
||||
private static @Nullable WorldGuard worldGuard;
|
||||
private static @Nullable GriefPrevention griefPrevention;
|
||||
private static @Nullable TownyAPI townyApi;
|
||||
private static @Nullable LandsIntegration landsApi;
|
||||
|
||||
static {
|
||||
try {
|
||||
luckPerms = LuckPermsProvider.get();
|
||||
} catch (NoClassDefFoundError | IllegalStateException __) {
|
||||
// LuckPerms is not installed
|
||||
}
|
||||
|
||||
try {
|
||||
groupManager = (GroupManager) YamipaPlugin.getInstance().getServer().getPluginManager().getPlugin("GroupManager");
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// GroupManager is not installed
|
||||
}
|
||||
|
||||
try {
|
||||
worldGuard = WorldGuard.getInstance();
|
||||
} catch (NoClassDefFoundError __) {
|
||||
@@ -27,32 +61,133 @@ public class Permissions {
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// GriefPrevention is not installed
|
||||
}
|
||||
}
|
||||
|
||||
public static boolean canEditBlock(@NotNull Player player, @NotNull Location location) {
|
||||
// Check WorldGuard flags
|
||||
if (worldGuard != null) {
|
||||
WorldGuardPlatform platform = worldGuard.getPlatform();
|
||||
LocalPlayer wrappedPlayer = WorldGuardPlugin.inst().wrapPlayer(player);
|
||||
boolean canEdit = platform.getRegionContainer().createQuery().testBuild(
|
||||
BukkitAdapter.adapt(location),
|
||||
wrappedPlayer
|
||||
);
|
||||
boolean canBypass = platform.getSessionManager().hasBypass(
|
||||
wrappedPlayer,
|
||||
BukkitAdapter.adapt(location.getWorld())
|
||||
);
|
||||
if (!canEdit && !canBypass) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
townyApi = TownyAPI.getInstance();
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// Towny is not installed
|
||||
}
|
||||
|
||||
// Check GriefPrevention permissions
|
||||
if (griefPrevention != null && griefPrevention.allowBuild(player, location) != null) {
|
||||
try {
|
||||
landsApi = LandsIntegration.of(YamipaPlugin.getInstance());
|
||||
} catch (NoClassDefFoundError __) {
|
||||
// Lands is not installed
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get player variable
|
||||
* @param variable Variable name (key)
|
||||
* @param player Player instance
|
||||
* @return Variable value or NULL if not found
|
||||
*/
|
||||
public static @Nullable String getVariable(@NotNull String variable, @NotNull Player player) {
|
||||
if (luckPerms != null) {
|
||||
return luckPerms.getPlayerAdapter(Player.class).getUser(player).getCachedData().getMetaData()
|
||||
.getMetaValue(variable);
|
||||
}
|
||||
|
||||
if (groupManager != null) {
|
||||
String rawValue = groupManager.getWorldsHolder().getWorldPermissions(player)
|
||||
.getPermissionString(player.getName(), variable);
|
||||
return rawValue.isEmpty() ? null : rawValue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Can build at this block
|
||||
* @param player Player instance
|
||||
* @param location Block location
|
||||
* @return Whether player can build or not
|
||||
*/
|
||||
public static boolean canBuild(@NotNull Player player, @NotNull Location location) {
|
||||
return queryWorldGuard(player, location, true)
|
||||
&& queryGriefPrevention(player, location, true)
|
||||
&& queryTowny(player, location, true)
|
||||
&& queryLands(player, location, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Can destroy this block
|
||||
* @param player Player instance
|
||||
* @param location Block location
|
||||
* @return Whether player can destroy or not
|
||||
*/
|
||||
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
|
||||
public static boolean canDestroy(@NotNull Player player, @NotNull Location location) {
|
||||
return queryWorldGuard(player, location, false)
|
||||
&& queryGriefPrevention(player, location, false)
|
||||
&& queryTowny(player, location, false)
|
||||
&& queryLands(player, location, false);
|
||||
}
|
||||
|
||||
private static boolean queryWorldGuard(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (worldGuard == null) {
|
||||
return true;
|
||||
}
|
||||
WorldGuardPlatform platform = worldGuard.getPlatform();
|
||||
LocalPlayer wrappedPlayer = WorldGuardPlugin.inst().wrapPlayer(player);
|
||||
|
||||
// Grant if bypass permission is enabled
|
||||
boolean hasBypass = platform.getSessionManager().hasBypass(
|
||||
wrappedPlayer,
|
||||
BukkitAdapter.adapt(Objects.requireNonNull(location.getWorld()))
|
||||
);
|
||||
if (hasBypass) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check permission (note "BUILD" flag must always be present)
|
||||
StateFlag flag = isBuild ? Flags.BLOCK_PLACE : Flags.BLOCK_BREAK;
|
||||
return platform.getRegionContainer().createQuery()
|
||||
.testState(BukkitAdapter.adapt(location), wrappedPlayer, Flags.BUILD, flag);
|
||||
}
|
||||
|
||||
private static boolean queryGriefPrevention(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (griefPrevention == null) {
|
||||
return true;
|
||||
}
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
|
||||
// Build callable depending on permission to check
|
||||
Callable<Boolean> canEditCallable = isBuild ?
|
||||
() -> griefPrevention.allowBuild(player, location) == null :
|
||||
() -> griefPrevention.allowBreak(player, location.getBlock(), location) == null;
|
||||
|
||||
// Check permission from primary thread
|
||||
try {
|
||||
return Bukkit.isPrimaryThread() ?
|
||||
canEditCallable.call() :
|
||||
Bukkit.getScheduler().callSyncMethod(plugin, canEditCallable).get();
|
||||
} catch (Exception e) {
|
||||
LOGGER.severe("Failed to get player permissions from GriefPrevention", e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Passed all checks, player can edit this block
|
||||
return true;
|
||||
private static boolean queryTowny(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (townyApi == null) {
|
||||
return true;
|
||||
}
|
||||
Material material = location.getBlock().getType();
|
||||
TownyPermission.ActionType type = isBuild ? TownyPermission.ActionType.BUILD : TownyPermission.ActionType.DESTROY;
|
||||
return PlayerCacheUtil.getCachePermission(player, location, material, type);
|
||||
}
|
||||
|
||||
private static boolean queryLands(@NotNull Player player, @NotNull Location location, boolean isBuild) {
|
||||
if (landsApi == null) {
|
||||
return true;
|
||||
}
|
||||
LandWorld landWorld = landsApi.getWorld(Objects.requireNonNull(location.getWorld()));
|
||||
if (landWorld == null) {
|
||||
return true;
|
||||
}
|
||||
LandPlayer landPlayer = Objects.requireNonNull(landsApi.getLandPlayer(player.getUniqueId()));
|
||||
RoleFlag flag = isBuild ?
|
||||
me.angeschossen.lands.api.flags.type.Flags.BLOCK_PLACE :
|
||||
me.angeschossen.lands.api.flags.type.Flags.BLOCK_BREAK;
|
||||
return landWorld.hasRoleFlag(landPlayer, location, flag, null, false);
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ import java.util.UUID;
|
||||
import java.util.function.BiConsumer;
|
||||
|
||||
public class SelectBlockTask {
|
||||
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
private static final Logger LOGGER = Logger.getLogger("SelectBlockTask");
|
||||
private static final Map<UUID, SelectBlockTask> instances = new HashMap<>();
|
||||
private static SelectBlockTaskListener listener = null;
|
||||
private final Player player;
|
||||
@@ -71,7 +71,7 @@ public class SelectBlockTask {
|
||||
if (listener == null) {
|
||||
listener = new SelectBlockTaskListener();
|
||||
listener.register();
|
||||
plugin.fine("Created SelectBlockTaskListener singleton");
|
||||
LOGGER.fine("Created SelectBlockTaskListener singleton");
|
||||
}
|
||||
|
||||
// Start task
|
||||
@@ -94,7 +94,7 @@ public class SelectBlockTask {
|
||||
if (instances.isEmpty()) {
|
||||
listener.unregister();
|
||||
listener = null;
|
||||
plugin.fine("Destroyed SelectBlockTaskListener singleton");
|
||||
LOGGER.fine("Destroyed SelectBlockTaskListener singleton");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,7 @@ public class SelectBlockTask {
|
||||
@Override
|
||||
public void register() {
|
||||
super.register();
|
||||
YamipaPlugin plugin = YamipaPlugin.getInstance();
|
||||
plugin.getServer().getPluginManager().registerEvents(this, plugin);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,15 @@ main: io.josemmo.bukkit.plugin.YamipaPlugin
|
||||
api-version: 1.16
|
||||
depend: [ProtocolLib]
|
||||
softdepend:
|
||||
- BentoBox
|
||||
- GriefPrevention
|
||||
- GroupManager
|
||||
- Hyperverse
|
||||
- LuckPerms
|
||||
- Multiverse-Core
|
||||
- My_Worlds
|
||||
- PhantomWorlds
|
||||
- Towny
|
||||
- WorldGuard
|
||||
|
||||
permissions:
|
||||
|
||||
Reference in new issue
Block a user