Compare commits

...
94 Commits
Author SHA1 Message Date
José Miguel Moreno 424aa67b6e Upgraded Java version in workflows (III)
- Updated tests.yml workflow
2024-05-26 13:01:04 +02:00
José Miguel Moreno 549a4ad81b Upgraded Java version in workflows (II)
- Updated tests.yml workflow
2024-05-26 12:58:20 +02:00
José Miguel Moreno 7726b9aa4e Fixed command dispatcher in MC 1.20.6
- Updated Internals class

 > Related to #125
2024-05-26 12:45:05 +02:00
José Miguel Moreno a90aad5b67 Upgraded Java version in workflows
- Updated build.yml and tests.yml
2024-05-26 12:43:44 +02:00
José Miguel Moreno 806e023657 Upgraded automata
- Updated package.json and package-lock.json
2024-05-26 12:43:12 +02:00
José Miguel Moreno 39fb971202 Upgraded dependencies
- Updated pom.xml
2024-05-26 12:37:28 +02:00
José Miguel Moreno adc4245c71 v1.3.2-SNAPSHOT-b2 2024-04-17 08:57:37 +02:00
José Miguel Moreno a4a0b07dfb Added compatibility with PhantomWorlds
- Updated plugin.yml
2024-04-17 08:55:10 +02:00
José Miguel Moreno 713957c382 v1.3.2-SNAPSHOT
[skip-ci]
2024-03-28 17:17:01 +01:00
José Miguel Moreno 16a1c283f2 Fixed file changes detection without inotify
- Created FileSystemWatcher class
- Updated ImageStorage class

> Fixes #114
2024-03-28 17:10:59 +01:00
José Miguel Moreno f0cb770372 Added support for BentoBox
- Updated plugin.yml

> Fixes #118
2024-03-24 16:58:04 +01:00
José Miguel Moreno 44b85b2c17 v1.3.1 2024-03-02 12:36:37 +01:00
José Miguel Moreno 4b962cf55e Upgraded dependencies
- Updated pom.xml
2024-03-02 12:36:14 +01:00
José Miguel Moreno 867b7b3484 Fixed rendering with high view distance
- Updated WorldAreaId class

> Fixes #113
2024-02-26 19:25:46 +01:00
José Miguel Moreno 54c2bdc301 Fixed overlapping file lock exception
- Updated SynchronizedFileStream class
- Updated CachedMapsFile#load() method
2024-02-24 13:37:01 +01:00
José Miguel Moreno b0f6330d6e v1.3.0 2024-01-06 12:07:37 +01:00
José Miguel Moreno ffbacd2962 Fixed exception when stopping storage
- Updated ImageStorage class
2024-01-05 15:27:14 +01:00
José Miguel Moreno 7cd2664ed4 Restricted download paths
- Updated ImageCommand class
- Updated ImageStorage class
2024-01-05 14:33:24 +01:00
José Miguel Moreno 9ea2cdb378 Initial support for image files authorization
- Updated ImageFileArgument and ImageCommand classes
- Updated ImageStorage class
- Updated YamipaPlugin class
2024-01-05 14:24:13 +01:00
José Miguel Moreno d99e037f9a Improved arguments builder
- Added Argument#suggest() method
- Updated child argument classes
- Updated Command class
2024-01-04 18:27:52 +01:00
José Miguel Moreno 2a8cd059b8 Added support for custom max. image dimension
- Updated image commands
- Updated image renderer
- Updated Permissions class
- Updated YamipaPlugin class
- Created ImageDimensionArgument class
- Updated documentation
- Updated dependencies
2024-01-03 13:31:56 +01:00
José Miguel Moreno 85c075ad4d Improved animate images property
- Updated FakeImage and ImageRenderer classes
- Updated YamipaPlugin class
2024-01-03 12:48:12 +01:00
José Miguel Moreno 3b5a27e71b Revert "Updated compiler target version"
This reverts commit 4a303c1768.
2023-12-31 14:44:56 +01:00
José Miguel Moreno 5c18ee6e20 v1.3.0-SNAPSHOT 2023-12-31 14:34:42 +01:00
José Miguel Moreno d87a97bb08 Updated configuration file separator
- Updated CsvConfiguration class
2023-12-31 14:21:45 +01:00
José Miguel Moreno 4a303c1768 Updated compiler target version
- Updated pom.xml
2023-12-31 14:11:55 +01:00
José Miguel Moreno 0926bcc942 Limit number of directories when downloading files
- Updated ImageCommand class
2023-12-31 14:10:53 +01:00
José Miguel Moreno 0d8dbaf9d7 Updated configuration path type
- Updated ImageRenderer class
- Updated CsvConfiguration class
- Updated YamipaPlugin class
2023-12-29 17:50:22 +01:00
José Miguel Moreno d25fcb3a1f Allowed downloading images in subdirectories
- Updated ImageCommand class
- Updated ImageStorage class
- Updated YamipaPlugin class
2023-12-29 17:46:05 +01:00
José Miguel Moreno 0edd1f28e5 Heavy reworking of storage and renderer components
- Created SynchronizedFile class and their dependencies
- Updated ImageFile class
- Migrated from FakeMapsContainer to CachedMapsFile class
- Updated renderer classes

> Related to #91
2023-12-29 17:25:31 +01:00
José Miguel Moreno 065df5755f Merge branch 'develop' into next 2023-12-26 12:13:02 +01:00
José Miguel Moreno d73fc25c96 v1.2.13 2023-12-26 11:15:42 +01:00
José Miguel Moreno 19db638891 Upgraded tests to use 1.20.4
- Updated tests.yml workflow
2023-12-26 11:06:41 +01:00
José Miguel Moreno bfab25826f Upgraded CI/CD workflows
- Updated build.yml and tests.yml workflows
2023-12-16 12:49:25 +01:00
José Miguel Moreno 435ee95094 Upgraded dependencies
- Updated pom.xml
2023-12-16 12:48:22 +01:00
José Miguel Moreno 6a48a17873 Minor annotation changes in commands
- Updated Command class
- Updated ImageCommandBridge class
2023-10-14 18:02:18 +02:00
José Miguel Moreno 14ac8ee526 Improving handling of metrics reference
- Updated YamipaPlugin class
2023-10-14 17:53:20 +02:00
José Miguel Moreno 4ee9566f10 Merged develop in next 2023-10-14 15:52:58 +02:00
José Miguel Moreno 1d67bac8c8 v1.2.12 2023-10-14 14:01:31 +02:00
José Miguel Moreno f565ce54b9 Minor fixes in permissions utils
- Updated Permissions class
2023-10-14 14:00:23 +02:00
José Miguel Moreno e328f959a6 Fixed LandsAPI integration
- Updated Permissions class

 > Related to #105
2023-10-14 13:59:39 +02:00
José Miguel Moreno b91103ba9a Added support for MC 1.20.2
- Updated pom.xml
- Updated tests.yml workflow
- Updated README.md
2023-10-14 11:07:06 +02:00
José Miguel Moreno 807622c418 Upgraded dependencies
- Updated pom.xml
- Updated package.json in automata
2023-10-14 11:06:06 +02:00
José Miguel Moreno 8a97f07c5a Merge pull request #105 from XXY233/develop
add Lands support
2023-10-14 10:48:01 +02:00
José Miguel Moreno d1bfffe49b Simplified LandsAPI integration
- Updated Permissions class
- Updated README.md
2023-10-14 10:40:41 +02:00
TheLittle_Yang f33cf9fb0d It's impossible to have offline players :D 2023-10-13 13:04:14 +08:00
TheLittle_Yang 66decb36ae add Lands support 2023-09-26 00:00:24 +08:00
José Miguel Moreno 16f04aab1a Updated CI to work with new logger
- Updated tests.yml workflow
2023-07-31 16:42:52 +02:00
José Miguel Moreno a5d698a266 Fixed component stop order
- Updated YamipaPlugin#onDisable()
2023-07-31 16:38:30 +02:00
José Miguel Moreno a16cda7c22 Improved legibility of static properties
- Made static properties UPPERCASE
- Added missing nullable annotations
- Improved legibility/performance of LAST_MAP_ID and LAST_FRAME_ID counters
2023-07-31 15:08:35 +02:00
José Miguel Moreno 599a7de7fc Fixed remaining dangling plugin reference
- Updated ImageFile class
2023-07-31 15:05:49 +02:00
José Miguel Moreno 0eab4f7f4d Minor improvements
- Updated ImageCommand class
- Updated ImageRenderer and ImageStorage classes
2023-07-31 14:53:16 +02:00
José Miguel Moreno 179cb69146 Improved support for plugin managers
- Updated YamipaPlugin to free singleton instance on disable
- Updated rest of classes to not keep dangling references
2023-07-31 14:51:14 +02:00
José Miguel Moreno 6d4472cb0e Minor change in FakeMap properties
- Updated FakeMap class
2023-07-31 14:34:05 +02:00
José Miguel Moreno 2ec966b567 Improved logging (II)
- Created Logger utils class
- Removed logging methods from YamipaPlugin class
- Updated dependent classes
2023-07-31 14:33:48 +02:00
José Miguel Moreno c2c697dc4a Fixed missing log for CI pipeline
- Updated ImageStorage class
2023-07-31 12:58:16 +02:00
José Miguel Moreno f615f689f6 Improved logging
- Updated logging methods in YamipaPlugin
- Updated affected classes
2023-07-31 12:57:09 +02:00
José Miguel Moreno bb1d951c73 Surrounded image file arguments in quotes
- Updated ImageFileArgument class
- Updated README.md
2023-07-31 12:49:56 +02:00
José Miguel Moreno a9308a5aac Added support for directories in configuration
- Updated CsvConfiguration class
2023-07-30 18:40:35 +02:00
José Miguel Moreno f7135fc9a6 Initial support for directories in image storage
- Updated ImageStorage class
- Updated ImageFile and ItemService classes
- Updated ImageCommand class
- Updated YamipaPlugin class
2023-07-30 18:34:16 +02:00
José Miguel Moreno 6f27717d88 Upgraded automata dependencies
- Updated package.json
- Updated package-lock.json
2023-07-15 11:01:20 +02:00
José Miguel Moreno 178c464cae Fixed race condition in map color cache generation
- Updated YamipaPlugin class

> Fixes `java.lang.IllegalStateException: java.lang.IllegalStateException: Cache is already build or is currently being build`
2023-06-17 12:25:22 +02:00
José Miguel Moreno 0c6cce2a4f v1.2.11 2023-06-17 10:53:55 +02:00
José Miguel Moreno 4194f674ad Fixed CI/CD
- Updated tests.yml workflow

> Do not check for exception in Bukkit servers, ProtocolLib always throws `java.lang.NullPointerException: Cannot invoke "com.comphenix.protocol.updater.Updater.isChecking()" because "this.updater" is null`
2023-06-17 10:17:15 +02:00
José Miguel Moreno e640d0ee1d Added support for MC 1.20.1
- Updated pom.xml
- Updated tests.yml workflow
- Updated README.md
2023-06-16 18:38:06 +02:00
José Miguel Moreno 9001db4a5c Upgraded automata dependencies
- Updated package.json
- Upgraded lock file
- Updated tests.yml workflow
2023-06-16 18:20:56 +02:00
José Miguel Moreno 1ae9b5553b Upgraded dependencies
- Updated pom.xml
- Updated tests.yml workflow
- Updated README.md
2023-06-16 18:20:22 +02:00
José Miguel Moreno 8b0787f87e Fixed permissions section in README
- Updated README.md
2023-05-07 12:03:55 +02:00
José Miguel Moreno 8eed5bd45c Fixed CI/CD
- Updated tests.yml
2023-05-07 11:43:46 +02:00
José Miguel Moreno af6b0994cb v1.2.10 2023-05-07 11:34:41 +02:00
José Miguel Moreno dbb36ec696 Updated ProtocoLib requirement in README
- Updated README.md
2023-05-07 11:33:41 +02:00
José Miguel Moreno 78144fd7e2 Added support for bundled packets in 1.19.4
- Updated FakeImage, FakeItemFrame and FakeMap to expose packets instead of sending them
- Updated FakeEntity#tryToSendPackets()
2023-05-07 11:21:49 +02:00
José Miguel Moreno 65c6a2a0a2 v1.2.10-SNAPSHOT 2023-04-03 13:11:30 +02:00
José Miguel Moreno 68a005267a Added support for Towny Advanced
- Updated Permissions utils class
- Updated pom.xml and plugin.yml
- Updated README.md

> Closes #87
2023-04-03 13:08:30 +02:00
José Miguel Moreno 15284f166f Fixed crash on missing permissions plugin
- Updated Permissions utils class
2023-04-03 13:02:56 +02:00
José Miguel Moreno 12f86d4240 Upgraded bStats
- Updated pom.xml
2023-04-03 12:38:51 +02:00
José Miguel Moreno ea53f8e5b3 Split edit permissions into build and destroy
- Updated Permissions util class
- Updated ImageCommand class
2023-04-03 12:38:09 +02:00
José Miguel Moreno c7d61d13ba v1.2.9 2023-03-25 12:17:55 +01:00
José Miguel Moreno e77cfcb378 Added support for MC 1.19.4
- Updated pom.xml
- Updated tests.yml workflow
2023-03-25 12:13:01 +01:00
José Miguel Moreno 99f18fd6f7 Upgraded dependencies
- Updated pom.xml
2023-03-25 12:12:17 +01:00
José Miguel Moreno 3977d02e2d v1.2.8 2023-02-02 19:40:59 +01:00
José Miguel Moreno fe20445e53 v1.2.8-SNAPSHOT-b2 2023-01-29 20:39:55 +01:00
José Miguel Moreno 4670b60b9a Disabled updates for ProtocolLib in tests
- Updated tests.yml workflow
2023-01-29 13:02:39 +01:00
José Miguel Moreno e772713328 Upgraded automata
- Upgraded mineflayer dependency
2023-01-29 12:43:07 +01:00
José Miguel Moreno ae5dab6475 Use static world size for neighborhoods
- Updated WorldAreaId class

> Fixes #77
2023-01-29 12:33:45 +01:00
José Miguel Moreno a5c53d7ec5 Fixed synchronization issue with observing players
- Updated FakeImage#nextStep() method

> Fixes #79
2023-01-26 19:25:53 +01:00
José Miguel Moreno e66c3fa92c Fixed image rendering for vehicles
- Updated ImageRenderer class

> Fixes #78
2023-01-21 11:37:17 +01:00
José Miguel Moreno ffa95e5d54 Upgraded dependencies
- Updated pom.xml
2023-01-21 11:05:48 +01:00
José Miguel Moreno 683ebe4ac4 v1.2.8-SNAPSHOT 2023-01-09 20:45:25 +01:00
José Miguel Moreno 1ce3e6099e Merge branch 'main' into develop 2023-01-09 20:41:39 +01:00
José Miguel Moreno 1fd4319454 Synchronized checking of block permissions
- Updated Permissions.canEditBlock() to always call GriefPrevention plugin on main game thread

> Closes #76
2023-01-09 20:38:12 +01:00
José Miguel Moreno 8061c4b267 Minor refactorization
- Renamed FakeImage.enableAnimation() to FakeImage.configure()
- Updated YamipaPlugin class
2022-12-29 17:08:24 +01:00
José Miguel Moreno bb59fbc898 Upgraded dependencies
- Updated ProtocolLib dependency
- Updated JetBrains annotations dependency
2022-12-29 16:42:11 +01:00
José Miguel Moreno 420f963fa9 Upgraded automata dependencies
- Updated package.json
- Updated package-lock.json
2022-12-29 16:38:57 +01:00
39 changed files with 2569 additions and 1308 deletions

No files matched your search

+3 -3
View File
@@ -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
View File
@@ -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
+78 -18
View File
@@ -5,7 +5,7 @@
[![bStats Servers](https://img.shields.io/bstats/servers/10243)](https://bstats.org/plugin/bukkit/Yamipa/10243)
[![License](https://img.shields.io/github/license/josemmo/yamipa)](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:
+327 -249
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -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"
}
}
+55 -10
View File
@@ -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("-"))
@@ -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);
}
+5
View File
@@ -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: