Compare commits

..
131 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
José Miguel Moreno 3df48ca88d Merge pull request #69 from josemmo/develop
v1.2.7
2022-12-17 13:43:11 +01:00
José Miguel Moreno f09624cd9b v1.2.7 2022-12-17 13:38:35 +01:00
José Miguel Moreno f27cd1d2c2 Upgraded automata dependencies
- Updated package.json
- Updated package-lock.json
2022-12-17 13:37:07 +01:00
José Miguel Moreno c446e42b87 Added compatibility with MC 1.19.3
- Updated EntityMetadataPacket to use new WrappedDataValue stucture
- Upgraded ProtocolLib version
- Updated tests workflow
2022-12-17 13:21:49 +01:00
José Miguel Moreno 0486da53c0 Ignored NPC events
- Updated ImageRenderer to ignore events originating from NPCs from other plugins

> Closes #59
2022-11-30 19:55:36 +01:00
José Miguel Moreno 705926f4b6 Merge pull request #63 from josemmo/develop
v1.2.6
2022-10-29 17:51:25 +02:00
José Miguel Moreno 2ae0dc3727 v1.2.6 2022-10-29 17:44:50 +02:00
José Miguel Moreno 7b7360ee03 Fixed commands ignoring permissions
- Updated Command#buildElement() method
2022-10-29 17:44:04 +02:00
José Miguel Moreno d8b784ab9a New permission system
- Updated Command#withPermission() method
- Updated ImageCommand and ImageCommandBridge classes
- Updated ItemService class
- Updated plugin.yml
- Updated README.md
2022-10-28 18:28:00 +02:00
José Miguel Moreno 7ee41b3bac Improved debug logging of fake images
- Updated FakeImage class
2022-10-27 21:19:48 +02:00
José Miguel Moreno 7db0af38b3 Fixed bug allowing removals of image items
- Updated ItemService to check block permissions also on image removal
- Updated ImageCommand class

> Fixes #60
2022-10-27 21:11:16 +02:00
José Miguel Moreno 0abaec8db0 Merge pull request #53 from josemmo/develop
v1.2.5
2022-08-16 11:56:06 +02:00
José Miguel Moreno e973a065e8 v1.2.5 2022-08-16 11:49:11 +02:00
José Miguel Moreno 28b7f69a6c Fixed server crash on entity interact
- Removed thread synchronization from InteractWithEntityListener (already synchronized since latest renderer version)
- Removed conflicting listener from SelectBlockTask

> Closes #51
2022-08-16 11:45:15 +02:00
José Miguel Moreno 0d35c486e8 Merge pull request #50 from josemmo/develop
v1.2.4
2022-08-12 12:26:00 +02:00
José Miguel Moreno 1080b22427 Fixed view distance on Bukkit
- Updated WorldAreaId class

> Related to #46
2022-08-12 12:20:22 +02:00
José Miguel Moreno d82e4a619e v1.2.4 2022-08-12 11:50:02 +02:00
José Miguel Moreno b807744106 Added support for 1.19.2
- Upgraded dependencies
- Updated tests workflow
- Updated README.md
2022-08-12 11:49:28 +02:00
José Miguel Moreno 13f45d927c Improved view distance
- Updated WorldAreaId to generate neighborhood based on world's view distance

> Closes #46
2022-08-12 11:47:46 +02:00
José Miguel Moreno 752c49eee2 Added support for MC 1.19.1
- Updated pom.xml
- Updated tests workflow
- Updated README.md
2022-08-02 10:51:27 +02:00
José Miguel Moreno 9fa78a9b2d v1.2.4-SNAPSHOT-b2 2022-08-02 10:28:01 +02:00
José Miguel Moreno 1f52f9f390 Fixed fetching of PlayerInjectionHandler
- Updated FakeEntity class

> Fix for ProtocolLib v4
2022-08-01 19:08:54 +02:00
José Miguel Moreno 6da34147a4 Added non-blocking packet sending
- Updated FakeEntity class
2022-08-01 18:47:16 +02:00
José Miguel Moreno 0f8f884760 Updated rendering to send pixels on-demand
- Updated FakeItemFrame class

> Now map pixels are send on render as they're needed instead of at spawn
2022-08-01 18:34:53 +02:00
José Miguel Moreno d1ebec19f0 Fixed maps not resending on relogin
- Updated FakeMap class
2022-08-01 18:31:26 +02:00
José Miguel Moreno e33eb0e1ee Optimized image rendering and sending
- Updated FakeEntity and FakeImage classes
- Warmed-up ProtocolLib at startup
2022-07-30 13:28:00 +02:00
José Miguel Moreno d5e0f669eb Fixed issues with images in between world areas
- Updated ImageRenderer class
- Updated FakeImage class
- Removed WorldArea class
- Updated comments in WorldAreaId class
2022-07-27 18:33:31 +02:00
José Miguel Moreno 37e0c5879e v1.2.4-SNAPSHOT 2022-07-02 17:23:18 +02:00
José Miguel Moreno 182db3896d Added areas documentation
- Updated README.md
2022-07-02 17:22:55 +02:00
José Miguel Moreno 55463c78cd Fixed image item placing
- Updated ItemService#onPlaceItem() to work with Minecraft <=1.17

> Fixes #45
> Fixes #26
2022-07-02 16:57:27 +02:00
José Miguel Moreno ded538698e Improved testing automata
- Updated tests workflow
- Updated index.js
- Upgraded automata dependencies
2022-07-02 13:28:10 +02:00
José Miguel Moreno 6ccd650038 Updated tests workflow
- Used fixed JDK version for building
- Added hard timeout to server
2022-07-02 13:08:07 +02:00
José Miguel Moreno 6f622a872f Added support for GriefPrevention
- Updated Permissions utils class
- Added GriefPrevention as a provided dependency

> Closes #42
2022-07-02 12:52:33 +02:00
José Miguel Moreno e258a14e4c Fixed entity interaction synchronization
- Updated InteractWithEntityListener to run callbacks on main server thread
- Fixed typo in SelectBlockTask class
2022-07-02 12:47:28 +02:00
José Miguel Moreno 23dac06c5e Added compatibility with WorldGuard
- Added Permissions utils class
- Updated ImageCommand class
- Added WorldGuard as a provided dependency

> Closes #42
2022-07-02 11:22:29 +02:00
José Miguel Moreno eaeea070c1 Updated minimum API version
- Updated plugin.yml
2022-07-02 11:16:58 +02:00
José Miguel Moreno b929939a76 Improved image collision prevention
- Updated ImageCommand#placeImage()
- Added FakeImage#getAllLocations() method
2022-07-02 11:15:08 +02:00
42 changed files with 3607 additions and 1716 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
+45 -32
View File
@@ -6,39 +6,53 @@ jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 8
continue-on-error: ${{ matrix.version == '1.19' }}
continue-on-error: ${{ matrix.version == '1.20.4' }}
strategy:
fail-fast: false
matrix:
flavor: ['bukkit', 'spigot', 'paper', 'purpur']
version: ['1.19', '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
- name: Setup JDK
uses: actions/setup-java@v3
# Setup Java (for building)
- name: Setup JDK (for building)
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: '21'
# Build plugin
- name: Build with Maven
run: mvn --batch-mode --update-snapshots verify
# Setup Java (for testing)
- name: Setup JDK (for testing)
uses: actions/setup-java@v4
with:
distribution: 'temurin'
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: '16'
node-version: '20'
# Setup automata
- name: Setup automata
working-directory: ./automata
run: npm install
# Build plugin
- name: Build with Maven
run: mvn --batch-mode --update-snapshots verify
# Setup server
- name: Setup server
env:
@@ -60,24 +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
run: |
if [ $VERSION == "1.19" ]; 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
@@ -92,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 90s java -DIReallyKnowWhatIAmDoingISwear -jar server.jar nogui | tee server.log || true
if ! grep -Fq '[YamipaPlugin] Loading YamipaPlugin' server.log; then
timeout -s SIGINT -k 110s 90s java -DIReallyKnowWhatIAmDoingISwear -jar server.jar nogui | tee server.log || true
if ! grep -Fq '[YamipaPlugin] ' server.log; then
echo "Plugin did not load"
exit 1
fi
@@ -103,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
+108 -28
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 |
@@ -33,7 +33,8 @@ Here are the Minecraft distributions where Yamipa should be able to run:
| 1.16.x | ✅ | ✅ | ✅ |
| 1.17 & 1.17.1 | ✅ | ✅ | ✅ |
| 1.18 & 1.18.x | ✅ | ✅ | ✅ |
| 1.19 | ✅ | ✅ | ✅ |
| 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,24 +131,68 @@ 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, each one corresponding to the command with the same name:
Yamipa defines the following permissions:
- `yamipa.clear`
- `yamipa.describe`
- `yamipa.download`
- `yamipa.give`
- `yamipa.list`
- `yamipa.place`
- `yamipa.remove`
- `yamipa.top`
| 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` 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 |
| `yamipa.command.remove.own` | OPs | Same as previous, but only for images placed by the same player |
| `yamipa.command.top` | OPs | To use the `/image top` command |
| `yamipa.item.place` | All players | To place image items |
| `yamipa.item.remove` | All players | To remove image items (that have the `REMO` flag) |
| `yamipa.item.remove.own` | All players | Same as previous, but only for images placed by the same player |
By default, only server OPs have all permissions granted. You can change this by using a permission plugin,
Note that permissions work **as a tree**. This means that if you grant a permission, by default its children will also
be granted. For instance, you can grant access to all commands with `yamipa.command.*`.
Similarly, if you grant the `yamipa.command.remove`, the permission `yamipa.command.remove.own` will also be granted.
You can change which roles or players are granted these commands by using a permission plugin,
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.
If you have one or more of such plugins, Yamipa will automatically apply permissions accordingly and only let players
place or remove images where they can place or remove blocks, no additional configuration required.
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:
+5 -1
View File
@@ -14,7 +14,6 @@ import { getRconClient, waitForServer } from './src/rcon.js'
const conn = await getRconClient()
await conn.send('op test')
await conn.send('gamemode creative test')
await conn.end()
await wait(2000)
console.log('[AUTOMATA] Preparing test account...')
@@ -37,5 +36,10 @@ import { getRconClient, waitForServer } from './src/rcon.js'
console.log('[AUTOMATA] Logging off...')
bot.end()
console.log('[AUTOMATA] Stopping server...')
await conn.send('stop')
await conn.end()
console.log('[AUTOMATA] Done!')
})()
+642 -399
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.1.0",
"rcon-client": "^4.2.3"
"mineflayer": "^4.20.1",
"rcon-client": "^4.2.4"
}
}
+72 -7
View File
@@ -6,7 +6,7 @@
<groupId>io.josemmo.bukkit.plugin</groupId>
<artifactId>YamipaPlugin</artifactId>
<version>1.2.3</version>
<version>1.3.2-SNAPSHOT-b2</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
@@ -27,36 +27,101 @@
<id>minecraft-libraries</id>
<url>https://libraries.minecraft.net</url>
</repository>
<repository>
<id>enginehub-repo</id>
<url>https://maven.enginehub.org/repo/</url>
</repository>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</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-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.comphenix.protocol</groupId>
<artifactId>ProtocolLib</artifactId>
<version>5.0.0-SNAPSHOT</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.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.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>
@@ -73,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>
@@ -105,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,10 +1,9 @@
package io.josemmo.bukkit.plugin;
import io.josemmo.bukkit.plugin.commands.ImageCommandBridge;
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;
@@ -12,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;
}
/**
@@ -40,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;
}
@@ -48,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;
}
@@ -56,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
@@ -88,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
@@ -119,6 +122,15 @@ public class YamipaPlugin extends JavaPlugin {
// Create thread pool
scheduler = Executors.newScheduledThreadPool(6);
// Warm-up plugin dependencies
LOGGER.fine("Waiting for ProtocolLib to be ready...");
scheduler.execute(() -> {
FakeEntity.waitForProtocolLib();
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 -> {
if (number >= 1000) return "1000+";
@@ -128,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<>();
/**
@@ -53,11 +54,18 @@ public class Command {
/**
* Add permission requirement to this command
* @param permission Permission name
* @return This instance
* @param permissions Permission names (match as least one)
* @return This instance
*/
public @NotNull Command withPermission(@NotNull String permission) {
return withRequirement(sender -> sender.hasPermission(permission));
public @NotNull Command withPermission(@NotNull String ...permissions) {
return withRequirement(sender -> {
for (String permission : permissions) {
if (sender.hasPermission(permission)) {
return true;
}
}
return false;
});
}
/**
@@ -75,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;
@@ -112,9 +121,20 @@ public class Command {
@SuppressWarnings({"rawtypes", "unchecked"})
private @NotNull ArgumentBuilder buildElement(@NotNull ArgumentBuilder parent, int argIndex) {
// Attach requirement handler to each command element
parent.requires(source -> {
CommandSender sender = Internals.getBukkitSender(source);
return requirementHandler.test(sender);
});
// 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;
@@ -122,12 +142,6 @@ public class Command {
return parent;
}
// Attach requirement handler to last command element
parent.requires(source -> {
CommandSender sender = Internals.getBukkitSender(source);
return requirementHandler.test(sender);
});
// Attach execution handler to last command element
if (executesPlayerHandler != null) {
parent.executes(ctx -> {
@@ -5,6 +5,9 @@ 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;
import org.bukkit.*;
@@ -22,47 +25,50 @@ 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;
s.sendMessage(ChatColor.BOLD + "=== Yamipa Plugin Help ===");
s.sendMessage(ChatColor.AQUA + cmd + ChatColor.RESET + " - Show this help");
if (s.hasPermission("yamipa.clear")) {
if (s.hasPermission("yamipa.command.clear") || s.hasPermission("yamipa.clear")) {
s.sendMessage(ChatColor.AQUA + cmd + " clear <x z w> <r> [<player>]" + ChatColor.RESET + " - Remove placed images");
}
if (s.hasPermission("yamipa.describe")) {
if (s.hasPermission("yamipa.command.describe") || s.hasPermission("yamipa.describe")) {
s.sendMessage(ChatColor.AQUA + cmd + " describe" + ChatColor.RESET + " - Describe placed image");
}
if (s.hasPermission("yamipa.download")) {
if (s.hasPermission("yamipa.command.download") || s.hasPermission("yamipa.download")) {
s.sendMessage(ChatColor.AQUA + cmd + " download <url> <filename>" + ChatColor.RESET + " - Download image");
}
if (s.hasPermission("yamipa.give")) {
if (s.hasPermission("yamipa.command.give") || s.hasPermission("yamipa.give")) {
s.sendMessage(ChatColor.AQUA + cmd + " give <p> <filename> <#> <w> [<h>] [<f>]" + ChatColor.RESET + " - Give items");
}
if (s.hasPermission("yamipa.list")) {
if (s.hasPermission("yamipa.command.list") || s.hasPermission("yamipa.list")) {
s.sendMessage(ChatColor.AQUA + cmd + " list [<page>]" + ChatColor.RESET + " - List all images");
}
if (s.hasPermission("yamipa.place")) {
if (s.hasPermission("yamipa.command.place") || s.hasPermission("yamipa.place")) {
s.sendMessage(ChatColor.AQUA + cmd + " place <filename> <w> [<h>] [<f>]" + ChatColor.RESET + " - Place image");
}
if (s.hasPermission("yamipa.remove")) {
if (s.hasPermission("yamipa.command.remove.own") || s.hasPermission("yamipa.remove")) {
s.sendMessage(ChatColor.AQUA + cmd + " remove" + ChatColor.RESET + " - Remove a single placed image");
}
if (s.hasPermission("yamipa.top")) {
if (s.hasPermission("yamipa.command.top") || s.hasPermission("yamipa.top")) {
s.sendMessage(ChatColor.AQUA + cmd + " top" + ChatColor.RESET + " - List players with the most images");
}
}
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) {
@@ -84,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;
@@ -148,6 +172,7 @@ public class ImageCommand {
// Download file
sender.sendMessage("Downloading file...");
Files.createDirectories(destPath.getParent());
Files.copy(conn.getInputStream(), destPath);
// Validate downloaded file
@@ -159,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());
}
@@ -182,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");
}
@@ -202,46 +225,74 @@ public class ImageCommand {
@NotNull Location location,
@NotNull BlockFace face
) {
YamipaPlugin plugin = YamipaPlugin.getInstance();
// Prevent two images occupying the same space
FakeImage existingImage = plugin.getRenderer().getImage(location, face);
if (existingImage != null) {
ActionBar.send(player, ChatColor.RED + "There's already an image there!");
return false;
}
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
// 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.canBuild(player, loc)) {
ActionBar.send(player, ChatColor.RED + "You're not allowed to place an image here!");
return false;
}
if (renderer.getImage(loc, face) != null) {
ActionBar.send(player, ChatColor.RED + "There's already an image there!");
return false;
}
}
// Show loading status to player
ActionBar loadingActionBar = ActionBar.repeat(player, ChatColor.AQUA + "Loading image...");
fakeImage.setOnLoadedListener(loadingActionBar::clear);
// Add fake image to renderer
plugin.getRenderer().addImage(fakeImage);
renderer.addImage(fakeImage);
return true;
}
public static void removeImage(@NotNull Player player) {
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
// Ask user to select fake image
SelectBlockTask task = new SelectBlockTask(player);
task.onSuccess((location, face) -> {
FakeImage image = renderer.getImage(location, face);
FakeImage image = YamipaPlugin.getInstance().getRenderer().getImage(location, face);
if (image == null) {
ActionBar.send(player, ChatColor.RED + "That is not a valid image!");
} else {
renderer.removeImage(image);
return;
}
// Check player's command permissions
if (
!player.getUniqueId().equals(image.getPlacedBy().getUniqueId()) &&
!player.hasPermission("yamipa.command.remove") &&
!player.hasPermission("yamipa.remove")
) {
ActionBar.send(player, ChatColor.RED + "You cannot remove images from other players!");
return;
}
// Attempt to remove image
removeImage(player, image);
});
task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image removing canceled"));
task.run("Right click an image to continue");
}
public static boolean removeImage(@NotNull Player player, @NotNull FakeImage image) {
// Check block permissions
for (Location loc : image.getAllLocations()) {
if (!Permissions.canDestroy(player, loc)) {
ActionBar.send(player, ChatColor.RED + "You're not allowed to remove this image!");
return false;
}
}
// Trigger image removal
YamipaPlugin.getInstance().getRenderer().removeImage(image);
return true;
}
public static void clearImages(
@NotNull CommandSender sender,
@NotNull Location origin,
@@ -252,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,
@@ -265,6 +316,19 @@ public class ImageCommand {
images.removeIf(image -> !target.equals(image.getPlacedBy().getUniqueId()));
}
// Filter out images outside the permission scope of the sender
if (sender instanceof Player) {
Player senderAsPlayer = (Player) sender;
images.removeIf(image -> {
for (Location loc : image.getAllLocations()) {
if (!Permissions.canDestroy(senderAsPlayer, loc)) {
return true;
}
}
return false;
});
}
// Remove found images
for (FakeImage image : images) {
renderer.removeImage(image);
@@ -404,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,31 +52,32 @@ 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);
// Help command
root.withRequirement(sender ->
sender.hasPermission("yamipa.*") ||
sender.hasPermission("yamipa.clear") ||
sender.hasPermission("yamipa.describe") ||
sender.hasPermission("yamipa.download") ||
sender.hasPermission("yamipa.give") ||
sender.hasPermission("yamipa.list") ||
sender.hasPermission("yamipa.place") ||
sender.hasPermission("yamipa.remove") ||
sender.hasPermission("yamipa.top"))
root.withPermission(
"yamipa.command.clear", "yamipa.clear",
"yamipa.command.describe", "yamipa.describe",
"yamipa.command.download", "yamipa.download",
"yamipa.command.give", "yamipa.give",
"yamipa.command.list", "yamipa.list",
"yamipa.command.place", "yamipa.place",
"yamipa.command.remove.own", "yamipa.remove",
"yamipa.command.top", "yamipa.top"
)
.executes((sender, args) -> {
ImageCommand.showHelp(sender, (String) args[0]);
});
// Clear command
root.addSubcommand("clear")
.withPermission("yamipa.clear")
.withPermission("yamipa.command.clear", "yamipa.clear")
.withArgument(new IntegerArgument("x"))
.withArgument(new IntegerArgument("z"))
.withArgument(new WorldArgument("world"))
@@ -85,7 +88,7 @@ public class ImageCommandBridge {
ImageCommand.clearImages(sender, origin, (int) args[4], (OfflinePlayer) args[5]);
});
root.addSubcommand("clear")
.withPermission("yamipa.clear")
.withPermission("yamipa.command.clear", "yamipa.clear")
.withArgument(new IntegerArgument("x"))
.withArgument(new IntegerArgument("z"))
.withArgument(new WorldArgument("world"))
@@ -97,14 +100,14 @@ public class ImageCommandBridge {
// Describe command
root.addSubcommand("describe")
.withPermission("yamipa.describe")
.withPermission("yamipa.command.describe", "yamipa.describe")
.executesPlayer((player, __) -> {
ImageCommand.describeImage(player);
});
// Download command
root.addSubcommand("download")
.withPermission("yamipa.download")
.withPermission("yamipa.command.download", "yamipa.download")
.withArgument(new StringArgument("url"))
.withArgument(new StringArgument("filename"))
.executes((sender, args) -> {
@@ -113,34 +116,34 @@ public class ImageCommandBridge {
// Give subcommand
root.addSubcommand("give")
.withPermission("yamipa.give")
.withPermission("yamipa.command.give", "yamipa.give")
.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],
(int) args[4], (int) args[5], (int) args[6]);
});
root.addSubcommand("give")
.withPermission("yamipa.give")
.withPermission("yamipa.command.give", "yamipa.give")
.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);
});
root.addSubcommand("give")
.withPermission("yamipa.give")
.withPermission("yamipa.command.give", "yamipa.give")
.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);
@@ -148,13 +151,13 @@ public class ImageCommandBridge {
// List subcommand
root.addSubcommand("list")
.withPermission("yamipa.list")
.withPermission("yamipa.command.list", "yamipa.list")
.withArgument(new IntegerArgument("page", 1))
.executes((sender, args) -> {
ImageCommand.listImages(sender, (int) args[1]);
});
root.addSubcommand("list")
.withPermission("yamipa.list")
.withPermission("yamipa.command.list", "yamipa.list")
.executes((sender, __) -> {
boolean isPlayer = (sender instanceof Player);
ImageCommand.listImages(sender, isPlayer ? 1 : 0);
@@ -162,27 +165,27 @@ public class ImageCommandBridge {
// Place subcommand
root.addSubcommand("place")
.withPermission("yamipa.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]);
});
root.addSubcommand("place")
.withPermission("yamipa.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);
});
root.addSubcommand("place")
.withPermission("yamipa.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);
@@ -190,14 +193,14 @@ public class ImageCommandBridge {
// Remove subcommand
root.addSubcommand("remove")
.withPermission("yamipa.remove")
.withPermission("yamipa.command.remove.own", "yamipa.remove")
.executesPlayer((player, __) -> {
ImageCommand.removeImage(player);
});
// Top subcommand
root.addSubcommand("top")
.withPermission("yamipa.top")
.withPermission("yamipa.command.top", "yamipa.top")
.executes((sender, __) -> {
ImageCommand.showTopPlayers(sender);
});
@@ -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();
}
}
@@ -2,18 +2,25 @@ package io.josemmo.bukkit.plugin.packets;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.wrappers.BukkitConverters;
import com.comphenix.protocol.wrappers.WrappedDataValue;
import com.comphenix.protocol.wrappers.WrappedDataWatcher;
import io.josemmo.bukkit.plugin.utils.Internals;
import org.bukkit.Rotation;
import org.bukkit.inventory.ItemStack;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
public class EntityMetadataPacket extends PacketContainer {
private static final boolean USE_DATA_WATCHER;
private static final int ITEM_INDEX;
private static final int ROTATION_INDEX;
private final WrappedDataWatcher dataWatcher = new WrappedDataWatcher();
private final WrappedDataWatcher dataWatcher = new WrappedDataWatcher(); // For <= 1.19.2
private final List<WrappedDataValue> values = new ArrayList<>(); // For >= 1.19.3
static {
USE_DATA_WATCHER = (Internals.MINECRAFT_VERSION < 19.3f);
ITEM_INDEX = (Internals.MINECRAFT_VERSION < 17) ? 7 : 8;
ROTATION_INDEX = ITEM_INDEX + 1;
}
@@ -29,7 +36,11 @@ public class EntityMetadataPacket extends PacketContainer {
public @NotNull EntityMetadataPacket setFlags(byte flags) {
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.get(Byte.class);
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(0, serializer), flags);
if (USE_DATA_WATCHER) {
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(0, serializer), flags);
} else {
values.add(new WrappedDataValue(0, serializer, flags));
}
return this;
}
@@ -40,18 +51,37 @@ public class EntityMetadataPacket extends PacketContainer {
public @NotNull EntityMetadataPacket setItem(@NotNull ItemStack item) {
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.getItemStackSerializer(false);
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(ITEM_INDEX, serializer), item);
if (USE_DATA_WATCHER) {
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(ITEM_INDEX, serializer), item);
} else {
values.add(new WrappedDataValue(
ITEM_INDEX,
serializer,
BukkitConverters.getItemStackConverter().getGeneric(item)
));
}
return this;
}
public @NotNull EntityMetadataPacket setRotation(@NotNull Rotation rotation) {
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.get(Integer.class);
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(ROTATION_INDEX, serializer), rotation.ordinal());
if (USE_DATA_WATCHER) {
dataWatcher.setObject(
new WrappedDataWatcher.WrappedDataWatcherObject(ROTATION_INDEX, serializer),
rotation.ordinal()
);
} else {
values.add(new WrappedDataValue(ROTATION_INDEX, serializer, rotation.ordinal()));
}
return this;
}
public @NotNull EntityMetadataPacket build() {
getWatchableCollectionModifier().write(0, dataWatcher.getWatchableObjects());
if (USE_DATA_WATCHER) {
getWatchableCollectionModifier().write(0, dataWatcher.getWatchableObjects());
} else {
getDataValueCollectionModifier().write(0, values);
}
return this;
}
}
@@ -1,26 +1,49 @@
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 java.util.logging.Level;
import org.jetbrains.annotations.Nullable;
import java.lang.reflect.Field;
public abstract class FakeEntity {
protected static final YamipaPlugin plugin = YamipaPlugin.getInstance();
private static final ProtocolManager connection = ProtocolLibrary.getProtocolManager();
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()) {
if (field.getType().equals(PlayerInjectionHandler.class)) {
field.setAccessible(true);
PLAYER_INJECTION_HANDLER = (PlayerInjectionHandler) field.get(CONNECTION);
break;
}
}
if (PLAYER_INJECTION_HANDLER == null) {
throw new RuntimeException("No valid candidate field found in ProtocolManager");
}
} catch (Exception e) {
LOGGER.severe("Failed to get PlayerInjectionHandler from ProtocolLib", e);
}
}
/**
* Wait for ProtocolLib to be ready
* <p>
* NOTE: Will wait synchronously, blocking the invoker thread
*/
protected static synchronized void waitForProtocolLib() {
if (ready) {
public static synchronized void waitForProtocolLib() {
if (READY) {
// ProtocolLib is ready
return;
}
@@ -29,22 +52,32 @@ public abstract class FakeEntity {
while (true) {
try {
WrappedDataWatcher.Registry.get(Byte.class);
ready = true;
READY = true;
break;
} catch (Exception e) {
if (++retry > 3) {
if (++retry > 20) {
// Exhausted max. retries
throw e;
}
try {
Thread.sleep(1000);
} catch (Exception __) {
// Fail silently
}
tryToSleep(200);
}
}
}
/**
* Try to sleep
* <p>
* NOTE: Will wait synchronously, blocking the invoker thread
* @param ms Delay in milliseconds
*/
protected static void tryToSleep(long ms) {
try {
Thread.sleep(ms);
} catch (Exception __) {
// Fail silently
}
}
/**
* Try to send packet
* @param player Player who will receive the packet
@@ -52,11 +85,32 @@ public abstract class FakeEntity {
*/
protected static void tryToSendPacket(@NotNull Player player, @NotNull PacketContainer packet) {
try {
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)
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);
}
}
@@ -65,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)
@@ -32,7 +41,6 @@ public class FakeImage extends FakeEntity {
public static final int DEFAULT_GIVE_FLAGS = FLAG_ANIMATABLE | FLAG_REMOVABLE | FLAG_DROPPABLE;
// Instance properties
private static boolean animateImages = false;
private final String filename;
private final Location location;
private final BlockFace face;
@@ -43,33 +51,19 @@ public class FakeImage extends FakeEntity {
private final OfflinePlayer placedBy;
private final int flags;
private final BiFunction<Integer, Integer, Vector> getLocationVector;
private Runnable onLoadedListener = null;
private final Set<Player> observingPlayers = new HashSet<>();
private @Nullable Runnable onLoadedListener = null;
// Generated values
private boolean loading = false;
private FakeItemFrame[] frames = null;
private int delay = 0; // Delay between steps in 50ms intervals, "0" for N/A
private int numOfSteps = -1; // Total number of animation steps
// Animation task attributes
private ScheduledFuture<?> task;
private final Set<Player> animatingPlayers = new HashSet<>();
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
@@ -96,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;
}
@@ -173,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 + ")");
}
/**
@@ -189,11 +203,11 @@ 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);
}
/**
* Get location instance
* Get top-left corner of image
* @return Location instance
*/
public @NotNull Location getLocation() {
@@ -273,19 +287,31 @@ public class FakeImage extends FakeEntity {
return delay;
}
/**
* Get all block locations covered by the image
* @return Array of location instances
*/
public @NotNull Location[] getAllLocations() {
Location[] locations = new Location[width*height];
for (int row=0; row<height; row++) {
for (int col=0; col<width; col++) {
locations[row*width + col] = location.clone().add(getLocationVector.apply(col, row));
}
}
return locations;
}
/**
* Get the world area IDs where this image is located
* @return Array of world area IDs
*/
public @NotNull WorldAreaId[] getWorldAreaIds() {
WorldAreaId topLeft = WorldAreaId.fromLocation(location);
WorldAreaId bottomRight = WorldAreaId.fromLocation(
location.clone().add(getLocationVector.apply(width-1, height-1))
);
if (topLeft.equals(bottomRight)) {
return new WorldAreaId[]{topLeft};
}
return new WorldAreaId[]{topLeft, bottomRight};
Set<WorldAreaId> corners = new HashSet<>();
corners.add(WorldAreaId.fromLocation(location));
corners.add(WorldAreaId.fromLocation(location.clone().add(getLocationVector.apply(width-1, 0))));
corners.add(WorldAreaId.fromLocation(location.clone().add(getLocationVector.apply(0, height-1))));
corners.add(WorldAreaId.fromLocation(location.clone().add(getLocationVector.apply(width-1, height-1))));
return corners.toArray(new WorldAreaId[0]);
}
/**
@@ -294,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) {
@@ -334,28 +361,42 @@ 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
frames = new FakeItemFrame[width*height];
FakeItemFrame[] newFrames = new FakeItemFrame[width*height];
boolean glowing = hasFlag(FLAG_GLOWING);
for (int col=0; col<width; col++) {
for (int row=0; row<height; row++) {
Location frameLocation = location.clone().add(getLocationVector.apply(col, row));
frames[height*col+row] = new FakeItemFrame(frameLocation, face, rotation, glowing, maps[col][row]);
newFrames[height*col+row] = new FakeItemFrame(frameLocation, face, rotation, glowing, maps[col][row]);
}
}
frames = newFrames;
// Start animation task (if needed)
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
);
LOGGER.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")");
}
// Notify listener
if (onLoadedListener != null) {
@@ -369,86 +410,136 @@ public class FakeImage extends FakeEntity {
* @param player Player instance
*/
public void spawn(@NotNull Player player) {
LOGGER.fine("Received request to spawn FakeImage#(" + location + "," + face + ") for Player#" + player.getName());
// Send pixels if instance is already loaded
if (frames != null) {
spawnOnceLoaded(player);
return;
}
// Wait for instance to load if loading
if (loading) {
tryToRunAsyncTask(() -> {
while (loading) {
tryToSleep(5);
}
spawnOnceLoaded(player);
});
return;
}
// Instance needs to be loaded
loading = true;
tryToRunAsyncTask(() -> {
synchronized (this) {
waitForProtocolLib();
// Load frames and other generated values from disk if not already loaded
if (frames == null) {
load();
}
// Spawn frames in player's client
for (FakeItemFrame frame : frames) {
frame.spawn(player);
frame.render(player, 0);
}
// Add player to animation task
if (animateImages && hasFlag(FLAG_ANIMATABLE) && numOfSteps > 1) {
animatingPlayers.add(player);
if (task == null) {
task = plugin.getScheduler().scheduleAtFixedRate(
this::nextStep,
0,
delay*50L,
TimeUnit.MILLISECONDS
);
plugin.fine("Spawned animation task for FakeImage#(" + location + "," + face + ")");
}
}
}
waitForProtocolLib();
load();
loading = false;
spawnOnceLoaded(player);
});
}
/**
* Destroy image for a player
* Spawn image for a player (once instance has been loaded)
* @param player Player instance
*/
public void destroy(@NotNull Player player) {
final FakeItemFrame[] framesRef = frames; // In case renderer FakeImage#invalidate() gets called
tryToRunAsyncTask(() -> {
synchronized (this) {
// Unregister player from animation task
animatingPlayers.remove(player);
if (animatingPlayers.isEmpty()) {
destroyAnimationTask();
}
private void spawnOnceLoaded(@NotNull Player player) {
String playerName = player.getName();
observingPlayers.add(player);
// Send packets to destroy item frames
if (framesRef != null) {
for (FakeItemFrame frame : framesRef) {
frame.destroy(player);
}
}
}
});
// Prepare packets to send
List<PacketContainer> packets = new ArrayList<>();
for (FakeItemFrame frame : frames) {
packets.add(frame.getSpawnPacket());
packets.addAll(frame.getRenderPackets(player, 0));
LOGGER.fine("Spawned FakeItemFrame#" + frame.getId() + " for Player#" + playerName);
}
// Send packets
tryToSendPackets(player, packets);
}
/**
* Invalidate cache
* <p>
* Removes all item frames associated with this image.
* Destroy image for all players
*/
public void invalidate() {
tryToRunAsyncTask(() -> {
synchronized (this) {
// Clear animation task (if exists)
destroyAnimationTask();
animatingPlayers.clear();
public void destroy() {
if (frames != null) {
destroy(null);
}
}
// Free array of fake item frames
frames = null;
/**
* Destroy image a player
* @param player Player instance or NULL for all observing players
*/
public void destroy(@Nullable Player player) {
LOGGER.fine(
"Received request to destroy FakeImage#(" + location + "," + face + ") for " +
(player == null ? "all players" : "Player#" + player.getName())
);
// Notify invalidation to source ImageFile
ImageFile file = getFile();
if (file != null) {
file.unsubscribe(this);
// Send packets to destroy item frames
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) {
packets.add(frame.getDestroyPacket());
LOGGER.fine("Destroyed FakeItemFrame#" + frame.getId() + " for Player#" + targetName);
}
plugin.fine("Invalidated FakeImage#(" + location + "," + face + ")");
tryToSendPackets(target, packets);
}
});
}
// Remove player from observing players
if (player == null) {
observingPlayers.clear();
} else {
observingPlayers.remove(player);
}
// Invalidate instance (if no more players)
if (observingPlayers.isEmpty()) {
invalidate();
}
}
/**
* Notify player quit from server
* @param player Player instance
*/
public void notifyPlayerQuit(@NotNull Player player) {
observingPlayers.remove(player);
if (observingPlayers.isEmpty()) {
invalidate();
}
}
/**
* Invalidate instance
* <p>
* Removes all item frames associated with this image, among other things.
*/
private void invalidate() {
// Destroy animation task
if (task != null) {
task.cancel(true);
task = null;
currentStep = -1;
LOGGER.fine("Destroyed animation task for FakeImage#(" + location + "," + face + ")");
}
// Free array of fake item frames
frames = null;
loading = false;
LOGGER.fine("Invalidated FakeImage#(" + location + "," + face + ")");
// Notify invalidation to source ImageFile
ImageFile file = getFile();
if (file != null) {
file.unsubscribe(this);
}
}
/**
@@ -456,24 +547,18 @@ public class FakeImage extends FakeEntity {
*/
private void nextStep() {
currentStep = (currentStep + 1) % numOfSteps;
for (Player player : animatingPlayers) {
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).
}
}
/**
* Destroy animation task
*/
private void destroyAnimationTask() {
if (task == null) {
// No active animation task
return;
}
task.cancel(true);
task = null;
currentStep = -1;
plugin.fine("Destroyed animation task for FakeImage#(" + location + "," + face + ")");
}
}
@@ -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,21 +128,24 @@ 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());
// Send pixels for all linked maps
for (FakeMap map : maps) {
map.sendPixels(player);
}
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) {
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));
NbtCompound itemStackNbt = NbtFactory.ofCompound("tag");
@@ -137,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 = 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,27 +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();
if (now-lastPlayerSendTime.getOrDefault(uuid, 0L) <= RESEND_THRESHOLD) {
return;
// Has enough time passed since last re-send?
long last = lastPlayerSendTime.getOrDefault(uuid, 0L);
if ((now-last) <= RESEND_THRESHOLD && (player.getLastPlayed()/1000) < last) {
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,43 +2,67 @@ 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, WorldArea> worldAreas = new ConcurrentHashMap<>();
private final ConcurrentMap<WorldAreaId, Set<FakeImage>> images = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Integer> imagesCountByPlayer = new ConcurrentHashMap<>();
private final Map<UUID, WorldAreaId> playersLocation = new HashMap<>();
private final Map<Player, WorldAreaId> playersLocation = new HashMap<>();
/**
* 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);
}
@@ -57,8 +82,10 @@ public class ImageRenderer implements Listener {
HandlerList.unregisterAll(this);
// Destroy images from remote clients
for (WorldArea worldArea : worldAreas.values()) {
worldArea.unload();
for (Set<FakeImage> fakeImagesPart : images.values()) {
for (FakeImage fakeImage : fakeImagesPart) {
fakeImage.destroy();
}
}
// Persist configuration
@@ -66,14 +93,19 @@ public class ImageRenderer implements Listener {
saveTask.cancel();
}
saveConfig();
// Clear dangling references
images.clear();
imagesCountByPlayer.clear();
playersLocation.clear();
}
/**
* 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;
}
@@ -82,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;
}
@@ -90,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);
@@ -113,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);
}
}
}
@@ -121,13 +153,14 @@ public class ImageRenderer implements Listener {
/**
* Save configuration to disk
*/
@SuppressWarnings("ExtractMethodRecommender")
private void saveConfig() {
if (!hasConfigChanged.get()) return;
// Get all fake images
Set<FakeImage> fakeImages = new HashSet<>();
for (WorldArea worldArea : worldAreas.values()) {
fakeImages.addAll(worldArea.getImages());
for (Set<FakeImage> fakeImagesPart : images.values()) {
fakeImages.addAll(fakeImagesPart);
}
// Placed here so, if another change comes while saving, we don't lose those changes (will be saved later)
@@ -158,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);
}
}
@@ -172,13 +205,12 @@ public class ImageRenderer implements Listener {
public void addImage(@NotNull FakeImage image, boolean isInit) {
WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds();
// Add image to world area(s)
// Add image to renderer
for (WorldAreaId worldAreaId : imageWorldAreaIds) {
WorldArea worldArea = worldAreas.computeIfAbsent(worldAreaId, __ -> {
plugin.fine("Created WorldArea#(" + worldAreaId + ")");
return new WorldArea(worldAreaId);
});
worldArea.addImage(image);
images.computeIfAbsent(worldAreaId, __ -> {
LOGGER.fine("Created WorldArea#(" + worldAreaId + ")");
return ConcurrentHashMap.newKeySet();
}).add(image);
}
// Set configuration changed flag
@@ -191,7 +223,7 @@ public class ImageRenderer implements Listener {
imagesCountByPlayer.compute(placedById, (__, prev) -> (prev == null) ? 1 : prev+1);
// Spawn image in players nearby
for (Player player : getPlayersInNeighborhood(imageWorldAreaIds)) {
for (Player player : getPlayersInViewDistance(imageWorldAreaIds)) {
image.spawn(player);
}
}
@@ -212,13 +244,13 @@ public class ImageRenderer implements Listener {
*/
public @Nullable FakeImage getImage(@NotNull Location location, @NotNull BlockFace face) {
WorldAreaId worldAreaId = WorldAreaId.fromLocation(location);
WorldArea worldArea = worldAreas.get(worldAreaId);
if (worldArea == null) {
Set<FakeImage> candidateImages = images.get(worldAreaId);
if (candidateImages == null) {
return null;
}
// Find first fake image containing the given location
for (FakeImage image : worldArea.getImages()) {
for (FakeImage image : candidateImages) {
if (image.contains(location, face)) {
return image;
}
@@ -235,13 +267,13 @@ 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<>();
for (WorldArea worldArea : worldAreas.values()) {
if (!worldArea.getId().getWorld().getName().equals(world.getName())) continue;
for (FakeImage image : worldArea.getImages()) {
for (Map.Entry<WorldAreaId, Set<FakeImage>> entry : images.entrySet()) {
if (!entry.getKey().getWorld().getName().equals(world.getName())) continue;
for (FakeImage image : entry.getValue()) {
Location loc = image.getLocation();
if (loc.getBlockX() < minX || loc.getBlockX() > maxX) continue;
if (loc.getBlockZ() < minZ || loc.getBlockZ() > maxZ) continue;
@@ -258,19 +290,16 @@ public class ImageRenderer implements Listener {
public void removeImage(@NotNull FakeImage image) {
WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds();
// Destroy image from players nearby
for (Player player : getPlayersInNeighborhood(imageWorldAreaIds)) {
image.destroy(player);
}
image.invalidate();
// Destroy image from all players nearby
image.destroy();
// Remove image from world area(s)
// Remove image from renderer
for (WorldAreaId worldAreaId : imageWorldAreaIds) {
WorldArea worldArea = worldAreas.get(worldAreaId);
worldArea.removeImage(image);
if (!worldArea.hasImages()) {
plugin.fine("Destroyed WorldArea#(" + worldAreaId + ")");
worldAreas.remove(worldAreaId);
Set<FakeImage> worldAreaImages = images.get(worldAreaId);
worldAreaImages.remove(image);
if (worldAreaImages.isEmpty()) {
LOGGER.fine("Destroyed WorldArea#(" + worldAreaId + ")");
images.remove(worldAreaId);
}
}
@@ -317,84 +346,77 @@ public class ImageRenderer implements Listener {
}
/**
* Get available world areas from IDs
* Get players in view distance of the provided world area IDs
* @param ids World area IDs
* @return World area instances
*/
private @NotNull Set<WorldArea> getWorldAreas(@NotNull WorldAreaId[] ids) {
Set<WorldArea> instances = new HashSet<>();
for (WorldAreaId id : ids) {
if (!worldAreas.containsKey(id)) continue;
instances.add(worldAreas.get(id));
}
return instances;
}
/**
* Get players in neighborhood
* @param ids World area IDs to compute neighborhood from
* @return Players inside those world areas
*/
private @NotNull Set<Player> getPlayersInNeighborhood(@NotNull WorldAreaId[] ids) {
private @NotNull Set<Player> getPlayersInViewDistance(@NotNull WorldAreaId[] ids) {
Set<WorldAreaId> neighborhood = new HashSet<>();
for (WorldAreaId worldAreaId : ids) {
Collections.addAll(neighborhood, worldAreaId.getNeighborhood());
}
Set<Player> players = new HashSet<>();
for (Map.Entry<UUID, WorldAreaId> entry : playersLocation.entrySet()) {
for (Map.Entry<Player, WorldAreaId> entry : playersLocation.entrySet()) {
if (neighborhood.contains(entry.getValue())) {
UUID uuid = entry.getKey();
Player player = Bukkit.getPlayer(uuid);
if (player == null) {
plugin.warning("Failed to get online player with UUID " + uuid);
continue;
}
players.add(player);
players.add(entry.getKey());
}
}
return players;
}
/**
* Get images in view distance from world area ID
* @param worldAreaId World area ID
* @return Set of fake images
*/
private @NotNull Set<FakeImage> getImagesInViewDistance(@NotNull WorldAreaId worldAreaId) {
Set<FakeImage> response = new HashSet<>();
for (WorldAreaId target : worldAreaId.getNeighborhood()) {
Set<FakeImage> targetImages = images.get(target);
if (targetImages != null) {
response.addAll(targetImages);
}
}
return response;
}
/**
* On player location change
* @param player Player instance
* @param location New player location
*/
private void onPlayerLocationChange(@NotNull Player player, @NotNull Location location) {
UUID uuid = player.getUniqueId();
// Ignore NPC events from other plugins
if (player.hasMetadata("NPC")) {
LOGGER.fine("Ignored NPC event from Player#" + player.getName());
return;
}
// Has player moved to another world area?
WorldAreaId worldAreaId = WorldAreaId.fromLocation(location);
WorldAreaId prevWorldAreaId = playersLocation.get(uuid);
WorldAreaId prevWorldAreaId = playersLocation.get(player);
if (worldAreaId.equals(prevWorldAreaId)) {
return;
}
playersLocation.put(uuid, worldAreaId);
plugin.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")");
playersLocation.put(player, worldAreaId);
LOGGER.fine("Player#" + player.getName() + " moved to WorldArea#(" + worldAreaId + ")");
// Get world areas that should be loaded/unloaded
Set<WorldArea> desiredState = getWorldAreas(worldAreaId.getNeighborhood());
Set<WorldArea> currentState;
if (prevWorldAreaId == null) {
currentState = new HashSet<>();
} else {
currentState = getWorldAreas(prevWorldAreaId.getNeighborhood());
}
Set<WorldArea> areasToLoad = new HashSet<>(desiredState);
areasToLoad.removeAll(currentState);
Set<WorldArea> areasToUnload = new HashSet<>(currentState);
areasToUnload.removeAll(desiredState);
// Get images that should be spawned/destroyed
Set<FakeImage> desiredState = getImagesInViewDistance(worldAreaId);
Set<FakeImage> currentState = (prevWorldAreaId == null) ? new HashSet<>() : getImagesInViewDistance(prevWorldAreaId);
Set<FakeImage> imagesToLoad = new HashSet<>(desiredState);
imagesToLoad.removeAll(currentState);
Set<FakeImage> imagesToUnload = new HashSet<>(currentState);
imagesToUnload.removeAll(desiredState);
// Load/unload world areas
for (WorldArea area : areasToUnload) {
area.unload(player);
plugin.fine("Unloaded WorldArea#(" + area.getId() + ") for Player#" + player.getName());
// Spawn/destroy images
for (FakeImage image : imagesToUnload) {
image.destroy(player);
}
for (WorldArea area : areasToLoad) {
area.load(player);
plugin.fine("Loaded WorldArea#(" + area.getId() + ") for Player#" + player.getName());
for (FakeImage image : imagesToLoad) {
image.spawn(player);
}
}
@@ -406,17 +428,15 @@ public class ImageRenderer implements Listener {
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onPlayerQuit(@NotNull PlayerQuitEvent event) {
Player player = event.getPlayer();
UUID uuid = player.getUniqueId();
// Get player's current world area ID
WorldAreaId worldAreaId = playersLocation.get(uuid);
WorldAreaId worldAreaId = playersLocation.get(player);
if (worldAreaId == null) return;
playersLocation.remove(uuid);
playersLocation.remove(player);
// Notify world areas that player quit
Set<WorldArea> loadedWorldAreas = getWorldAreas(worldAreaId.getNeighborhood());
for (WorldArea worldArea : loadedWorldAreas) {
worldArea.removePlayer(player);
for (FakeImage image : getImagesInViewDistance(worldAreaId)) {
image.notifyPlayerQuit(player);
}
}
@@ -432,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)
@@ -443,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());
}
}
}
}
@@ -3,7 +3,9 @@ package io.josemmo.bukkit.plugin.renderer;
import io.josemmo.bukkit.plugin.YamipaPlugin;
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;
@@ -26,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
@@ -47,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);
@@ -63,6 +73,7 @@ public class ItemService extends InteractWithEntityListener implements Listener
*/
public void start() {
register();
YamipaPlugin plugin = YamipaPlugin.getInstance();
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}
@@ -95,72 +106,119 @@ public class ItemService extends InteractWithEntityListener implements Listener
@EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST)
public void onPlaceItem(@NotNull HangingPlaceEvent event) {
Player player = event.getPlayer();
ItemStack item = event.getItemStack();
if (player == null || item == null) return;
if (player == null) return;
PlayerInventory inventory = player.getInventory();
// Get metadata from item
ItemMeta itemMeta = item.getItemMeta();
if (itemMeta == null) return;
PersistentDataContainer itemData = itemMeta.getPersistentDataContainer();
String filename = itemData.get(NSK_FILENAME, PersistentDataType.STRING);
if (filename == null) return;
Integer width = itemData.get(NSK_WIDTH, PersistentDataType.INTEGER);
Integer height = itemData.get(NSK_HEIGHT, PersistentDataType.INTEGER);
Integer flags = itemData.get(NSK_FLAGS, PersistentDataType.INTEGER);
// Prepare variables to hold item metadata
boolean isOffHand = false;
ItemStack item = null;
String filename = null;
Integer width = null;
Integer height = null;
Integer flags = null;
// Find placed item stack in player's inventory
// NOTE: we cannot use `event.getItemStack()` as it's only supported since 1.17.1
for (int i=0; i<2; i++) {
isOffHand = (i == 1);
item = isOffHand ? inventory.getItemInOffHand() : inventory.getItemInMainHand();
// Get item metadata
ItemMeta itemMeta = item.getItemMeta();
if (itemMeta == null) continue;
PersistentDataContainer itemData = itemMeta.getPersistentDataContainer();
filename = itemData.get(NSK_FILENAME, PersistentDataType.STRING);
if (filename == null) continue;
// Found item, parse metadata
width = itemData.get(NSK_WIDTH, PersistentDataType.INTEGER);
height = itemData.get(NSK_HEIGHT, PersistentDataType.INTEGER);
flags = itemData.get(NSK_FLAGS, PersistentDataType.INTEGER);
break;
}
// Validate item and metadata
if (filename == null) {
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)");
player.sendMessage(ChatColor.RED + "Image file \"" + 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;
}
// Prevent item frame placing
event.setCancelled(true);
// Validate player permissions
if (!player.hasPermission("yamipa.item.place")) {
ActionBar.send(player, ChatColor.RED + "You're not allowed to place image items!");
return;
}
// Try to place image in world
Location location = event.getBlock().getLocation();
boolean success = ImageCommand.placeImage(player, image, width, height, flags, location, event.getBlockFace());
if (!success) return;
// Decrement item from player's inventory
if (player.getGameMode() == GameMode.CREATIVE) return;
PlayerInventory inventory = player.getInventory();
int itemIndex = inventory.first(item);
int amount = item.getAmount();
if (amount > 1) {
item.setAmount(amount - 1);
inventory.setItem(itemIndex, item);
} else {
inventory.setItem(itemIndex, new ItemStack(Material.AIR));
if (player.getGameMode() != GameMode.CREATIVE) {
int amount = item.getAmount();
if (amount > 1) {
item.setAmount(amount - 1);
} else {
item = new ItemStack(Material.AIR);
}
if (isOffHand) {
inventory.setItemInOffHand(item);
} else {
inventory.setItemInMainHand(item);
}
}
}
@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?
FakeImage image = renderer.getImage(location, face);
if (image == null || !image.hasFlag(FakeImage.FLAG_REMOVABLE)) return true;
// Remove image from renderer
renderer.removeImage(image);
// Validate player permissions
if (!player.hasPermission("yamipa.item.remove.own")) {
ActionBar.send(player, ChatColor.RED + "You're not allowed to remove image items!");
return true;
}
if (
!player.getUniqueId().equals(image.getPlacedBy().getUniqueId()) &&
!player.hasPermission("yamipa.item.remove")
) {
ActionBar.send(player, ChatColor.RED + "You cannot remove image items from other players!");
return true;
}
// Attempt to remove image
boolean success = ImageCommand.removeImage(player, image);
if (!success) {
return true;
}
// Drop image item
if (player.getGameMode() == GameMode.SURVIVAL && image.hasFlag(FakeImage.FLAG_DROPPABLE)) {
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;
@@ -1,117 +0,0 @@
package io.josemmo.bukkit.plugin.renderer;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
/**
* A World Area is a group of 16 chunks arranged in a 4x4 square.
*/
public class WorldArea {
private final WorldAreaId id;
private final Set<FakeImage> fakeImages = ConcurrentHashMap.newKeySet();
private final Set<Player> players = new HashSet<>();
/**
* Class constructor
* @param id World area ID
*/
public WorldArea(@NotNull WorldAreaId id) {
this.id = id;
// Initialize current list of players in this area
for (Player player : id.getWorld().getPlayers()) {
WorldAreaId playerWorldAreaId = WorldAreaId.fromLocation(player.getLocation());
if (id.equals(playerWorldAreaId)) {
players.add(player);
}
}
}
/**
* Get world area ID
* @return World area ID
*/
public @NotNull WorldAreaId getId() {
return id;
}
/**
* Contains fake images
* @return TRUE if instance contains fake images, FALSE otherwise
*/
public boolean hasImages() {
return !fakeImages.isEmpty();
}
/**
* Get all fake images
* @return Set of fake images
*/
public @NotNull Set<FakeImage> getImages() {
return fakeImages;
}
/**
* Add fake image
* @param image Fake image instance
*/
public void addImage(@NotNull FakeImage image) {
fakeImages.add(image);
}
/**
* Remove fake image
* @param image Fake image instance
*/
public void removeImage(@NotNull FakeImage image) {
fakeImages.remove(image);
}
/**
* Load for player
* @param player Player instance
*/
public void load(@NotNull Player player) {
players.add(player);
for (FakeImage image : fakeImages) {
image.spawn(player);
}
}
/**
* Remove player from world area
* @param player Player instance
*/
public void removePlayer(@NotNull Player player) {
players.remove(player);
if (players.isEmpty()) {
for (FakeImage image : fakeImages) {
image.invalidate();
}
}
}
/**
* Unload for player
* @param player Player instance
*/
public void unload(@NotNull Player player) {
for (FakeImage image : fakeImages) {
image.destroy(player);
}
removePlayer(player);
}
/**
* Unload for all players
*/
public void unload() {
for (Player player : new ArrayList<>(players)) {
unload(player);
}
}
}
@@ -1,16 +1,33 @@
package io.josemmo.bukkit.plugin.renderer;
import org.bukkit.Bukkit;
import org.bukkit.Chunk;
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;
/**
* World Area IDs represent groups of 16 chunks arranged in a 4x4 square.
*/
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;
private WorldAreaId[] neighborhood = null;
static {
try {
World.class.getMethod("getViewDistance");
} catch (Exception e) {
USE_WORLD_VIEW_DISTANCE = false;
}
}
/**
* Get ID from location
@@ -43,21 +60,340 @@ public class WorldAreaId {
}
/**
* Get colliding world area IDs plus this one
* @return List of neighbors
* Get nearby world area IDs in view distance (plus this one)
* @return Array of neighbors
*/
public @NotNull WorldAreaId[] getNeighborhood() {
return new WorldAreaId[]{
// Get value from cache (if available)
if (neighborhood != null) {
return neighborhood;
}
// Calculate neighborhood size from world's view distance
// 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 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
// XXXX|XXXX
// ·XXX|XXX·
// ·XXX|XXX·
// ···X|X···
if (size == 3) {
neighborhood = new WorldAreaId[]{
new WorldAreaId(world, x-1, z-4),
new WorldAreaId(world, x, z-4),
new WorldAreaId(world, x+1, z-4),
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-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-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-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-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-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-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-1, z+4),
new WorldAreaId(world, x, z+4),
new WorldAreaId(world, x+1, z+4),
};
return neighborhood;
}
// 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-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+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;
}
@Override
@@ -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");
}
@@ -17,6 +17,7 @@ import java.util.List;
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
@@ -81,12 +82,16 @@ public abstract class InteractWithEntityListener implements PacketListener {
action = event.getPacket().getEnumEntityUseActions().read(0).getAction();
}
// Notify handler
// Notify handler synchronously
boolean allowEvent = true;
if (action == EnumWrappers.EntityUseAction.ATTACK) {
allowEvent = onAttack(player, targetBlock, targetBlockFace);
} else if (action == EnumWrappers.EntityUseAction.INTERACT_AT) {
allowEvent = onInteract(player, targetBlock, targetBlockFace);
try {
if (action == EnumWrappers.EntityUseAction.ATTACK) {
allowEvent = onAttack(player, targetBlock, targetBlockFace);
} else if (action == EnumWrappers.EntityUseAction.INTERACT_AT) {
allowEvent = onInteract(player, targetBlock, targetBlockFace);
}
} catch (Exception 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);
}
}
@@ -0,0 +1,193 @@
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 {
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 __) {
// WorldGuard is not installed
}
try {
griefPrevention = GriefPrevention.instance;
} catch (NoClassDefFoundError __) {
// GriefPrevention is not installed
}
try {
townyApi = TownyAPI.getInstance();
} catch (NoClassDefFoundError __) {
// Towny is not installed
}
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;
}
}
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);
}
}
@@ -12,8 +12,6 @@ import org.bukkit.event.EventPriority;
import org.bukkit.event.HandlerList;
import org.bukkit.event.Listener;
import org.bukkit.event.block.Action;
import org.bukkit.event.player.PlayerAnimationEvent;
import org.bukkit.event.player.PlayerAnimationType;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.player.PlayerQuitEvent;
import org.jetbrains.annotations.NotNull;
@@ -24,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;
@@ -73,7 +71,7 @@ public class SelectBlockTask {
if (listener == null) {
listener = new SelectBlockTaskListener();
listener.register();
plugin.fine("Created PlayerInteractionListener singleton");
LOGGER.fine("Created SelectBlockTaskListener singleton");
}
// Start task
@@ -96,7 +94,7 @@ public class SelectBlockTask {
if (instances.isEmpty()) {
listener.unregister();
listener = null;
plugin.fine("Destroyed SelectBlockTaskListener singleton");
LOGGER.fine("Destroyed SelectBlockTaskListener singleton");
}
}
@@ -107,6 +105,7 @@ public class SelectBlockTask {
@Override
public void register() {
super.register();
YamipaPlugin plugin = YamipaPlugin.getInstance();
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}
@@ -143,18 +142,6 @@ public class SelectBlockTask {
}
}
@EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST)
public void onArmSwing(@NotNull PlayerAnimationEvent event) {
if (event.getAnimationType() != PlayerAnimationType.ARM_SWING) {
// Sanity check, vanilla Minecraft does not have any other player animation type
return;
}
boolean allowEvent = handle(event.getPlayer(), null, null);
if (!allowEvent) {
event.setCancelled(true);
}
}
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onPlayerQuit(@NotNull PlayerQuitEvent event) {
UUID uuid = event.getPlayer().getUniqueId();
+70 -2
View File
@@ -1,14 +1,28 @@
name: ${project.name}
version: ${project.version}
main: io.josemmo.bukkit.plugin.YamipaPlugin
api-version: 1.13
api-version: 1.16
depend: [ProtocolLib]
softdepend: [Multiverse-Core, Hyperverse, My_Worlds]
softdepend:
- BentoBox
- GriefPrevention
- GroupManager
- Hyperverse
- LuckPerms
- Multiverse-Core
- My_Worlds
- PhantomWorlds
- Towny
- WorldGuard
permissions:
# All plugin permissions
yamipa.*:
default: op
children:
yamipa.command.*: true
yamipa.item.*: true
# Legacy children
yamipa.clear: true
yamipa.describe: true
yamipa.download: true
@@ -17,6 +31,60 @@ permissions:
yamipa.place: true
yamipa.remove: true
yamipa.top: true
# All command permissions
yamipa.command.*:
default: op
children:
yamipa.command.clear: true
yamipa.command.describe: true
yamipa.command.download: true
yamipa.command.give: true
yamipa.command.list: true
yamipa.command.place: true
yamipa.command.remove: true
yamipa.command.top: true
# Command permissions
yamipa.command.clear:
default: op
yamipa.command.describe:
default: op
yamipa.command.download:
default: op
yamipa.command.give:
default: op
yamipa.command.list:
default: op
yamipa.command.place:
default: op
yamipa.command.remove:
default: op
children:
yamipa.command.remove.own: true
yamipa.command.remove.own:
default: op
yamipa.command.top:
default: op
# All item permissions
yamipa.item.*:
default: true
children:
yamipa.item.place: true
yamipa.item.remove: true
# Item permissions
yamipa.item.place:
default: true
yamipa.item.remove:
default: true
children:
yamipa.item.remove.own: true
yamipa.item.remove.own:
default: true
# Legacy permissions
yamipa.clear:
default: op
yamipa.describe: