Compare commits

...
274 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
José Miguel Moreno 267946ae59 Merge pull request #44 from josemmo/develop
v1.2.3
2022-06-15 11:21:09 +02:00
José Miguel Moreno 90e0f9463b v1.2.3 2022-06-15 11:19:01 +02:00
José Miguel Moreno 246d8cf374 Updated ProtocolLib version in test workflow
- Updated tests.yml
2022-06-15 11:11:30 +02:00
José Miguel Moreno 07b9fc1cc9 Updated CI/CD workflows
- Updated build.yml
- Updated tests.yml
2022-06-15 11:00:55 +02:00
José Miguel Moreno 56b0559c92 Added support for 1.19
- Updated SpawnEntityPacket class
- Upgraded dependencies
- Updated README.md
2022-06-15 11:00:08 +02:00
José Miguel Moreno 2fd3e80501 v1.2.3-SNAPSHOT 2022-04-12 14:31:08 +02:00
José Miguel Moreno 69e82fbe83 Added giphy.com URL downloader
- Updated ImageCommand.downloadImage()
2022-04-12 14:27:03 +02:00
José Miguel Moreno 1f381495aa Added imgur.com URL downloader
- Updated ImageCommand.downloadImage()
2022-04-12 13:57:16 +02:00
José Miguel Moreno e25290281e Improved downloaded image validation
- Replaced Content-Type detection with ImageIO reading (Java's content type checking is too strict)
2022-04-12 13:37:23 +02:00
José Miguel Moreno c000079ca9 Improved error reporting at image download
- Updated ImageCommand.downloadImage()
2022-04-12 11:49:59 +02:00
José Miguel Moreno 7f12163765 Fixed typo in README.md
- Updated README.md

[skip-ci]
2022-03-20 13:06:58 +01:00
José Miguel Moreno 510e2f3ea7 Merge pull request #38 from tdherring/main
Added My_Worlds Softdepend
2022-03-20 11:33:21 +01:00
Tom Herring 015b8c4302 Added My_Worlds Softdepend
See https://github.com/josemmo/yamipa/issues/37
2022-03-20 11:24:44 +01:00
José Miguel Moreno 37d1d8300c Updated ProtocolLib in test workflow
- Switched to latest stable ProtocolLib release in test workflow
2022-03-13 13:55:14 +01:00
José Miguel Moreno c2d8ea45ac Upgraded mineflayer
- Updated automata depedencies
2022-03-13 13:46:32 +01:00
José Miguel Moreno 1cf0e546d1 Shorten help command
- Updated help command to not line break on "/yamipa give" line
2022-03-13 13:17:35 +01:00
José Miguel Moreno 160b74ab5d Upgraded dependencies
- Updated pom.xml
- Updated tests workflow
2022-03-13 13:14:12 +01:00
José Miguel Moreno c325dab2e2 Fixed exception when rendering GIFs
- Fixed ArrayIndexOutOfBoundsException in ImageFile#renderImages()

> Closes #30
2022-02-15 18:57:01 +01:00
José Miguel Moreno e7dfc2e570 Merge pull request #29 from josemmo/develop
v1.2.2
2022-01-29 11:28:39 +01:00
José Miguel Moreno 038f3c8d88 v1.2.2 2022-01-29 11:24:07 +01:00
José Miguel Moreno 2364d92184 Added support for transparent animated GIFs
- Updated ImageFile#renderImages()

> Closes #27
2022-01-29 11:20:45 +01:00
José Miguel Moreno 3d68399f37 Upgraded mineflayer
- Updated automata depedencies
- Added jsDoc block to bot.js
- Updated tests workflow versions
2022-01-23 11:42:18 +01:00
José Miguel Moreno 9c96c78f3a Fixed improper rendering on teleportation
- Removed unnecessary PlayerChangedWorldEvent listener (PlayerTeleportEvent also gets called)
- Fixed ImageRenderer#onPlayerTeleport()

> Related to #24
2022-01-16 20:48:03 +01:00
José Miguel Moreno 2b2086da67 Handled NullPointerException in Bukkit.getPlayer()
- Updated ImageRenderer#getPlayersInNeighborhood()

> Related to #22
2022-01-15 14:49:26 +01:00
José Miguel Moreno 2565161cc8 Fixed freeze of block updates
- Updated SelectBlockTask to allow the propagation of events from players without an active block selection task

> Related to #20
2022-01-15 14:47:12 +01:00
José Miguel Moreno b5b4708783 v1.2.2-SNAPSHOT 2022-01-06 20:29:42 +01:00
José Miguel Moreno 2012690609 Log warning on failed to download file
- Updated ImageCommand.downloadImage()
2022-01-06 20:29:00 +01:00
José Miguel Moreno ad9f112e75 Fixed selection of non-occluding blocks
- Updated InteractWithEntityListener#onPacketReceiving()

> Related to #20
2022-01-06 17:34:41 +01:00
José Miguel Moreno 904879555f Merge pull request #19 from josemmo/develop
v1.2.1
2022-01-05 10:52:25 +01:00
José Miguel Moreno 0c03386337 v1.2.1 2022-01-05 10:41:11 +01:00
José Miguel Moreno 1fcb3885b9 Implemented glowing images
- Updated FakeImage
- Updated FakeItemFrame

> Closes #16
2021-12-27 17:55:42 +01:00
José Miguel Moreno 99b9fe49af Added flags documentation
- Updated README.md
2021-12-27 17:32:41 +01:00
José Miguel Moreno 3e47310f09 Added flags argument
- Added GLOWING flag
- Created ImageFlagsArgument
- Updated commands
- Updated default flags constants
- Updated ImageService

> Related to #16
2021-12-27 17:27:51 +01:00
José Miguel Moreno dcbea17ca8 Updated image item drop location
- Updated ItemService#onAttack() listener

> Related to #10
2021-12-26 12:09:40 +01:00
José Miguel Moreno 5710f5abde v1.2.1-SNAPSHOT 2021-12-19 12:14:35 +01:00
José Miguel Moreno bf96e9b44f Fixed image placing to a different World Area
- Updated ImageRenderer to spawn/destroy images to all players in neighborhood
- Removed images spawning/destroying from WorldArea
- Added debug log calls in FakeItemFrame

> When a player tried to place/remove an image in a different WorldArea that he/she was currently at, the place/destroy packets were not sent correctly.
2021-12-19 12:07:44 +01:00
José Miguel Moreno 26b4527fda Added image item example
- Updated README.md
- Added demo-item.gif
- Removed sample-1.jpg and renamed sample-2.jpg
2021-12-11 12:27:00 +01:00
José Miguel Moreno 617436942c Upgraded Spigot API to 1.18.1
- Updated pom.xml
- Updated README.md
2021-12-11 12:25:03 +01:00
José Miguel Moreno a58ec4dfef Added priority to InteractWithEntityListener
- Updated InteractWithEntityListener
- Updated SelectBlockTask
2021-12-11 10:52:23 +01:00
José Miguel Moreno 7032771181 Implemented removable images
- Updated ImageService to handle removal of placed images

> Related to #10
2021-12-10 21:20:08 +01:00
José Miguel Moreno d85b6e0fec Split SelectBlockTask in two classes
- Created InteractWithEntityListener to only handle player-entity interaction events
- Updated SelectBlockTask to use new listener class
2021-12-10 20:40:35 +01:00
José Miguel Moreno e8ac05c954 Added flags to describe command
- Updated FakeImage
- Updated ImageCommand.describeImage()
2021-12-09 19:03:53 +01:00
José Miguel Moreno 3f550e9dcd Implemented Fake Image flags
- Added flags property to FakeImage
- Updated ImageRenderer to save and load FakeImage flags
- Updated ImageCommand#placeImage()
- Updated ItemService#onPlaceItem()
2021-12-08 18:17:19 +01:00
José Miguel Moreno 65a16aa4d1 Implemented image item placing
- Split ImageCommand.placeImage() in two methods
- Updated ItemService

> Related to #10
2021-12-07 16:05:04 +01:00
José Miguel Moreno 6019f6c701 Merge pull request #12 from Blanktopia/develop
Add Hyperverse as soft dependency
2021-12-06 21:42:53 +01:00
Goh Wei Wen d8977b03f4 Add Hyperverse as soft dependency 2021-12-06 13:21:23 +08:00
José Miguel Moreno 21d6d1727a Initial item service
- Created ItemService class
- Updated YamipaPlugin class
- Updated ImageCommand#giveImageItems()
2021-12-05 18:08:03 +01:00
José Miguel Moreno 55547c9448 Added "give" command
- Created OnlinePlayerArgument
- Updated ImageCommand and ImageCommandBridge
- Updated README.md

> Related to #10
2021-12-05 17:16:53 +01:00
José Miguel Moreno 981ee0c8a9 v1.2.0 2021-12-04 13:45:44 +01:00
José Miguel Moreno e506504b23 Updated JDK version in CI workflow
- Updated tests workflow
2021-12-04 13:34:10 +01:00
José Miguel Moreno 14932fe325 Updated CI/CD workflows
- Upgraded to Temurin JDK 17
- Added Minecraft 1.18 to tests workflow
2021-12-04 13:23:35 +01:00
José Miguel Moreno 26dbac9350 Added compatibility table to README.md
- Updated README.md
2021-12-04 13:15:34 +01:00
José Miguel Moreno 56085a8204 Added support for Minecraft 1.18
- Updated Internals class
- Upgraded dependencies

> Fixes #11
2021-12-04 13:14:29 +01:00
José Miguel Moreno ca93461378 Fixed PaperMC CI workflow
- Updated tests workflow
2021-12-02 19:37:32 +01:00
José Miguel Moreno 091958b008 Added loading message when placing images
- Added onLoadedListener to FakeImage
- Updated ImageCommand#placeImage()
2021-11-28 16:34:34 +01:00
José Miguel Moreno 7ae344773e Improved ActionBar class
- Updated ActionBar class to return instances instead of being fully static
- Updated ImageCommand and SelectBlockTask
2021-11-28 13:35:58 +01:00
José Miguel Moreno 461c75a605 Added animated images metric to bStats
- Added FakeImage#isAnimationEnabled() method
- Updated YamipaPlugin#onEnable()
2021-11-28 12:45:54 +01:00
José Miguel Moreno 3c3c09ac13 v1.2.0-SNAPSHOT 2021-10-17 13:17:02 +02:00
José Miguel Moreno 84c05ac712 Upgraded dependencies
- Updated repositories
- Upgraded bStats and JetBrains Annotations
2021-10-17 13:16:40 +02:00
José Miguel Moreno 6bc22e49c6 Merge pull request #9 from josemmo/feat/animated-images
Animated images, yay! 🎉
2021-10-17 13:03:00 +02:00
José Miguel Moreno 3f843fc3b8 Created custom executor service
- Created ScheduledExecutorService in YamipaPlugin to prevent halting the built-in Bukkit scheduler while rendering images
- Updated FakeEntity and FakeImage classes
2021-10-17 12:50:54 +02:00
José Miguel Moreno 3c9026cf83 Fixed server thread halting on player quit
- Moved all heavy tasks requiring instance synchronization to async pool
2021-10-17 12:48:10 +02:00
José Miguel Moreno 297ded94c6 Added check to wait for ProtocolLib to load
- Added FakeEntity#waitForProtocolLib() method
- Updated FakeImage class
2021-10-16 14:41:57 +02:00
José Miguel Moreno e66210a493 Fixed tasks synchronization
- Updated FakeImage to run heavy computations in other threads while keeping in sync with image instance
- Flattened FakeImage#frames for better performance
2021-10-16 14:36:05 +02:00
José Miguel Moreno 38d38a4ecc Improved packet sending
- Updated FakeImage to send spawn/destroy packets concurrently
- Updated FakeEntity helper methods
2021-10-10 18:26:31 +02:00
José Miguel Moreno f77d2501b5 Improved image rendering concurrency
- Updated ImageFile to allow rendering images from the same instance concurrently
- Minor syntax fixes
2021-10-10 18:20:42 +02:00
José Miguel Moreno c88b12095b Added animated image demo
- Created "demo-animated.gif" file
- Updated README.md
2021-10-10 12:34:02 +02:00
José Miguel Moreno 3fd0545972 Fixed exception on JPG metadata parsing
- Updated ImageFile#renderImages()
2021-10-09 18:22:14 +02:00
José Miguel Moreno 65aac6bb35 Step delay improvements
- Stored delay in 50ms intervals
- Updated "image describe" command
2021-10-09 18:09:29 +02:00
José Miguel Moreno 80eac8c53c Added GIF dynamic delay support
- Created FakeMapsContainer to also hold delay
- Updated FakeImage, FakeMap and ImageFile
2021-10-09 14:40:44 +02:00
José Miguel Moreno 973479e640 Improved README.md
- Updated plugin description
- Added "How does it work?" section
2021-10-06 18:22:12 +02:00
José Miguel Moreno 201deda397 Fixed GIF rendering
- Updated ImageFile#renderImages()
- Optimized copying of image bytes

> Did not accounted for left and top offsets when rendering GIFs
2021-10-06 18:21:22 +02:00
José Miguel Moreno 448c002403 Added option to disable animated imges
- Updated FakeImage class
- Updated YamipaPlugin class
- Updated README.md
2021-10-06 18:17:06 +02:00
José Miguel Moreno 3fc27756df Limited max number of steps per image
- Created FakeImage.MAX_STEPS constant
- Updated ImageFile
2021-10-03 12:30:51 +02:00
José Miguel Moreno 128d0a024d Added animation task scheduler
- Updated FakeImage class to create an animation task per instance
2021-10-03 11:07:45 +02:00
José Miguel Moreno dfbe53518d Renamed animation frame to step
- Updated FakeItemFrame, FakeMap and ImageFile

> Renamed "animation frame" to "animation step" to avoid confusion with item frames
2021-10-02 20:45:25 +02:00
José Miguel Moreno 08c5e9b5f8 Fixed GIF repainting
- Updated ImageFile#renderImages() to use a temporary canvas to paint next frame of animation
2021-10-02 20:23:35 +02:00
José Miguel Moreno a605536b46 Fixed synchronization issue on image decoding
- Updated ImageFile#renderImages() method
- Added test rendering loop to FakeImage
2021-10-02 17:59:03 +02:00
José Miguel Moreno 5575b53b98 Initial API rewrites to allow animated images
- Updated FakeImage#load()
- Separated spawning from image rendering in FakeItemFrame
- Updated FakeMap#getErrorMatrix()
- Rewritten image file reading in ImageFile
- Rewritten cache reading/persistence in ImageFile
2021-10-02 14:29:39 +02:00
José Miguel Moreno e3b5c10216 Updated CI/CD workflows
- Switched from AdoptOpenJDK to Adoptium (Eclipse Temurin)
- Increased RCON timeout in automata
2021-09-25 17:07:36 +02:00
José Miguel Moreno f3314f6a99 Fixed Bukkit/Spigot testing in CI
- Updated tests workflow
2021-08-25 20:01:50 +02:00
José Miguel Moreno 65646b6f98 Added 1.17.1 and Purpur to CI
- Updated tests workflow
2021-08-25 19:41:12 +02:00
José Miguel Moreno 93237bddc9 Upgraded mineflayer
- Upgraded mineflayer to v3.11.0 in automata

> Adds support for testing Minecraft 1.17 and 1.17.1
2021-08-25 19:10:45 +02:00
José Miguel Moreno dee9e38b00 Upgraded Brigadier to v1.0.18
- Updated pom.xml
2021-08-03 20:31:45 +02:00
José Miguel Moreno 9ec54524cb Improved help command
- Added dynamic "/<command>" to help menu depending on what alias was called to avoid confusion
2021-08-03 20:30:29 +02:00
José Miguel Moreno 78ea2683aa v1.1.1 2021-07-10 17:55:30 +02:00
José Miguel Moreno 13dc0de0f7 Fixed destroy entity packet in v1.17.1
- Updated DestroyEntityPacket#setId() method
2021-07-10 12:18:54 +02:00
José Miguel Moreno 39b7e0848f Upgraded dependencies for 1.17.1
- Updated pom.xml
2021-07-10 12:18:05 +02:00
José Miguel Moreno d88474d9c7 v1.1.0 2021-07-02 18:10:18 +02:00
José Miguel Moreno 097630878f Fixed NullPointerException on command aliases
- Updated ImageCommandBridge#fixPermissions()
2021-06-26 14:00:37 +02:00
José Miguel Moreno dcaf704bda Fixed block selection on 1.17
- Upgraded ProtocolLib to latest snapshot
- Updated PlayerInteractionListener#onPacketReceiving()
2021-06-25 15:59:24 +02:00
José Miguel Moreno 7eea618d38 Fixed automata not making bot an OP
- Updated automata/index.js
2021-06-22 17:37:48 +02:00
José Miguel Moreno 56ad4cf560 Added 1.17 support to tests
- Added 1.17 version to tests workflow
- Use JDK 11 for 1.16 runs
2021-06-22 17:21:08 +02:00
José Miguel Moreno 1cf5bc0de4 Upgraded JDK in workflows
- Updated build workflow
- Updated tests workflow
2021-06-22 17:00:28 +02:00
José Miguel Moreno d6a8df928c Fixed testing with automata
- Fixed automata not making player OP properly
- Improved automata logging
2021-06-22 16:59:39 +02:00
José Miguel Moreno 4ec933de0b Added automata for better testing
- Created automata for testing player interaction
- Updated tests workflow
- Added "node_modules" to gitignore
2021-06-21 21:06:42 +02:00
José Miguel Moreno 06e8d9c98d v1.1.0-SNAPSHOT 2021-06-19 17:28:23 +02:00
José Miguel Moreno 670c9858f2 Minor changes
- Removed unused repository
- Updated requirements in README.md
- Updated MC version badge in README.md
- Updated license paragraph in README.md
2021-06-19 17:23:37 +02:00
José Miguel Moreno 18e9791f83 Removed item-nbt-api dependency
- Updated FakeItemFrame to use ProtocolLib instead of item-nbt-api
- Removed "de.tr7zw:item-nbt-api" dependency
2021-06-19 17:21:16 +02:00
José Miguel Moreno a4dcdd34c7 Upgraded dependencies
- Upgraded Spigot API
- Upgraded ProtocolAPI
2021-06-19 17:18:22 +02:00
José Miguel Moreno aa20c0a294 Added initial support for 1.17
- Added Internals.MINECRAFT_VERSION constant
- Updated packet classes
- Updated FakeMap#sendPixels()
2021-06-19 16:47:00 +02:00
José Miguel Moreno 1184e5ffab Fixed image memory cache invalidation
- Updated ImageFile#getMapsAndSubscribe()
- Invalidated image file on image removal
2021-06-18 20:05:30 +02:00
José Miguel Moreno c5a6ce353c Fixed em-dash character in Bukkit
- Updated ImageCommand
- Updated SelectBlockTask

> Replaced em-dash with hyphen to prevent weird character rendering on Bukkit 1.17
2021-06-17 18:22:35 +02:00
José Miguel Moreno 70d013706c Added feedback when missing command arguments
- Updated Command class
- Re-ordered subcommands in ImageCommandBridge
2021-06-17 18:18:25 +02:00
José Miguel Moreno 61b419fb46 Removed CommandAPI dependency
- Updated pom.xml
2021-06-17 18:04:47 +02:00
José Miguel Moreno a983bdf349 Switched from CommandAPI to Brigadier
- Created new Command and Argument classes using Brigadier API
- Updated ImageCommand and ImageCommandBridge
- Updated YamipaPlugin
- Updated examples in README.md
2021-06-17 18:01:53 +02:00
José Miguel Moreno 3b7a70b03b Added Brigadier dependency
- Added com.mojang:brigadier dependency to be used as the new command dispatcher
- Created Internals utils class with static methods required to use Brigadier
2021-06-15 19:32:10 +02:00
José Miguel Moreno d5bf8992ad Improved dependency shading
- Updated pom.xml
2021-06-15 19:24:45 +02:00
José Miguel Moreno f1546bcf31 Created network packet classes
- Created ActionBarPacket, DestroyEntityPacket, EntityMetadataPacket, MapDataPacket and SpawnEntityPacket
- Updated fake entity classes
- Updated ActionBar utils class

> NOTE: in preparation for Minecraft  1.17

> Now DestroyEntityPacket takes a single entity ID instead of an array of integers
2021-06-12 13:53:39 +02:00
José Miguel Moreno 120736a7d0 Increased FakeMap pool of IDs
- Updated FakeMap.MIN_MAP_ID and FakeMap.MAX_MAP_ID
2021-06-12 13:48:57 +02:00
José Miguel Moreno d66b799109 Added "top" command
- Added "yamipa.top" permission
- Added ImageRenderer#getImagesCountByPlayer() method
- Added "/image top" command
- Updated README.md
2021-06-08 21:34:38 +02:00
José Miguel Moreno 2bed109a9a Fixed get image located between areas
- Fixed ImageRenderer#getImage() not working when image is overlapping two world areas
2021-05-30 17:18:17 +02:00
José Miguel Moreno 536434bdba Removed dead code
- Removed commented code in ImageRenderer
2021-05-28 20:30:43 +02:00
José Miguel Moreno e66b2426dd Upgraded actions/setup-java
- Updated build workflow
- Updated tests workflow
2021-05-26 19:04:01 +02:00
José Miguel Moreno 0b911738fe Added NotNull and Nullable annotations
- Added org.jetbrains.annotations dependency
- Updated project classes
2021-05-26 19:02:05 +02:00
José Miguel Moreno 40d1fea5b6 Added placed images stats
- Updated ImageRenderer
- Updated YamipaPlugin
2021-05-26 16:33:18 +02:00
José Miguel Moreno 9bd3447b6d Improved placed images counters
- Replaced nullable FakeImage#getPlacedBy() with zeroed UUID
- Created constant FakeImage::UNKNOWN_PLAYER_ID
- Updated ImageCommand
- Added example to README.md
2021-05-23 16:43:28 +02:00
José Miguel Moreno 53feb69c2d Added examples to README.md
- Updated README.md
2021-05-21 20:54:25 +02:00
José Miguel Moreno 544fe14799 Sorted suggestions in PlacedByArgument
- Updated PlacedByArgument class
2021-05-21 20:31:21 +02:00
José Miguel Moreno 6c8b80d6b3 Replaced "remove radius" with "place" command
- Updated ImageCommand and ImageCommandBride
- Removed yamipa.remove.radius command
- Added yamipa.clear command
- Updated README.md

> **Why?**
> 1.  Brigadier doesn't allow the same command to have different permissions
> 2. The more powerful remove in radius command is just a character away from the remove a single image command
> 3. With the new clean command images can be removed from the console or a command block
2021-05-21 20:28:35 +02:00
José Miguel Moreno 5544250e84 Improved help menu
- Required at least one permission to show help menu
- Updated help menu to show only usage for commands accessible to sender
2021-05-20 21:06:38 +02:00
José Miguel Moreno d3a5d66115 Upgraded CommandAPI
- Updated pom.xml dependencies
- Updated YamipaPlugin#onLoad()
2021-05-18 21:23:57 +02:00
José Miguel Moreno 010bba5d48 Fixed remove in radius for offline player
- Updated ImageRenderer to keep track of the number of images placed by a given player in realtime
- Created PlacedByArgument to map strings to OfflinePlayer instances
- Updated ImageCommandBridge and ImageCommand
2021-05-01 12:37:58 +02:00
José Miguel Moreno 7b0e5dc685 Minimal refactoring in ImageRenderer
- Renamed argument "fakeImage" to "image" to match other class methods
2021-04-25 20:33:48 +02:00
José Miguel Moreno 17f9a4c8df Updated bStats badge
- Replaced bStats players badge with more appropriate server count
2021-04-25 20:32:31 +02:00
José Miguel Moreno 07a97c399e Fixed player teleportation handling
- Ignored cancelled events from monitor listeners
- Listen to player teleportation events to trigger WordArea change if needed
2021-04-18 17:00:31 +02:00
José Miguel Moreno f154e3042a v1.0.4 2021-04-17 14:07:43 +02:00
José Miguel Moreno 73b9f29738 Removed separators from README.md
- Updated README.md
2021-04-17 13:19:11 +02:00
José Miguel Moreno 3eb33a8226 Updated README.md
- Added bStats badge
- Improved "Permissions" section
2021-04-17 13:16:11 +02:00
José Miguel Moreno b836a26955 Added remove in filtered radius command
- Updated help command
- Added optional "placedBy" argument to remove command to delete only images placed by given target player
- Updated README.md
2021-04-15 19:49:00 +02:00
José Miguel Moreno c8e4d22da1 Added image describe command
- Updated README.md
- Updated permissions in plugin.yml
- Added describe command
- Sorted commands and permissions alphabetically in documentation
2021-04-11 13:08:29 +02:00
José Miguel Moreno b87da736d6 Added placedAt and placedBy attributes
- Saved date and player when an image is placed
- Updated FakeImage class
- Updated ImageRenderer class
2021-04-11 13:00:13 +02:00
José Miguel Moreno 09591603f3 No list pagination on console
- Updated image list command to not paginate on server console
2021-04-11 10:35:56 +02:00
José Miguel Moreno 9127a95a7b Fixed image removing selection
- Removed orphan event warnings (was logging events from unrelated players)
- Added packet listener for entity interaction events

> Explanation: frames are entities, and they have their own hitboxes. Because FakeItemFrames do not exists on the server, the latter discards events coming for those.
2021-04-05 18:07:34 +02:00
José Miguel Moreno 7c7e4ae9bf Custom User-Agent when downloading images
- Updated ImageCommand#downloadImage() to send HTTP requests with a custom User-Agent header
2021-04-05 18:02:54 +02:00
José Miguel Moreno 5a8e7068af Fixed SelectBlockTask on player quit
- Added event handler for PlayerQuitEvent on SelectBlockTask class
2021-04-04 17:30:13 +02:00
José Miguel Moreno dc506ecf31 Fixed ActionBar in CraftBukkit servers
- Updated ActionBar class to use ProtocolLib to send messages
2021-04-04 17:28:45 +02:00
José Miguel Moreno 7553620ed4 Added support for multiverse
- Added soft-dependency to wait for multiverse plugin to load
- Updated version name

> Related to #1
2021-04-03 12:26:43 +02:00
José Miguel Moreno e0b9814006 Added version name to releases
- Updated pom.xml
- Updated CI/CD workflows
2021-04-03 12:24:14 +02:00
José Miguel Moreno 5655357e19 v1.0.3 2021-03-30 10:24:13 +02:00
José Miguel Moreno 45be7ea35d Added screenshots
- Updated README.md
- Created snapshots directory
2021-03-29 15:03:30 +02:00
José Miguel Moreno 9c446fb78a Fixed multiple block tasks at once
- Fixed bug where multiple players could not add or remove images at the same time
2021-03-29 14:29:39 +02:00
José Miguel Moreno 21f8305b3f Upgraded dependencies
- Updated versions in pom.xml
2021-03-29 14:22:20 +02:00
56 changed files with 7005 additions and 1133 deletions

No files matched your search

+5 -4
View File
@@ -10,13 +10,14 @@ jobs:
steps:
# Download code from repository
- name: Checkout code
uses: actions/checkout@v2
uses: actions/checkout@v4
# Setup Java
- name: Setup JDK
uses: actions/setup-java@v1
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'temurin'
java-version: '21'
# Build plugin
- name: Build with Maven
@@ -26,4 +27,4 @@ jobs:
- name: Upload release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh release upload ${GITHUB_REF##*/} ./target/YamipaPlugin.jar
run: gh release upload ${GITHUB_REF##*/} ./target/YamipaPlugin-*.jar
+74 -20
View File
@@ -5,61 +5,96 @@ on: [push]
jobs:
test:
runs-on: ubuntu-latest
timeout-minutes: 15
timeout-minutes: 8
continue-on-error: ${{ matrix.version == '1.20.4' }}
strategy:
fail-fast: false
matrix:
flavor: ['bukkit', 'spigot', 'paper']
version: ['1.16.5', '1.16.4', '1.16.3', '1.16.2', '1.16.1']
flavor: ['bukkit', 'spigot', 'paper', 'purpur']
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@v2
uses: actions/checkout@v4
# Setup Java
- name: Setup JDK
uses: actions/setup-java@v1
# Setup Java (for building)
- name: Setup JDK (for building)
uses: actions/setup-java@v4
with:
java-version: '11'
distribution: 'temurin'
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@v4
with:
node-version: '20'
# Setup automata
- name: Setup automata
working-directory: ./automata
run: npm install
# Setup server
- name: Setup server
env:
FLAVOR: ${{ matrix.flavor }}
VERSION: ${{ matrix.version }}
GETBUKKIT_SUBDOMAIN: ${{ startsWith(matrix.version, '1.16') && 'cdn' || 'download' }}
run: |
if [ $FLAVOR == "bukkit" ]; then
url="https://cdn.getbukkit.org/craftbukkit/craftbukkit-$VERSION.jar"
url="https://$GETBUKKIT_SUBDOMAIN.getbukkit.org/craftbukkit/craftbukkit-$VERSION.jar"
elif [ $FLAVOR == "spigot" ]; then
url="https://cdn.getbukkit.org/spigot/spigot-$VERSION.jar"
url="https://$GETBUKKIT_SUBDOMAIN.getbukkit.org/spigot/spigot-$VERSION.jar"
elif [ $FLAVOR == "paper" ]; then
url="https://papermc.io/api/v1/paper/$VERSION/latest/download"
build=$(curl "https://papermc.io/api/v2/projects/paper/versions/$VERSION" | jq '.builds[-1]')
url="https://papermc.io/api/v2/projects/paper/versions/$VERSION/builds/$build/downloads/paper-$VERSION-$build.jar"
elif [ $FLAVOR == "purpur" ]; then
url="https://api.purpurmc.org/v2/purpur/$VERSION/latest/download"
fi
mkdir ./server && cd ./server
wget -nv "$url" -O server.jar
java -jar server.jar || true
sed -i 's/eula=false/eula=true/g' eula.txt
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: |
url="https://ci.dmulloy2.net/job/ProtocolLib/lastSuccessfulBuild/artifact/target/ProtocolLib.jar"
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
run: |
mkdir -p ./server/plugins/YamipaPlugin/images
cp ./target/YamipaPlugin.jar ./server/plugins/
cp ./target/YamipaPlugin-*.jar ./server/plugins/
echo "verbose: true" > ./server/plugins/YamipaPlugin/config.yml
for i in {1..5}; do
wget -q "https://picsum.photos/400" -O "./server/plugins/YamipaPlugin/images/pic-$i.jpg"
@@ -68,9 +103,12 @@ jobs:
# Test plugin
- name: Run plugin in server
working-directory: ./server
env:
FLAVOR: ${{ matrix.flavor }}
run: |
timeout -s SIGINT 90s java -DIReallyKnowWhatIAmDoingISwear -jar server.jar nogui | tee server.log || true
if ! grep -Fq '[YamipaPlugin] Loading YamipaPlugin' server.log; then
(cd ../automata && npm run start) &
timeout -s SIGINT -k 110s 90s java -DIReallyKnowWhatIAmDoingISwear -jar server.jar nogui | tee server.log || true
if ! grep -Fq '[YamipaPlugin] ' server.log; then
echo "Plugin did not load"
exit 1
fi
@@ -78,7 +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] [ImageCommandBridge] Fixed command permissions' server.log; then
echo "Plugin did not fixed command permissions"
exit 1
fi
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] [FakeImage] Invalidated FakeImage' server.log; then
echo "Plugin did not remove the fake image"
exit 1
fi
if [ "$FLAVOR" != "bukkit" ] && grep -iFq 'exception' server.log; then
echo "Server threw an exception"
exit 1
fi
+1
View File
@@ -1,3 +1,4 @@
/.idea
/target
/*.iml
node_modules
+195 -23
View File
@@ -2,21 +2,42 @@
[![Tests](https://github.com/josemmo/yamipa/workflows/Tests/badge.svg)](https://github.com/josemmo/yamipa/actions)
[![Latest Version](https://img.shields.io/github/v/release/josemmo/yamipa)](https://github.com/josemmo/yamipa/releases/latest)
![Minecraft Version](https://img.shields.io/badge/minecraft-%3E%3D1.16-blueviolet)
[![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 you to place images 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.
## Requirements
- Minecraft >=1.16 (tested on 1.16.4)
- [ProtocolLib](https://www.spigotmc.org/resources/protocollib.1997/)
<p align="center">
<a href="https://imgur.com/9rzeKFS"><img alt="Placing and removing image" src="screenshots/demo.gif" width="300"></a>
<a href="https://imgur.com/a/J22z9Dq"><img alt="Works with animated images too!" src="screenshots/demo-animated.gif" width="300"></a>
<a href="https://imgur.com/a/laPF1eN"><img alt="Placing and removing image item" src="screenshots/demo-item.gif" width="300"></a>
<a href="screenshots/sample.jpg"><img alt="Optimized to work in low-spec servers" src="screenshots/sample.jpg" width="300"></a>
</p>
## Installation
Download the JAR file for the [latest release](https://github.com/josemmo/yamipa/releases/latest) and copy it to the
"plugins" directory of your Bukkit/Spigot/Paper server. That's it!
"plugins" directory of your Minecraft server as you'll do with any other plugin. That's it!
### Requirements
Before installing Yamipa make sure you meet the following requirements:
- 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 |
|------------------:|:-----------:|:------:|:-------:|
| 1.16.x | ✅ | ✅ | ✅ |
| 1.17 & 1.17.1 | ✅ | ✅ | ✅ |
| 1.18 & 1.18.x | ✅ | ✅ | ✅ |
| 1.19 & 1.19.x | ✅ | ✅ | ✅ |
| 1.20 & 1.20.x | ✅ | ✅ | ✅ |
## Configuration
Yamipa is ready to go out of the box. By default, it creates the following files and directories under the
Yamipa is ready-to-go right out of the box. By default, it creates the following files and directories under the
`plugins/YamipaPlugin` directory:
- `cache`: A directory containing cached images to speed up the rendering process. You shouldn't modify its contents.
@@ -24,14 +45,50 @@ Yamipa is ready to go out of the box. By default, it creates the following files
- `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
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).
@@ -39,21 +96,136 @@ disable it at any time by adding `enabled: false` to the
## Usage
This plugin adds the following commands:
- `/image list [<page>]`: List all available files in the images directory.
- `/image clear <x z world> <r> [<placed-by>]`: Remove all placed images in a radius of `r` blocks around an origin.
- `/image describe`: Show detailed information about a placed image.
- `/image download <url> <filename>`: Download an image from a URL and place it in the images directory.
- `/image place <filename> <width> [<height>]`: Place an image of size `width`x`height` blocks.
- `/image give <player> <filename> <amount> <w> [<h>] [<flags>]`: Give image items that can be placed later to a player.
- `/image list [<page>]`: List all available files in the images directory.
- `/image place <filename> <w> [<h>] [<flags>]`: Place an image of size `w`x`h` blocks.
- `/image remove`: Remove a placed image from the world without deleting the image file.
- `/image remove <radius>`: Remove all placed images in a radius of `radius` blocks around the player.
- `/image top`: List players with the most placed images.
### Permissions
By default, only server OPs have all plugin permissions granted:
### Examples
- `yamipa.list`
- `yamipa.download`
- `yamipa.place`
- `yamipa.remove`
- `yamipa.remove.radius`
- 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"`
- Give 10 image items to "TestPlayer" for the "test.jpg" image (3x5 blocks)\
`/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`
- Start the dialog to place an image with a width of 3 blocks and auto height\
`/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`
- Start the dialog to place an image that glows in the dark\
`/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\
`/image clear 0 0 world 5`
- Remove all images placed by "EvilPlayer" in a radius of 100 blocks in the nether\
`/image clear 50 70 world_nether 100 EvilPlayer`
- Remove all legacy placed images (without "placed by" metadata)\
`/image clear 0 0 world 9999999 00000000-0000-0000-0000-000000000000`
## Permissions
If you want more granular control over the features exposed to players, permissions are the way to go!
Yamipa defines the following permissions:
| 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 |
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:
- `ANIM` (animatable): Whether an image should be animated or not, useful when you don't want a GIF image to play.
- `REMO` (removable): Whether an image can be removed by any player by left-clicking it.
- `DROP` (droppable): Whether an image drops an image item when is removed by any player.
- `GLOW` (glowing): Whether an image glows in the dark (only works on Minecraft 1.17 and above).
By default, images placed with the "/image place" command only have the `ANIM` flag.
Similarly, image items issued with the "/image give" command have `ANIM`, `REMO` and `DROP` flags.
Default flags can be modified through the "flags" argument.
To add a flag to the default ones use "+{FLAG_NAME}" (e.g. `+GLOW`), and to remove it use "-{FLAG_NAME}" (e.g. `-ANIM`).
You can modify multiple flags separating them with commas (e.g. `+GLOW,-ANIM`).
## How does it work?
As you may have already guessed, Minecraft does not support the placing of image files.
Yamipa bypasses this limitation by using two built-in features (**item frames and maps**) to render custom images.
However, because item frames **are entities** (and these cause a lot of lag in a server), Yamipa **does not actually
create any entity** in your world. Instead, it tricks your players' clients into thinking there's a placed item frame
where there isn't by sending crafted network packets.
In the case of animated images (i.e. GIFs), Yamipa sends all animation frames to the player (which takes a decent amount
of bandwidth) and then sends a tiny packet telling the client to switch to the next animation step (i.e. map) every few
milliseconds.
> **IMPORTANT!**\
> Because Yamipa stores a cached copy of an image in memory whenever a player is near it just in case it needs to send
> it again without compromising performance, your server **needs to have a bit of spare RAM** to handle this.
>
> The rule of thumb here is 100K per unique loaded image (1MB for animated images).
> Unique means if the same image with same dimensions is placed multiple times it only counts as one instance.
## License
Yamipa is licensed under the [MIT License](LICENSE) and includes third-party software licensed under
the MIT License and the [GPLv3 License](https://www.gnu.org/licenses/gpl-3.0.html).
Yamipa is licensed under the [MIT License](LICENSE).
+45
View File
@@ -0,0 +1,45 @@
import { placeBlockOnTheFloor, startBot } from './src/bot.js'
import { wait } from './src/common.js'
import { getRconClient, waitForServer } from './src/rcon.js'
(async() => {
console.log('[AUTOMATA] Waiting for Minecraft server to be ready...')
await waitForServer()
console.log('[AUTOMATA] Logging in as test account...')
const bot = await startBot()
await bot.look(0, -Math.PI/2)
console.log('[AUTOMATA] Making test account an OP...')
const conn = await getRconClient()
await conn.send('op test')
await conn.send('gamemode creative test')
await wait(2000)
console.log('[AUTOMATA] Preparing test account...')
bot.chat('/clear @p')
await wait(2000)
bot.chat('/give @p minecraft:dirt')
await wait(2000)
console.log('[AUTOMATA] Placing image on the floor...')
bot.chat('/image place pic-1.jpg 4 4')
await wait(2000)
placeBlockOnTheFloor(bot)
await wait(2000)
console.log('[AUTOMATA] Removing image from floor...')
bot.chat('/image remove')
await wait(2000)
placeBlockOnTheFloor(bot)
await wait(2000)
console.log('[AUTOMATA] Logging off...')
bot.end()
console.log('[AUTOMATA] Stopping server...')
await conn.send('stop')
await conn.end()
console.log('[AUTOMATA] Done!')
})()
+1672
View File
File diff suppressed because it is too large. Load diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@yamipa/automata",
"description": "Minecraft Automata for testing Yamipa",
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"mineflayer": "^4.20.1",
"rcon-client": "^4.2.4"
}
}
+28
View File
@@ -0,0 +1,28 @@
import mineflayer from 'mineflayer'
/**
* @return {Promise<mineflayer.Bot>} Bot instance
*/
export function startBot() {
return new Promise(resolve => {
const bot = mineflayer.createBot({
host: '127.0.0.1',
username: 'test'
})
bot.once('spawn', () => resolve(bot))
})
}
/**
* @param {mineflayer.Bot} bot Bot instance
*/
export function placeBlockOnTheFloor(bot) {
bot._client.write('block_place', {
location: bot.entity.position.offset(0, -1, 0),
direction: 1,
hand: 0,
cursorX: 0,
cursorY: 0,
cursorZ: 0
})
}
+3
View File
@@ -0,0 +1,3 @@
export function wait(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
+26
View File
@@ -0,0 +1,26 @@
import { Rcon } from 'rcon-client'
import { wait } from './common.js'
const CONNECTION = {
host: '127.0.0.1',
port: 25575,
password: 'rcon',
timeout: 1000*20
}
export async function waitForServer() {
while (true) {
try {
const rcon = await Rcon.connect(CONNECTION)
await rcon.end()
break
} catch (_) {
// Not ready yet
}
wait(2000)
}
}
export function getRconClient() {
return Rcon.connect(CONNECTION)
}
+102 -41
View File
@@ -6,7 +6,7 @@
<groupId>io.josemmo.bukkit.plugin</groupId>
<artifactId>YamipaPlugin</artifactId>
<version>1.0.2</version>
<version>1.3.2-SNAPSHOT-b2</version>
<properties>
<maven.compiler.source>8</maven.compiler.source>
@@ -17,50 +17,112 @@
<repositories>
<repository>
<id>spigot-repo</id>
<url>https://hub.spigotmc.org/nexus/content/repositories/snapshots/</url>
<url>https://hub.spigotmc.org/nexus/content/groups/public/</url>
</repository>
<repository>
<id>dmulloy2-repo</id>
<url>https://repo.dmulloy2.net/nexus/repository/public/</url>
</repository>
<repository>
<id>codemc-repo</id>
<url>https://repo.codemc.io/repository/maven-public/</url>
<layout>default</layout>
<id>minecraft-libraries</id>
<url>https://libraries.minecraft.net</url>
</repository>
<repository>
<id>commandapi</id>
<url>https://raw.githubusercontent.com/JorelAli/CommandAPI/mvn-repo/</url>
<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.16.5-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>4.6.0-SNAPSHOT</version>
<version>5.2.0-SNAPSHOT</version>
<scope>provided</scope>
</dependency>
<!-- https://github.com/Mojang/brigadier/releases -->
<dependency>
<groupId>de.tr7zw</groupId>
<artifactId>item-nbt-api</artifactId>
<version>2.7.1</version>
</dependency>
<dependency>
<groupId>dev.jorel</groupId>
<artifactId>commandapi-shade</artifactId>
<version>5.8</version>
<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>1.8</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>24.1.0</version>
<scope>provided</scope>
</dependency>
</dependencies>
@@ -71,12 +133,12 @@
<filtering>true</filtering>
</resource>
</resources>
<finalName>${project.name}</finalName>
<finalName>${project.name}-${project.version}</finalName>
<plugins>
<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>
@@ -90,33 +152,14 @@
<createDependencyReducedPom>false</createDependencyReducedPom>
<filters>
<filter>
<artifact>dev.jorel:*</artifact>
<excludes>
<exclude>META-INF/**</exclude>
</excludes>
</filter>
<filter>
<artifact>de.tr7zw:*</artifact>
<artifact>*:*</artifact>
<excludes>
<exclude>META-INF/**</exclude>
<exclude>LICENSE</exclude>
</excludes>
</filter>
<filter>
<artifact>org.bstats:*</artifact>
<excludes>
<exclude>META-INF/**</exclude>
</excludes>
</filter>
</filters>
<relocations>
<relocation>
<pattern>dev.jorel.commandapi-shade</pattern>
</relocation>
<relocation>
<pattern>de.tr7zw.changeme.nbtapi</pattern>
<shadedPattern>de.tr7zw.nbtapi</shadedPattern>
</relocation>
<relocation>
<pattern>org.bstats</pattern>
<shadedPattern>${project.groupId}.bstats</shadedPattern>
@@ -124,7 +167,25 @@
</relocations>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>3.1.0</version>
<executions>
<execution>
<phase>package</phase>
<goals>
<goal>run</goal>
</goals>
<configuration>
<target>
<delete file="${project.build.directory}/${project.build.finalName}-shaded.jar" />
<delete file="${project.build.directory}/original-${project.build.finalName}.jar" />
</target>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

@@ -1,36 +1,49 @@
package io.josemmo.bukkit.plugin;
import dev.jorel.commandapi.CommandAPI;
import io.josemmo.bukkit.plugin.commands.ImageCommandBridge;
import io.josemmo.bukkit.plugin.renderer.ImageRenderer;
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;
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.logging.Level;
import java.util.Objects;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.function.Function;
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 @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 YamipaPlugin getInstance() {
return instance;
public static @NotNull YamipaPlugin getInstance() {
Objects.requireNonNull(INSTANCE, "Cannot get plugin instance if plugin is not running");
return INSTANCE;
}
/**
* Get image storage instance
* @return Image storage instance
*/
public ImageStorage getStorage() {
public @NotNull ImageStorage getStorage() {
Objects.requireNonNull(storage, "Cannot get storage instance if plugin is not running");
return storage;
}
@@ -38,141 +51,138 @@ public class YamipaPlugin extends JavaPlugin {
* Get image renderer instance
* @return Image renderer instance
*/
public ImageRenderer getRenderer() {
public @NotNull ImageRenderer getRenderer() {
Objects.requireNonNull(renderer, "Cannot get renderer instance if plugin is not running");
return renderer;
}
/**
* Get configuration value
* @param path Configuration key path
* @param defaultValue Default value
* @return Configuration value
* Get internal tasks scheduler
* @return Tasks scheduler
*/
private String getConfigValue(String path, String defaultValue) {
String value = getConfig().getString(path);
return (value == null) ? defaultValue : value;
public @NotNull ScheduledExecutorService getScheduler() {
Objects.requireNonNull(scheduler, "Cannot get scheduler instance if plugin is not running");
return scheduler;
}
/**
* Is verbose
* @return Whether plugin is running in verbose mode
*/
public boolean isVerbose() {
return verbose;
}
@Override
public void onLoad() {
instance = this;
CommandAPI.onLoad(false);
ImageCommandBridge.register();
INSTANCE = this;
}
@Override
public void onEnable() {
CommandAPI.onEnable(this);
// 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
ImageCommandBridge.register(this);
// 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
renderer = new ImageRenderer(basePath.resolve(dataPath).toString());
boolean animateImages = getConfig().getBoolean("animate-images", true);
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
itemService = new ItemService();
itemService.start();
// 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
Metrics metrics = new Metrics(this, BSTATS_PLUGIN_ID);
metrics.addCustomChart(new Metrics.SimplePie("number_of_image_files", () -> {
int number = storage.size();
Function<Integer, String> toStats = number -> {
if (number >= 1000) return "1000+";
if (number >= 500) return "500-999";
if (number >= 100) return "100-499";
if (number >= 50) return "50-99";
if (number >= 10) return "10-49";
return "0-9";
}));
};
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 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
if (scheduler != null) {
scheduler.shutdownNow();
scheduler = null;
}
// Remove Bukkit listeners and tasks
HandlerList.unregisterAll(this);
Bukkit.getScheduler().cancelTasks(this);
storage.stop();
renderer.stop();
storage = null;
renderer = null;
}
/**
* Log message
* @param level Record level
* @param message Message
* @param e Throwable instance, NULL to ignore
*/
public void log(Level level, String message, 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(Level level, String message) {
log(level, message, null);
}
/**
* Log severe message
* @param message Message
*/
public void severe(String message) {
log(Level.SEVERE, message);
}
/**
* Log warning message
* @param message Message
*/
public void warning(String message) {
log(Level.WARNING, message);
}
/**
* Log info message
* @param message Message
*/
public void info(String message) {
log(Level.INFO, message);
}
/**
* Log fine message
* @param message Message
*/
public void fine(String message) {
log(Level.FINE, message);
// Unlink reference to instance
INSTANCE = null;
}
}
@@ -0,0 +1,179 @@
package io.josemmo.bukkit.plugin.commands;
import com.mojang.brigadier.builder.ArgumentBuilder;
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
import com.mojang.brigadier.context.CommandContext;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import io.josemmo.bukkit.plugin.commands.arguments.Argument;
import io.josemmo.bukkit.plugin.utils.Internals;
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;
import java.util.function.Predicate;
public class Command {
private final String name;
private final List<Argument> arguments = new ArrayList<>();
private Predicate<CommandSender> requirementHandler = __ -> true;
private @Nullable BiConsumer<CommandSender, Object[]> executesHandler;
private @Nullable BiConsumer<Player, Object[]> executesPlayerHandler;
private final List<Command> subcommands = new ArrayList<>();
/**
* Command constructor
* @param name Command literal name
*/
public Command(@NotNull String name) {
this.name = name;
}
/**
* Add argument to command
* @param argument Argument instance
* @return This instance
*/
public @NotNull Command withArgument(@NotNull Argument argument) {
arguments.add(argument);
return this;
}
/**
* Add requirement handler to this command
* @param handler Handler to determine command availability
* @return This instance
*/
public @NotNull Command withRequirement(@NotNull Predicate<CommandSender> handler) {
requirementHandler = handler;
return this;
}
/**
* Add permission requirement to this command
* @param permissions Permission names (match as least one)
* @return This instance
*/
public @NotNull Command withPermission(@NotNull String ...permissions) {
return withRequirement(sender -> {
for (String permission : permissions) {
if (sender.hasPermission(permission)) {
return true;
}
}
return false;
});
}
/**
* Add handler that will be executed when the command gets called
* @param handler Command handler
* @return This instance
*/
public @NotNull Command executes(@NotNull BiConsumer<CommandSender, Object[]> handler) {
executesHandler = handler;
return this;
}
/**
* Add handler that will be executed when the command gets called by a player
* @param handler Command handler
* @return This instance
*/
@SuppressWarnings("UnusedReturnValue")
public @NotNull Command executesPlayer(@NotNull BiConsumer<Player, Object[]> handler) {
executesPlayerHandler = handler;
return this;
}
/**
* Add subcommand to this command
* @param name Subcommand literal name
* @return The new subcommand
*/
public @NotNull Command addSubcommand(@NotNull String name) {
Command subcommand = new Command(name);
subcommands.add(subcommand);
return subcommand;
}
/**
* Build command and all of its children
* @return Literal argument builder instance
*/
@SuppressWarnings({"rawtypes", "unchecked"})
public @NotNull LiteralArgumentBuilder<?> build() {
LiteralArgumentBuilder<?> root = LiteralArgumentBuilder.literal(name);
// Add arguments and execution handler
buildElement(root, 0);
// Add subcommands
for (Command subcommand : subcommands) {
root.then((ArgumentBuilder) subcommand.build());
}
return root;
}
@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()) {
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;
});
return parent;
}
// Attach execution handler to last command element
if (executesPlayerHandler != null) {
parent.executes(ctx -> {
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
if (sender instanceof Player) {
executesPlayerHandler.accept((Player) sender, getArgumentValues(sender, ctx));
} else {
sender.sendMessage(ChatColor.RED + "Only in-game players can execute this command!");
}
return 0;
});
} else if (executesHandler != null) {
parent.executes(ctx -> {
CommandSender sender = Internals.getBukkitSender(ctx.getSource());
executesHandler.accept(sender, getArgumentValues(sender, ctx));
return 0;
});
}
return parent;
}
private @NotNull Object[] getArgumentValues(
@NotNull CommandSender sender,
@NotNull CommandContext<?> ctx
) throws CommandSyntaxException {
List<Object> argValues = new ArrayList<>();
argValues.add(ctx.getNodes().get(0).getNode().getName());
for (Argument argument : arguments) {
Object value = ctx.getArgument(argument.getName(), Object.class);
value = argument.parse(sender, value);
argValues.add(value);
}
return argValues.toArray(new Object[0]);
}
}
@@ -3,41 +3,72 @@ package io.josemmo.bukkit.plugin.commands;
import io.josemmo.bukkit.plugin.YamipaPlugin;
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.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Location;
import org.bukkit.Rotation;
import org.bukkit.*;
import org.bukkit.block.BlockFace;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import org.bukkit.plugin.PluginDescriptionFile;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import javax.imageio.ImageIO;
import java.awt.Dimension;
import java.io.IOException;
import java.io.InputStream;
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.util.Set;
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(CommandSender s) {
public static void showHelp(@NotNull CommandSender s, @NotNull String commandName) {
String cmd = "/" + commandName;
s.sendMessage(ChatColor.BOLD + "=== Yamipa Plugin Help ===");
s.sendMessage(ChatColor.AQUA + "/image" + ChatColor.RESET + " — Show this help");
s.sendMessage(ChatColor.AQUA + "/image list [<page>]" + ChatColor.RESET + " — List all images");
s.sendMessage(ChatColor.AQUA + "/image download <url> <filename>" + ChatColor.RESET + " — Download image");
s.sendMessage(ChatColor.AQUA + "/image place <filename>" + ChatColor.RESET + " — Place image");
s.sendMessage(ChatColor.AQUA + "/image remove" + ChatColor.RESET + " — Remove placed image");
s.sendMessage(ChatColor.AQUA + "/image remove <radius>" + ChatColor.RESET + " — Remove placed images in radius");
s.sendMessage(ChatColor.AQUA + cmd + ChatColor.RESET + " - Show this help");
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.command.describe") || s.hasPermission("yamipa.describe")) {
s.sendMessage(ChatColor.AQUA + cmd + " describe" + ChatColor.RESET + " - Describe placed image");
}
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.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.command.list") || s.hasPermission("yamipa.list")) {
s.sendMessage(ChatColor.AQUA + cmd + " list [<page>]" + ChatColor.RESET + " - List all images");
}
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.command.remove.own") || s.hasPermission("yamipa.remove")) {
s.sendMessage(ChatColor.AQUA + cmd + " remove" + ChatColor.RESET + " - Remove a single placed image");
}
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(CommandSender sender, int page) {
String[] filenames = YamipaPlugin.getInstance().getStorage().getAllFilenames();
int numOfImages = filenames.length;
public static void listImages(@NotNull CommandSender sender, int page) {
ImageStorage storage = YamipaPlugin.getInstance().getStorage();
List<String> filenames = storage.getFilenames(sender);
int numOfImages = filenames.size();
// Are there any images available?
if (numOfImages == 0) {
@@ -46,97 +77,266 @@ public class ImageCommand {
}
// Is the page number valid?
int firstImageIndex = (page - 1) * ITEMS_PER_PAGE;
int firstImageIndex = Math.max(page-1, 0) * ITEMS_PER_PAGE;
if (firstImageIndex >= numOfImages) {
sender.sendMessage(ChatColor.RED + "Page " + page + " not found");
return;
}
// Render list of images
int maxPage = (int) Math.ceil((float) numOfImages / ITEMS_PER_PAGE);
sender.sendMessage("=== Page " + page + " out of " + maxPage + " ===");
for (int i=firstImageIndex; i<Math.min(numOfImages, firstImageIndex+ITEMS_PER_PAGE); ++i) {
sender.sendMessage("" + ChatColor.GOLD + filenames[i]);
int stopImageIndex = (page == 0) ? numOfImages : Math.min(numOfImages, firstImageIndex+ITEMS_PER_PAGE);
if (page > 0) {
int maxPage = (int) Math.ceil((float) numOfImages / ITEMS_PER_PAGE);
sender.sendMessage("=== Page " + page + " out of " + maxPage + " ===");
}
for (int i=firstImageIndex; i<stopImageIndex; ++i) {
sender.sendMessage(ChatColor.GOLD + filenames.get(i));
}
}
public static void downloadImage(CommandSender sender, String url, String filename) {
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;
}
// Download remote URL
// Validate and fix remote URL
URL url;
String referrer = null;
try {
url = new URL(rawUrl);
// Giphy.com
if (url.getHost().equals("giphy.com")) {
String path = url.getPath();
String id = path.substring(path.lastIndexOf('-')+1);
url = new URL("https://media.giphy.com/media/" + id + "/giphy.gif");
referrer = "https://giphy.com/";
}
// Imgur.com
if (url.getHost().equals("imgur.com")) {
String[] parts = url.getPath().replaceAll("^/|/$", "").split("/");
if (parts.length == 2 && (parts[0].equals("a") || parts[0].equals("gallery"))) {
url = new URL("https://imgur.com/a/" + parts[1] + "/zip");
referrer = "https://imgur.com/a/" + parts[1];
} else {
url = new URL("https://imgur.com/download/" + parts[parts.length-1] + "/");
referrer = "https://imgur.com/" + parts[parts.length-1];
}
}
} catch (MalformedURLException e) {
sender.sendMessage(ChatColor.RED + "The remote URL is not valid");
return;
}
// Download and validate remote file
final URL finalUrl = url;
final String finalReferrer = referrer;
Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> {
try {
InputStream inputStream = new URL(url).openStream();
URLConnection conn = finalUrl.openConnection();
PluginDescriptionFile desc = plugin.getDescription();
conn.setRequestProperty("Accept", "*/*");
conn.setRequestProperty("User-Agent", desc.getName() + "/" + desc.getVersion());
if (finalReferrer != null) {
conn.setRequestProperty("Referer", finalReferrer);
}
// Download file
sender.sendMessage("Downloading file...");
Files.copy(inputStream, destPath);
Files.createDirectories(destPath.getParent());
Files.copy(conn.getInputStream(), destPath);
// Validate downloaded file
if (ImageIO.read(destPath.toFile()) == null) {
throw new IllegalArgumentException("The downloaded file is not a valid image");
}
// Notify sender
sender.sendMessage(ChatColor.GREEN + "Done!");
} catch (MalformedURLException e) {
sender.sendMessage(ChatColor.RED + "The remote URL is not valid");
} catch (IOException e) {
sender.sendMessage(ChatColor.RED + "An error occurred trying to download the remote file");
LOGGER.warning("Failed to download file from \"" + finalUrl + "\": " + e.getClass().getName());
} catch (IllegalArgumentException e) {
if (Files.exists(destPath) && !destPath.toFile().delete()) {
LOGGER.warning("Failed to delete corrupted file \"" + destPath + "\"");
}
sender.sendMessage(ChatColor.RED + e.getMessage());
}
});
}
public static void placeImage(
Player player,
String filename,
@NotNull Player player,
@NotNull ImageFile image,
int width,
int height
int height,
int flags
) {
YamipaPlugin plugin = YamipaPlugin.getInstance();
// Get image instance
ImageFile image = plugin.getStorage().get(filename);
if (image == null) {
player.sendMessage(ChatColor.RED + "The requested file does not exist");
return;
}
// Get image size in blocks
Dimension sizeInPixels = image.getSize();
if (sizeInPixels == null) {
player.sendMessage(ChatColor.RED + "The requested file is not a valid image");
return;
}
if (height == 0) {
float imageRatio = (float) sizeInPixels.height / sizeInPixels.width;
height = Math.round(width * imageRatio);
height = Math.min(height, FakeImage.MAX_DIMENSION);
}
final int finalHeight = 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) -> {
FakeImage existingImage = plugin.getRenderer().getImage(location, face);
if (existingImage != null) {
ActionBar.send(player, ChatColor.RED + "There's already an image there!");
return;
}
ActionBar.send(player, "");
Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation());
FakeImage fakeImage = new FakeImage(image.getName(), location, face, rotation, width, finalHeight);
plugin.getRenderer().addImage(fakeImage);
});
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");
}
public static void removeImage(Player player) {
public static boolean placeImage(
@NotNull Player player,
@NotNull ImageFile image,
int width,
int height,
int flags,
@NotNull Location location,
@NotNull BlockFace face
) {
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
// Create new fake image instance
Rotation rotation = FakeImage.getRotationFromPlayerEyesight(face, player.getEyeLocation());
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
renderer.addImage(fakeImage);
return true;
}
public static void removeImage(@NotNull Player player) {
SelectBlockTask task = new SelectBlockTask(player);
task.onSuccess((location, face) -> {
FakeImage image = YamipaPlugin.getInstance().getRenderer().getImage(location, face);
if (image == null) {
ActionBar.send(player, ChatColor.RED + "That is not a valid 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,
int radius,
@Nullable OfflinePlayer placedBy
) {
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
// Get images in area
Set<FakeImage> images = renderer.getImages(
Objects.requireNonNull(origin.getWorld()),
origin.getBlockX()-radius+1,
origin.getBlockX()+radius-1,
origin.getBlockZ()-radius+1,
origin.getBlockZ()+radius-1
);
// Filter out images not placed by targeted player
if (placedBy != null) {
UUID target = placedBy.getUniqueId();
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);
}
sender.sendMessage("Removed " + images.size() + " placed image(s)");
}
public static void describeImage(@NotNull Player player) {
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
// Ask user to select fake image
@@ -145,32 +345,141 @@ public class ImageCommand {
FakeImage image = renderer.getImage(location, face);
if (image == null) {
ActionBar.send(player, ChatColor.RED + "That is not a valid image!");
} else {
ActionBar.send(player, "");
renderer.removeImage(image);
return;
}
// Separate previous messages
player.sendMessage("");
// Basic information
player.sendMessage(ChatColor.GOLD + "Filename: " + ChatColor.RESET + image.getFilename());
player.sendMessage(ChatColor.GOLD + "World: " + ChatColor.RESET +
image.getLocation().getChunk().getWorld().getName());
player.sendMessage(ChatColor.GOLD + "Coordinates: " + ChatColor.RESET +
image.getLocation().getBlockX() + ", " +
image.getLocation().getBlockY() + ", " +
image.getLocation().getBlockZ());
player.sendMessage(ChatColor.GOLD + "Block Face: " + ChatColor.RESET + image.getBlockFace());
player.sendMessage(ChatColor.GOLD + "Rotation: " + ChatColor.RESET + image.getRotation());
player.sendMessage(ChatColor.GOLD + "Dimensions: " + ChatColor.RESET +
image.getWidth() + "x" + image.getHeight() + " blocks");
// Speed
int delay = image.getDelay() * 50;
String delayStr = (delay > 0) ? delay + " ms per step" : ChatColor.GRAY + "N/A";
player.sendMessage(ChatColor.GOLD + "Speed: " + ChatColor.RESET + delayStr);
// Placed At
String dateStr = (image.getPlacedAt() == null) ?
ChatColor.GRAY + "Some point in time" :
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss Z").format(image.getPlacedAt());
player.sendMessage(ChatColor.GOLD + "Placed At: " + ChatColor.RESET + dateStr);
// Placed By
String playerStr;
if (image.getPlacedBy().getUniqueId().equals(FakeImage.UNKNOWN_PLAYER_ID)) {
playerStr = ChatColor.GRAY + "Someone";
} else if (image.getPlacedBy().getName() == null) {
playerStr = ChatColor.DARK_AQUA + image.getPlacedBy().getUniqueId().toString();
} else {
playerStr = image.getPlacedBy().getName();
}
player.sendMessage(ChatColor.GOLD + "Placed By: " + ChatColor.RESET + playerStr);
// Flags
String flagsStr = "";
if (image.hasFlag(FakeImage.FLAG_ANIMATABLE)) {
flagsStr += ChatColor.AQUA + "ANIM ";
}
if (image.hasFlag(FakeImage.FLAG_REMOVABLE)) {
flagsStr += ChatColor.RED + "REMO ";
}
if (image.hasFlag(FakeImage.FLAG_DROPPABLE)) {
flagsStr += ChatColor.LIGHT_PURPLE + "DROP ";
}
if (image.hasFlag(FakeImage.FLAG_GLOWING)) {
flagsStr += ChatColor.GREEN + "GLOW ";
}
if (flagsStr.isEmpty()) {
flagsStr = ChatColor.GRAY + "N/A";
}
player.sendMessage(ChatColor.GOLD + "Flags: " + ChatColor.RESET + flagsStr);
});
task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image removing canceled"));
task.run("Right click an image to continue");
task.onFailure(() -> ActionBar.send(player, ChatColor.RED + "Image describing canceled"));
task.run("Right click the image to describe");
}
public static void removeImagesInRadius(Player player, int radius) {
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
public static void showTopPlayers(@NotNull CommandSender sender) {
UUID senderId = (sender instanceof Player) ? ((Player) sender).getUniqueId() : null;
Map<OfflinePlayer, Integer> stats = YamipaPlugin.getInstance().getRenderer().getImagesCountByPlayer();
// Get images in area
Location loc = player.getLocation();
Set<FakeImage> images = renderer.getImages(
player.getWorld(),
loc.getBlockX()-radius+1,
loc.getBlockX()+radius-1,
loc.getBlockZ()-radius+1,
loc.getBlockZ()+radius-1
);
// Remove found images
for (FakeImage image : images) {
renderer.removeImage(image);
// Render header
sender.sendMessage("=== Top players with the most placed images ===");
if (stats.isEmpty()) {
sender.sendMessage(ChatColor.RED + "No one on this server has placed a single image!");
return;
}
player.sendMessage("Removed " + images.size() + " placed image(s)");
int rank = 0;
int printedLines = 0;
boolean hasShownSender = (senderId == null); // Assume sender has already been shown if it's not a player
for (Map.Entry<OfflinePlayer, Integer> item : stats.entrySet()) {
OfflinePlayer player = item.getKey();
int value = item.getValue();
++rank;
// Skip line if irrelevant
if (player.getUniqueId().equals(senderId)) {
hasShownSender = true;
} else if (!hasShownSender && printedLines == ITEMS_PER_PAGE-1) {
continue; // Leave last line empty for sender rank
} else if (printedLines >= ITEMS_PER_PAGE) {
break; // Stop printing players when chat is filled
}
// Prepare player name or UUID
String playerName = (player.getName() == null) ?
ChatColor.GOLD + player.getUniqueId().toString() :
ChatColor.GREEN + player.getName();
// Render player line
sender.sendMessage(
"" + ChatColor.BOLD + (rank > 1000 ? "1000+" : rank) + ChatColor.RESET + ". " +
playerName + ChatColor.RESET +
ChatColor.GRAY + " - " + value + " " + (value == 1 ? "image" : "images")
);
++printedLines;
}
}
public static void giveImageItems(
@NotNull CommandSender sender,
@NotNull Player player,
@NotNull ImageFile image,
int amount,
int width,
int height,
int flags
) {
// Get image size in blocks
Dimension sizeInPixels = image.getSize();
if (sizeInPixels == null) {
sender.sendMessage(ChatColor.RED + "The requested file is not a valid image");
return;
}
if (height == 0) {
height = FakeImage.getProportionalHeight(sizeInPixels, sender, width);
}
// Create item stack
ItemStack itemStack = ItemService.getImageItem(image, amount, width, height, flags);
// Add item stack to player's inventory
player.getInventory().addItem(itemStack);
sender.sendMessage(
ChatColor.ITALIC + "Added " + amount + " " +
(amount == 1 ? "image item" : "image items") +
" to " + player.getName() + "'s inventory"
);
}
}
@@ -1,99 +1,217 @@
package io.josemmo.bukkit.plugin.commands;
import dev.jorel.commandapi.CommandAPICommand;
import dev.jorel.commandapi.arguments.Argument;
import dev.jorel.commandapi.arguments.IntegerArgument;
import dev.jorel.commandapi.arguments.TextArgument;
import com.mojang.brigadier.CommandDispatcher;
import com.mojang.brigadier.tree.LiteralCommandNode;
import io.josemmo.bukkit.plugin.YamipaPlugin;
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;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
public class ImageCommandBridge {
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
* @param plugin Plugin instance
*/
public static void register() {
CommandAPICommand group = new CommandAPICommand("image").withAliases("yamipa", "images");
@SuppressWarnings({"rawtypes", "unchecked"})
public static void register(@NotNull YamipaPlugin plugin) {
CommandDispatcher dispatcher = Internals.getDispatcher();
// Register command
LiteralCommandNode<?> commandNode = getRootCommand().build().build();
dispatcher.getRoot().addChild(commandNode);
// Register aliases
for (String alias : COMMAND_ALIASES) {
LiteralCommandNode<?> aliasNode = new LiteralCommandNode(
alias,
commandNode.getCommand(),
commandNode.getRequirement(),
commandNode,
commandNode.getRedirectModifier(),
commandNode.isFork()
);
dispatcher.getRoot().addChild(aliasNode);
}
LOGGER.fine("Registered plugin command and aliases");
// Fix "minecraft.command.*" permissions
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> {
fixPermissions(COMMAND_NAME);
for (String alias : COMMAND_ALIASES) {
fixPermissions(alias);
}
LOGGER.fine("Fixed command permissions");
});
}
@SuppressWarnings("CodeBlock2Expr")
private static @NotNull Command getRootCommand() {
Command root = new Command(COMMAND_NAME);
// Help command
group.executes((sender, __) -> {
ImageCommand.showHelp(sender);
});
// List command
CommandAPICommand list = new CommandAPICommand("list")
.withPermission("yamipa.list")
.executes((sender, __) -> {
ImageCommand.listImages(sender, 1);
});
group.withSubcommand(list);
// List command (with page argument)
CommandAPICommand listWithPage = new CommandAPICommand("list")
.withPermission("yamipa.list")
.withArguments(new IntegerArgument("page", 1))
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.listImages(sender, (int) args[0]);
ImageCommand.showHelp(sender, (String) args[0]);
});
// Clear command
root.addSubcommand("clear")
.withPermission("yamipa.command.clear", "yamipa.clear")
.withArgument(new IntegerArgument("x"))
.withArgument(new IntegerArgument("z"))
.withArgument(new WorldArgument("world"))
.withArgument(new IntegerArgument("radius", 1))
.withArgument(new PlacedByArgument("placedBy"))
.executes((sender, args) -> {
Location origin = new Location((World) args[3], (int) args[1], 0, (int) args[2]);
ImageCommand.clearImages(sender, origin, (int) args[4], (OfflinePlayer) args[5]);
});
root.addSubcommand("clear")
.withPermission("yamipa.command.clear", "yamipa.clear")
.withArgument(new IntegerArgument("x"))
.withArgument(new IntegerArgument("z"))
.withArgument(new WorldArgument("world"))
.withArgument(new IntegerArgument("radius", 1))
.executes((sender, args) -> {
Location origin = new Location((World) args[3], (int) args[1], 0, (int) args[2]);
ImageCommand.clearImages(sender, origin, (int) args[4], null);
});
// Describe command
root.addSubcommand("describe")
.withPermission("yamipa.command.describe", "yamipa.describe")
.executesPlayer((player, __) -> {
ImageCommand.describeImage(player);
});
group.withSubcommand(listWithPage);
// Download command
CommandAPICommand download = new CommandAPICommand("download")
.withPermission("yamipa.download")
.withArguments(new TextArgument("url"))
.withArguments(new TextArgument("filename"))
root.addSubcommand("download")
.withPermission("yamipa.command.download", "yamipa.download")
.withArgument(new StringArgument("url"))
.withArgument(new StringArgument("filename"))
.executes((sender, args) -> {
ImageCommand.downloadImage(sender, (String) args[0], (String) args[1]);
ImageCommand.downloadImage(sender, (String) args[1], (String) args[2]);
});
group.withSubcommand(download);
// Place command (only width)
CommandAPICommand placeOnlyWidth = new CommandAPICommand("place")
.withPermission("yamipa.place")
.withArguments(getFilenameArgument())
.withArguments(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
.executesPlayer((sender, args) -> {
ImageCommand.placeImage(sender, (String) args[0], (int) args[1], 0);
// Give subcommand
root.addSubcommand("give")
.withPermission("yamipa.command.give", "yamipa.give")
.withArgument(new OnlinePlayerArgument("player"))
.withArgument(new ImageFileArgument("filename"))
.withArgument(new IntegerArgument("amount", 1, 64))
.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]);
});
group.withSubcommand(placeOnlyWidth);
// Place command
CommandAPICommand place = new CommandAPICommand("place")
.withPermission("yamipa.place")
.withArguments(getFilenameArgument())
.withArguments(new IntegerArgument("width", 1, FakeImage.MAX_DIMENSION))
.withArguments(new IntegerArgument("height", 1, FakeImage.MAX_DIMENSION))
.executesPlayer((sender, args) -> {
ImageCommand.placeImage(sender, (String) args[0], (int) args[1], (int) args[2]);
root.addSubcommand("give")
.withPermission("yamipa.command.give", "yamipa.give")
.withArgument(new OnlinePlayerArgument("player"))
.withArgument(new ImageFileArgument("filename"))
.withArgument(new IntegerArgument("amount", 1, 64))
.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);
});
group.withSubcommand(place);
// Remove command
CommandAPICommand remove = new CommandAPICommand("remove")
.withPermission("yamipa.remove")
.executesPlayer((sender, __) -> {
ImageCommand.removeImage(sender);
root.addSubcommand("give")
.withPermission("yamipa.command.give", "yamipa.give")
.withArgument(new OnlinePlayerArgument("player"))
.withArgument(new ImageFileArgument("filename"))
.withArgument(new IntegerArgument("amount", 1, 64))
.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);
});
group.withSubcommand(remove);
// Remove command (in radius)
CommandAPICommand removeInRadius = new CommandAPICommand("remove")
.withPermission("yamipa.remove.radius")
.withArguments(new IntegerArgument("radius", 1))
.executesPlayer((sender, args) -> {
ImageCommand.removeImagesInRadius(sender, (int) args[0]);
// List subcommand
root.addSubcommand("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.command.list", "yamipa.list")
.executes((sender, __) -> {
boolean isPlayer = (sender instanceof Player);
ImageCommand.listImages(sender, isPlayer ? 1 : 0);
});
group.withSubcommand(removeInRadius);
// Commit commands
group.register();
// Place subcommand
root.addSubcommand("place")
.withPermission("yamipa.command.place", "yamipa.place")
.withArgument(new ImageFileArgument("filename"))
.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.command.place", "yamipa.place")
.withArgument(new ImageFileArgument("filename"))
.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.command.place", "yamipa.place")
.withArgument(new ImageFileArgument("filename"))
.withArgument(new ImageDimensionArgument("width"))
.executesPlayer((player, args) -> {
ImageCommand.placeImage(player, (ImageFile) args[1], (int) args[2], 0,
FakeImage.DEFAULT_PLACE_FLAGS);
});
// Remove subcommand
root.addSubcommand("remove")
.withPermission("yamipa.command.remove.own", "yamipa.remove")
.executesPlayer((player, __) -> {
ImageCommand.removeImage(player);
});
// Top subcommand
root.addSubcommand("top")
.withPermission("yamipa.command.top", "yamipa.top")
.executes((sender, __) -> {
ImageCommand.showTopPlayers(sender);
});
return root;
}
/**
* Get filename argument
* @return Filename argument
*/
private static Argument getFilenameArgument() {
return new TextArgument("filename")
.overrideSuggestions(__ -> YamipaPlugin.getInstance().getStorage().getAllFilenames());
private static void fixPermissions(@NotNull String commandName) {
org.bukkit.command.Command command = Internals.getCommandMap().getCommand(commandName);
if (command != null) { // Command may have been aliased to null in "commands.yml"
command.setPermission(null);
}
}
}
@@ -0,0 +1,66 @@
package io.josemmo.bukkit.plugin.commands.arguments;
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;
/**
* Argument constructor
* @param name Argument name
*/
public Argument(@NotNull String name) {
this.name = name;
}
/**
* Get argument name
* @return Argument name
*/
public @NotNull String getName() {
return name;
}
/**
* Build argument
* @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
* @param rawValue Argument value provided by Brigadier
* @return Parsed argument value
*/
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
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;
}
}
@@ -0,0 +1,41 @@
package io.josemmo.bukkit.plugin.commands.arguments;
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;
public class ImageFileArgument extends StringArgument {
/**
* Image File Argument constructor
* @param name Argument name
*/
public ImageFileArgument(@NotNull String name) {
super(name);
}
@Override
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 {
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;
}
}
@@ -0,0 +1,96 @@
package io.josemmo.bukkit.plugin.commands.arguments;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import io.josemmo.bukkit.plugin.renderer.FakeImage;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.util.Arrays;
import java.util.Collections;
import java.util.concurrent.CompletableFuture;
public class ImageFlagsArgument extends Argument {
private final int defaultFlags;
/**
* Image File Argument constructor
* @param name Argument name
* @param defaultFlags Default flags
*/
public ImageFlagsArgument(@NotNull String name, int defaultFlags) {
super(name);
this.defaultFlags = defaultFlags;
}
@Override
public @NotNull RequiredArgumentBuilder<?, ?> build() {
return RequiredArgumentBuilder.argument(name, StringArgumentType.greedyString());
}
@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("-"))
);
input = input.substring(0, lastIndex+1);
// Add suggestions
String[] values = new String[] {"ANIM", "REMO", "DROP", "GLOW"};
for (String value : values) {
builder.suggest(input + value);
}
return builder.buildFuture();
}
@Override
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
String value = (String) rawValue;
int parsedValue = defaultFlags;
// Load flags from parts
String[] parts = value.split(",");
for (String part : parts) {
// Parse modifier
String modifier = "";
if (part.startsWith("+") || part.startsWith("-")) {
modifier = part.substring(0, 1);
part = part.substring(1);
}
// Parse flag name
int flag;
switch (part) {
case "ANIM":
flag = FakeImage.FLAG_ANIMATABLE;
break;
case "REMO":
flag = FakeImage.FLAG_REMOVABLE;
break;
case "DROP":
flag = FakeImage.FLAG_DROPPABLE;
break;
case "GLOW":
flag = FakeImage.FLAG_GLOWING;
break;
default:
throw newException("Unrecognized flag \"" + part + "\"");
}
// Apply flag to value
if (modifier.equals("+")) {
parsedValue |= flag;
} else if (modifier.equals("-")) {
parsedValue &= ~flag;
} else {
parsedValue = flag;
}
}
return parsedValue;
}
}
@@ -0,0 +1,44 @@
package io.josemmo.bukkit.plugin.commands.arguments;
import com.mojang.brigadier.arguments.IntegerArgumentType;
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
import org.jetbrains.annotations.NotNull;
public class IntegerArgument extends Argument {
private final int min;
private final int max;
/**
* Integer Argument constructor
* @param name Argument name
* @param min Minimum allowed value
* @param max Maximum allowed value
*/
public IntegerArgument(@NotNull String name, int min, int max) {
super(name);
this.min = min;
this.max = max;
}
/**
* Integer Argument constructor
* @param name Argument name
* @param min Minimum allowed value
*/
public IntegerArgument(@NotNull String name, int min) {
this(name, min, Integer.MAX_VALUE);
}
/**
* Integer Argument constructor
* @param name Argument name
*/
public IntegerArgument(@NotNull String name) {
this(name, Integer.MIN_VALUE, Integer.MAX_VALUE);
}
@Override
public @NotNull RequiredArgumentBuilder<?, ?> build() {
return RequiredArgumentBuilder.argument(name, IntegerArgumentType.integer(min, max));
}
}
@@ -0,0 +1,46 @@
package io.josemmo.bukkit.plugin.commands.arguments;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
public class OnlinePlayerArgument extends StringArgument {
/**
* Online player argument constructor
* @param name Argument name
*/
public OnlinePlayerArgument(@NotNull String name) {
super(name);
}
@Override
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
getAllowedValues().keySet().forEach(builder::suggest);
return builder.buildFuture();
}
@Override
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
Player player = getAllowedValues().get((String) rawValue);
if (player == null) {
throw newException("Expected online player (name or UUID)");
}
return player;
}
private @NotNull Map<String, Player> getAllowedValues() {
Map<String, Player> values = new HashMap<>();
for (Player player : Bukkit.getOnlinePlayers()) {
values.put(player.getUniqueId().toString(), player);
values.put(player.getName(), player);
}
return values;
}
}
@@ -0,0 +1,50 @@
package io.josemmo.bukkit.plugin.commands.arguments;
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.renderer.ImageRenderer;
import org.bukkit.OfflinePlayer;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
public class PlacedByArgument extends StringArgument {
/**
* Placed by Argument constructor
* @param name Argument name
*/
public PlacedByArgument(@NotNull String name) {
super(name);
}
@Override
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
getAllowedValues().keySet().forEach(builder::suggest);
return builder.buildFuture();
}
@Override
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
OfflinePlayer player = getAllowedValues().get((String) rawValue);
if (player == null) {
throw newException("Expected player with placed images (name or UUID)");
}
return player;
}
private @NotNull Map<String, OfflinePlayer> getAllowedValues() {
Map<String, OfflinePlayer> values = new HashMap<>();
ImageRenderer renderer = YamipaPlugin.getInstance().getRenderer();
for (OfflinePlayer player : renderer.getPlayersWithPlacedImages()) {
values.put(player.getUniqueId().toString(), player);
if (player.getName() != null) {
values.put(player.getName(), player);
}
}
return values;
}
}
@@ -0,0 +1,20 @@
package io.josemmo.bukkit.plugin.commands.arguments;
import com.mojang.brigadier.arguments.StringArgumentType;
import com.mojang.brigadier.builder.RequiredArgumentBuilder;
import org.jetbrains.annotations.NotNull;
public class StringArgument extends Argument {
/**
* String Argument constructor
* @param name Argument name
*/
public StringArgument(@NotNull String name) {
super(name);
}
@Override
public @NotNull RequiredArgumentBuilder<?, ?> build() {
return RequiredArgumentBuilder.argument(name, StringArgumentType.string());
}
}
@@ -0,0 +1,37 @@
package io.josemmo.bukkit.plugin.commands.arguments;
import com.mojang.brigadier.exceptions.CommandSyntaxException;
import com.mojang.brigadier.suggestion.Suggestions;
import com.mojang.brigadier.suggestion.SuggestionsBuilder;
import org.bukkit.Bukkit;
import org.bukkit.World;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import java.util.concurrent.CompletableFuture;
public class WorldArgument extends StringArgument {
/**
* World Argument constructor
* @param name Argument name
*/
public WorldArgument(@NotNull String name) {
super(name);
}
@Override
public @NotNull CompletableFuture<Suggestions> suggest(@NotNull CommandSender sender, @NotNull SuggestionsBuilder builder) {
for (World world : Bukkit.getWorlds()) {
builder.suggest(world.getName());
}
return builder.buildFuture();
}
@Override
public @NotNull Object parse(@NotNull CommandSender sender, @NotNull Object rawValue) throws CommandSyntaxException {
World world = Bukkit.getWorld((String) rawValue);
if (world == null) {
throw newException("Expected world name");
}
return world;
}
}
@@ -0,0 +1,25 @@
package io.josemmo.bukkit.plugin.packets;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.wrappers.EnumWrappers;
import com.comphenix.protocol.wrappers.WrappedChatComponent;
import io.josemmo.bukkit.plugin.utils.Internals;
import org.jetbrains.annotations.NotNull;
public class ActionBarPacket extends PacketContainer {
private static final boolean USE_TITLE = (Internals.MINECRAFT_VERSION < 17);
@SuppressWarnings("deprecation")
public ActionBarPacket() {
super(USE_TITLE ? PacketType.Play.Server.TITLE : PacketType.Play.Server.SET_ACTION_BAR_TEXT);
if (USE_TITLE) {
getTitleActions().write(0, EnumWrappers.TitleAction.ACTIONBAR);
}
}
public @NotNull ActionBarPacket setText(@NotNull String text) {
getChatComponents().write(0, WrappedChatComponent.fromText(text));
return this;
}
}
@@ -0,0 +1,24 @@
package io.josemmo.bukkit.plugin.packets;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import io.josemmo.bukkit.plugin.utils.Internals;
import org.jetbrains.annotations.NotNull;
import java.util.Collections;
public class DestroyEntityPacket extends PacketContainer {
public DestroyEntityPacket() {
super(PacketType.Play.Server.ENTITY_DESTROY);
}
public @NotNull DestroyEntityPacket setId(int id) {
if (Internals.MINECRAFT_VERSION < 17) { // Minecraft 1.16.x
getIntegerArrays().write(0, new int[]{id});
} else if (Internals.MINECRAFT_VERSION < 17.1) { // Minecraft 1.17
getIntegers().write(0, id);
} else { // Minecraft 1.17.x
getIntLists().write(0, Collections.singletonList(id));
}
return this;
}
}
@@ -0,0 +1,87 @@
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(); // 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;
}
public EntityMetadataPacket() {
super(PacketType.Play.Server.ENTITY_METADATA);
}
public @NotNull EntityMetadataPacket setId(int id) {
getIntegers().write(0, id);
return this;
}
public @NotNull EntityMetadataPacket setFlags(byte flags) {
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.get(Byte.class);
if (USE_DATA_WATCHER) {
dataWatcher.setObject(new WrappedDataWatcher.WrappedDataWatcherObject(0, serializer), flags);
} else {
values.add(new WrappedDataValue(0, serializer, flags));
}
return this;
}
public @NotNull EntityMetadataPacket setInvisible(boolean invisible) {
int flags = invisible ? 0x20 : 0x00;
return setFlags((byte) flags);
}
public @NotNull EntityMetadataPacket setItem(@NotNull ItemStack item) {
WrappedDataWatcher.Serializer serializer = WrappedDataWatcher.Registry.getItemStackSerializer(false);
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);
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() {
if (USE_DATA_WATCHER) {
getWatchableCollectionModifier().write(0, dataWatcher.getWatchableObjects());
} else {
getDataValueCollectionModifier().write(0, values);
}
return this;
}
}
@@ -0,0 +1,73 @@
package io.josemmo.bukkit.plugin.packets;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.reflect.StructureModifier;
import io.josemmo.bukkit.plugin.utils.Internals;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public class MapDataPacket extends PacketContainer {
private static final int LOCKED_INDEX;
private @Nullable StructureModifier<?> mapDataModifier;
static {
LOCKED_INDEX = (Internals.MINECRAFT_VERSION < 17) ? 1 : 0;
}
public MapDataPacket() {
super(PacketType.Play.Server.MAP);
getModifier().writeDefaults();
if (Internals.MINECRAFT_VERSION < 17) {
// Disable tracking position
getBooleans().write(0, false);
} else {
// Create modifier for map data instance
Class<?> mapDataType = getModifier().getField(4).getType();
Object mapDataInstance = getModifier().read(4);
mapDataModifier = new StructureModifier<>(mapDataType).withTarget(mapDataInstance);
}
}
public @NotNull MapDataPacket setId(int id) {
getIntegers().write(0, id);
return this;
}
public @NotNull MapDataPacket setArea(int columns, int rows, int x, int z) {
if (mapDataModifier == null) {
getIntegers()
.write(1, x)
.write(2, z)
.write(3, columns)
.write (4, rows);
} else {
mapDataModifier.withType(Integer.TYPE)
.write(0, x)
.write(1, z)
.write(2, columns)
.write(3, rows);
}
return this;
}
public @NotNull MapDataPacket setScale(int scale) {
getBytes().write(0, (byte) scale);
return this;
}
public @NotNull MapDataPacket setLocked(boolean locked) {
getBooleans().write(LOCKED_INDEX, locked);
return this;
}
public @NotNull MapDataPacket setPixels(byte[] pixels) {
if (mapDataModifier == null) {
getByteArrays().write(0, pixels);
} else {
mapDataModifier.withType(byte[].class).write(0, pixels);
}
return this;
}
}
@@ -0,0 +1,69 @@
package io.josemmo.bukkit.plugin.packets;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import io.josemmo.bukkit.plugin.utils.Internals;
import org.bukkit.entity.EntityType;
import org.jetbrains.annotations.NotNull;
import java.util.UUID;
public class SpawnEntityPacket extends PacketContainer {
private static final boolean ROTATION_AS_BYTES;
private static final int DATA_INDEX;
static {
if (Internals.MINECRAFT_VERSION < 19) {
ROTATION_AS_BYTES = false;
DATA_INDEX = 6;
} else {
DATA_INDEX = 4;
ROTATION_AS_BYTES = true;
}
}
public SpawnEntityPacket() {
super(PacketType.Play.Server.SPAWN_ENTITY);
getIntegers()
.write(1, 0)
.write(2, 0)
.write(3, 0);
getUUIDs()
.write(0, UUID.randomUUID());
}
public @NotNull SpawnEntityPacket setEntityType(@NotNull EntityType type) {
getEntityTypeModifier().write(0, type);
return this;
}
public @NotNull SpawnEntityPacket setId(int id) {
getIntegers().write(0, id);
return this;
}
public @NotNull SpawnEntityPacket setRotation(int pitch, int yaw) {
if (ROTATION_AS_BYTES) {
getBytes()
.write(0, (byte) pitch)
.write(1, (byte) yaw);
} else {
getIntegers()
.write(4, pitch)
.write(5, yaw);
}
return this;
}
public @NotNull SpawnEntityPacket setData(int data) {
getIntegers().write(DATA_INDEX, data);
return this;
}
public @NotNull SpawnEntityPacket setPosition(double x, double y, double z) {
getDoubles()
.write(0, x)
.write(1, y)
.write(2, z);
return this;
}
}
@@ -1,26 +1,124 @@
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 java.util.logging.Level;
import org.jetbrains.annotations.NotNull;
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 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
*/
public static synchronized void waitForProtocolLib() {
if (READY) {
// ProtocolLib is ready
return;
}
int retry = 0;
while (true) {
try {
WrappedDataWatcher.Registry.get(Byte.class);
READY = true;
break;
} catch (Exception e) {
if (++retry > 20) {
// Exhausted max. retries
throw e;
}
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
* @param packet Packet to send
*/
protected static void tryToSendPacket(Player player, PacketContainer packet) {
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);
}
}
/**
* Try to run asynchronous task
* @param callback Callback to execute
*/
protected static void tryToRunAsyncTask(@NotNull Runnable callback) {
YamipaPlugin.getInstance().getScheduler().execute(callback);
}
}
@@ -1,30 +1,68 @@
package io.josemmo.bukkit.plugin.renderer;
import com.comphenix.protocol.PacketType;
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 org.bukkit.Bukkit;
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.scheduler.BukkitScheduler;
import org.bukkit.util.Vector;
import java.util.Arrays;
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;
import java.util.stream.Stream;
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)
public static final UUID UNKNOWN_PLAYER_ID = new UUID(0, 0);
// Flags
public static final int FLAG_ANIMATABLE = 1; // Whether image is allowed to animate multiple steps
public static final int FLAG_REMOVABLE = 2; // Whether image can be removed by a player using the interact button
public static final int FLAG_DROPPABLE = 4; // Whether image will drop an image item when removed by a player
public static final int FLAG_GLOWING = 8; // Whether image glows in the dark
public static final int DEFAULT_PLACE_FLAGS = FLAG_ANIMATABLE;
public static final int DEFAULT_GIVE_FLAGS = FLAG_ANIMATABLE | FLAG_REMOVABLE | FLAG_DROPPABLE;
// Instance properties
private final String filename;
private final Location location;
private final BlockFace face;
private final Rotation rotation;
private final int width;
private final int height;
private final Date placedAt;
private final OfflinePlayer placedBy;
private final int flags;
private final BiFunction<Integer, Integer, Vector> getLocationVector;
private FakeItemFrame[][] frames;
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 @Nullable ScheduledFuture<?> task;
private int currentStep = -1; // Current animation step
/**
* Get image rotation from player eyesight
@@ -32,7 +70,7 @@ public class FakeImage extends FakeEntity {
* @param location Player eye location
* @return Image rotation
*/
public static Rotation getRotationFromPlayerEyesight(BlockFace face, Location location) {
public static @NotNull Rotation getRotationFromPlayerEyesight(@NotNull BlockFace face, @NotNull Location location) {
// Images placed on N/S/E/W faces never have rotation
if (face != BlockFace.UP && face != BlockFace.DOWN) {
return Rotation.NONE;
@@ -53,21 +91,70 @@ public class FakeImage extends FakeEntity {
}
/**
* Class constructor
* @param filename Image filename
* @param location Top-left corner where image will be placed
* @param face Block face
* @param rotation Image rotation
* @param width Width in blocks
* @param height Height in blocks
* Get maximum image dimension
* @param sender Sender instance
* @return Maximum image dimension in blocks
*/
public FakeImage(String filename, Location location, BlockFace face, Rotation rotation, int width, int height) {
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 maximum image dimension for sender)
*/
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, getMaxImageDimension(sender));
return height;
}
/**
* Class constructor
* @param filename Image filename
* @param location Top-left corner where image will be placed
* @param face Block face
* @param rotation Image rotation
* @param width Width in blocks
* @param height Height in blocks
* @param placedAt Placed at
* @param placedBy Placed by
* @param flags Flags
*/
public FakeImage(
@NotNull String filename,
@NotNull Location location,
@NotNull BlockFace face,
@NotNull Rotation rotation,
int width,
int height,
@Nullable Date placedAt,
@NotNull OfflinePlayer placedBy,
int flags
) {
this.filename = filename;
this.location = location;
this.face = face;
this.rotation = rotation;
this.width = width;
this.height = height;
this.placedAt = placedAt;
this.placedBy = placedBy;
this.flags = flags;
// Define function for retrieving item frame positional vector from <row,column> pair
if (face == BlockFace.SOUTH) {
@@ -100,14 +187,14 @@ public class FakeImage extends FakeEntity {
}
}
plugin.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
LOGGER.fine("Created FakeImage#(" + location + "," + face + ") from ImageFile#(" + filename + ")");
}
/**
* Get image filename
* @return Image filename
*/
public String getFilename() {
public @NotNull String getFilename() {
return filename;
}
@@ -115,15 +202,15 @@ public class FakeImage extends FakeEntity {
* Get image file instance
* @return Image file instance or NULL if not found
*/
public ImageFile getFile() {
return plugin.getStorage().get(filename);
public @Nullable ImageFile getFile() {
return YamipaPlugin.getInstance().getStorage().get(filename);
}
/**
* Get location instance
* Get top-left corner of image
* @return Location instance
*/
public Location getLocation() {
public @NotNull Location getLocation() {
return location;
}
@@ -131,7 +218,7 @@ public class FakeImage extends FakeEntity {
* Get image block face
* @return Image block face
*/
public BlockFace getBlockFace() {
public @NotNull BlockFace getBlockFace() {
return face;
}
@@ -139,7 +226,7 @@ public class FakeImage extends FakeEntity {
* Get image rotation
* @return Image rotation
*/
public Rotation getRotation() {
public @NotNull Rotation getRotation() {
return rotation;
}
@@ -160,11 +247,71 @@ public class FakeImage extends FakeEntity {
}
/**
* Get world area ID where the top left corner of this image is located
* @return World area ID
* Get placed at date
* @return Placed at date
*/
public WorldAreaId getWorldAreaId() {
return WorldAreaId.fromLocation(location);
public @Nullable Date getPlacedAt() {
return placedAt;
}
/**
* Get placed by player
* @return Placed by player instance
*/
public @NotNull OfflinePlayer getPlacedBy() {
return placedBy;
}
/**
* Get flags
* @return Flags
*/
public int getFlags() {
return flags;
}
/**
* Has flag
* @param flag Flag to check
* @return Whether instance has given flag or not
*/
public boolean hasFlag(int flag) {
return ((flags & flag) == flag);
}
/**
* Get image delay
* @return Image delay in 50ms intervals
*/
public int getDelay() {
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() {
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]);
}
/**
@@ -173,7 +320,8 @@ public class FakeImage extends FakeEntity {
* @param face Block face
* @return TRUE for contained, FALSE otherwise
*/
public boolean contains(Location location, BlockFace face) {
@SuppressWarnings("RedundantIfStatement")
public boolean contains(@NotNull Location location, @NotNull BlockFace face) {
// Is point facing the same plane as the image?
if (face != this.face) {
return false;
@@ -198,76 +346,219 @@ public class FakeImage extends FakeEntity {
}
/**
* Load item frames and maps
* Set on loaded listener
* <p>
* Defines a single-use listener that gets called when the fake image finishes loading,
* at which point the listener is automatically unregistered.
* @param onLoadedListener Listener
*/
public void setOnLoadedListener(@NotNull Runnable onLoadedListener) {
this.onLoadedListener = onLoadedListener;
}
/**
* Load generated instance attributes
*/
private void load() {
ImageFile file = getFile();
FakeMap[][] maps;
// Get maps to use
FakeMap[][][] maps;
if (file == null) {
maps = FakeMap.getErrorMatrix(width, height);
plugin.warning("File \"" + filename + "\" does not exist");
LOGGER.warning("File \"" + filename + "\" does not exist");
} else {
maps = file.getMapsAndSubscribe(this);
CachedMapsFile cachedMapsFile = file.getMapsAndSubscribe(this);
maps = cachedMapsFile.getMaps();
delay = cachedMapsFile.getDelay();
}
numOfSteps = maps[0][0].length;
// 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[col][row] = new FakeItemFrame(frameLocation, face, rotation, 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) {
onLoadedListener.run();
onLoadedListener = null;
}
}
/**
* Spawn image for a player
* @param player Player instance
*/
public void spawn(Player player) {
BukkitScheduler scheduler = Bukkit.getScheduler();
scheduler.runTaskAsynchronously(plugin, () -> {
if (frames == null) load();
scheduler.runTask(plugin, () -> {
for (FakeItemFrame[] col : frames) {
for (FakeItemFrame frame : col) {
frame.spawn(player);
}
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(() -> {
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(Player player) {
if (frames == null) return;
private void spawnOnceLoaded(@NotNull Player player) {
String playerName = player.getName();
observingPlayers.add(player);
// Get frame IDs
int[] frameIds = Stream.of(frames).flatMap(Stream::of).mapToInt(FakeItemFrame::getId).toArray();
// 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 destroy packet
PacketContainer destroyPacket = new PacketContainer(PacketType.Play.Server.ENTITY_DESTROY);
destroyPacket.getIntegerArrays().write(0, frameIds);
tryToSendPacket(player, destroyPacket);
// Send packets
tryToSendPackets(player, packets);
}
/**
* Invalidate cache
* <p>
* Removes all item frames associated with this image.
* Destroy image for all players
*/
public void invalidate() {
public void destroy() {
if (frames != null) {
destroy(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())
);
// 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);
}
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);
}
}
plugin.fine("Invalidated FakeImage#(" + location + "," + face + ")");
/**
* Send next animation step to all registered players
*/
private void nextStep() {
currentStep = (currentStep + 1) % numOfSteps;
try {
for (Player player : observingPlayers) {
List<PacketContainer> packets = new ArrayList<>();
for (FakeItemFrame frame : frames) {
packets.addAll(frame.getRenderPackets(player, currentStep));
}
tryToSendPackets(player, packets);
}
} catch (ConcurrentModificationException e) {
// We can safely ignore this exception as it will just result
// in a dropped step (all `observingPlayers` modifications are
// called from the same thread).
}
}
}
@@ -1,9 +1,14 @@
package io.josemmo.bukkit.plugin.renderer;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
import com.comphenix.protocol.wrappers.WrappedDataWatcher;
import de.tr7zw.changeme.nbtapi.NBTItem;
import com.comphenix.protocol.utility.MinecraftReflection;
import com.comphenix.protocol.wrappers.nbt.NbtCompound;
import com.comphenix.protocol.wrappers.nbt.NbtFactory;
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;
@@ -11,27 +16,31 @@ import org.bukkit.block.BlockFace;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.Player;
import org.bukkit.inventory.ItemStack;
import java.util.UUID;
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 AtomicInteger lastFrameId = new AtomicInteger(MAX_FRAME_ID);
private static final boolean SUPPORTS_GLOWING = Internals.MINECRAFT_VERSION >= 17;
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;
private final Rotation rotation;
private final FakeMap map;
private PacketContainer[] spawnPackets;
private final boolean glowing;
private final FakeMap[] maps;
/**
* Get next unused item frame ID
* @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;
@@ -43,31 +52,38 @@ public class FakeItemFrame extends FakeEntity {
* @param location Frame location
* @param face Block face
* @param rotation Frame rotation
* @param map Fake map to render
* @param glowing Whether is glowing or regular frame
* @param maps Fake maps to animate
*/
public FakeItemFrame(Location location, BlockFace face, Rotation rotation, FakeMap map) {
public FakeItemFrame(
@NotNull Location location,
@NotNull BlockFace face,
@NotNull Rotation rotation,
boolean glowing,
@NotNull FakeMap[] maps
) {
this.id = getNextId();
this.location = location;
this.face = face;
this.rotation = rotation;
this.map = map;
plugin.fine("Created FakeItemFrame#" + this.id + " using FakeMap#" + this.map.getId());
this.glowing = glowing;
this.maps = maps;
LOGGER.fine("Created FakeItemFrame#" + this.id + " using " + this.maps.length + " FakeMap(s)");
}
/**
* Get item frame ID
* @return Item frame ID
* Get frame ID
* @return Frame ID
*/
public int getId() {
return id;
}
/**
* Generate spawn packets
* Get entity spawn packet
* @return Spawn packet
*/
private void generateSpawnPackets() {
spawnPackets = new PacketContainer[2];
public @NotNull SpawnEntityPacket getSpawnPacket() {
// Calculate frame position in relation to target block
double x = location.getBlockX();
double y = location.getBlockY();
@@ -106,64 +122,55 @@ public class FakeItemFrame extends FakeEntity {
}
// Create item frame entity
PacketContainer framePacket = new PacketContainer(PacketType.Play.Server.SPAWN_ENTITY);
framePacket.getEntityTypeModifier()
.write(0, EntityType.ITEM_FRAME);
framePacket.getIntegers()
.write(0, id)
.write(1, 0)
.write(2, 0)
.write(3, 0)
.write(4, pitch)
.write(5, yaw)
.write(6, orientation);
framePacket.getUUIDs()
.write(0, UUID.randomUUID());
framePacket.getDoubles()
.write(0, x)
.write(1, y)
.write(2, z);
spawnPackets[0] = framePacket;
SpawnEntityPacket framePacket = new SpawnEntityPacket();
framePacket.setId(id)
.setEntityType((glowing && SUPPORTS_GLOWING) ? EntityType.GLOW_ITEM_FRAME : EntityType.ITEM_FRAME)
.setPosition(x, y, z)
.setRotation(pitch, yaw)
.setData(orientation);
// Create and attach filled map
NBTItem mapNbt = new NBTItem(new ItemStack(Material.FILLED_MAP));
mapNbt.setInteger("map", map.getId());
ItemStack mapItemStack = mapNbt.getItem();
WrappedDataWatcher dataWatcher = new WrappedDataWatcher();
WrappedDataWatcher.Serializer byteSerializer = WrappedDataWatcher.Registry.get(Byte.class);
WrappedDataWatcher.Serializer itemStackSerializer = WrappedDataWatcher.Registry.getItemStackSerializer(false);
WrappedDataWatcher.Serializer integerSerializer = WrappedDataWatcher.Registry.get(Integer.class);
dataWatcher.setObject(
new WrappedDataWatcher.WrappedDataWatcherObject(0, byteSerializer),
(byte) 0x20 // Invisible
);
dataWatcher.setObject(
new WrappedDataWatcher.WrappedDataWatcherObject(7, itemStackSerializer),
mapItemStack
);
dataWatcher.setObject(
new WrappedDataWatcher.WrappedDataWatcherObject(8, integerSerializer),
rotation.ordinal()
);
PacketContainer mapPacket = new PacketContainer(PacketType.Play.Server.ENTITY_METADATA);
mapPacket.getIntegers().write(0, id);
mapPacket.getWatchableCollectionModifier().write(0, dataWatcher.getWatchableObjects());
spawnPackets[1] = mapPacket;
return framePacket;
}
/**
* Spawn item frame in player's client
* @param player Player instance
* Get frame of animation packets
* @param player Player who is expected to receive packets (for caching reasons)
* @param step Map step
*/
public void spawn(Player player) {
if (spawnPackets == null) {
generateSpawnPackets();
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());
}
for (PacketContainer packet : spawnPackets) {
tryToSendPacket(player, packet);
}
map.sendPixels(player);
// Create and attach filled map
ItemStack itemStack = MinecraftReflection.getBukkitItemStack(new ItemStack(Material.FILLED_MAP));
NbtCompound itemStackNbt = NbtFactory.ofCompound("tag");
itemStackNbt.put("map", maps[step].getId());
NbtFactory.setItemTag(itemStack, itemStackNbt);
// Build entity metadata packet
EntityMetadataPacket metadataPacket = new EntityMetadataPacket();
metadataPacket.setId(id)
.setInvisible(true)
.setItem(itemStack)
.setRotation(rotation)
.build();
packets.add(metadataPacket);
return packets;
}
/**
* Get destroy item frame packet
* @return Destroy packet
*/
public @NotNull DestroyEntityPacket getDestroyPacket() {
DestroyEntityPacket destroyPacket = new DestroyEntityPacket();
destroyPacket.setId(id);
return destroyPacket;
}
}
@@ -1,9 +1,11 @@
package io.josemmo.bukkit.plugin.renderer;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.events.PacketContainer;
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 = 10000;
public static final int MAX_MAP_ID = 32767;
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;
@@ -50,26 +53,26 @@ public class FakeMap extends FakeEntity {
* Get map instance to show in case of error
* @return Error instance
*/
private static FakeMap getErrorInstance() {
if (errorInstance == null) {
private static @NotNull FakeMap getErrorInstance() {
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 Error matrix
* @return Fake maps
*/
public static FakeMap[][] getErrorMatrix(int width, int height) {
FakeMap errorMap = getErrorInstance();
FakeMap[][] matrix = new FakeMap[width][height];
for (FakeMap[] column : matrix) {
Arrays.fill(column, errorMap);
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 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,36 +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(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
PacketContainer mapDataPacket = new PacketContainer(PacketType.Play.Server.MAP);
mapDataPacket.getModifier().writeDefaults();
mapDataPacket.getIntegers()
.write(0, id)
.write(1, 0) // X
.write(2, 0) // Z
.write(3, DIMENSION) // Columns
.write (4, DIMENSION); // Rows
mapDataPacket.getBytes()
.write(0, (byte) 0); // Scale (fully zoomed-in map)
mapDataPacket.getBooleans()
.write(0, false) // Tracking Position
.write(1, true); // Locked
mapDataPacket.getByteArrays()
.write(0, pixels);
// Send packet
tryToSendPacket(player, mapDataPacket);
// Authorize re-send and update latest timestamp
lastPlayerSendTime.put(uuid, now);
plugin.fine("Sent pixels for FakeMap#" + id + " to Player#" + player.getName());
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);
return mapDataPacket;
}
}
@@ -2,42 +2,67 @@ package io.josemmo.bukkit.plugin.renderer;
import io.josemmo.bukkit.plugin.YamipaPlugin;
import io.josemmo.bukkit.plugin.utils.CsvConfiguration;
import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.Rotation;
import org.bukkit.World;
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 {
static public 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 Map<UUID, WorldAreaId> playersLocation = new HashMap<>();
private final ConcurrentMap<WorldAreaId, Set<FakeImage>> images = new ConcurrentHashMap<>();
private final ConcurrentMap<UUID, Integer> imagesCountByPlayer = new ConcurrentHashMap<>();
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(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;
}
/**
@@ -45,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);
}
@@ -56,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
@@ -65,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;
}
@@ -81,27 +114,38 @@ 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;
}
// Parse each row
for (String[] row : config.getRows()) {
if (row.length != 9) continue;
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])));
addImage(new FakeImage(filename, location, face, rotation, width, height), true);
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].isEmpty()) ?
UUID.fromString(row[10]) :
FakeImage.UNKNOWN_PLAYER_ID;
OfflinePlayer placedBy = Bukkit.getOfflinePlayer(placedById);
int flags = (row.length > 11) ?
Math.max(Integer.parseInt(row[11]), 0) :
FakeImage.DEFAULT_PLACE_FLAGS;
FakeImage fakeImage = new FakeImage(filename, location, face, rotation, width, height,
placedAt, placedBy, flags);
addImage(fakeImage, true);
} catch (Exception e) {
plugin.warning("Invalid fake image properties: " + String.join(";", row));
LOGGER.severe("Invalid fake image properties: " + String.join(";", row), e);
}
}
}
@@ -109,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)
@@ -125,6 +170,7 @@ public class ImageRenderer implements Listener {
CsvConfiguration config = new CsvConfiguration();
for (FakeImage fakeImage : fakeImages) {
Location location = fakeImage.getLocation();
UUID placedById = fakeImage.getPlacedBy().getUniqueId();
String[] row = new String[]{
fakeImage.getFilename(),
location.getChunk().getWorld().getName(),
@@ -134,7 +180,10 @@ public class ImageRenderer implements Listener {
fakeImage.getBlockFace().name(),
fakeImage.getRotation().name(),
fakeImage.getWidth() + "",
fakeImage.getHeight() + ""
fakeImage.getHeight() + "",
(fakeImage.getPlacedAt() == null) ? "" : (fakeImage.getPlacedAt().getTime() / 1000) + "",
placedById.equals(FakeImage.UNKNOWN_PLAYER_ID) ? "" : placedById.toString(),
fakeImage.getFlags() + ""
};
config.addRow(row);
}
@@ -142,35 +191,49 @@ 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);
}
}
/**
* Add image to renderer
* @param fakeImage Fake image instance
* @param isInit TRUE if called during renderer startup, FALSE otherwise
* @param image Fake image instance
* @param isInit TRUE if called during renderer startup, FALSE otherwise
*/
public void addImage(FakeImage fakeImage, boolean isInit) {
WorldAreaId worldAreaId = fakeImage.getWorldAreaId();
WorldArea worldArea = worldAreas.computeIfAbsent(worldAreaId, __ -> {
plugin.fine("Created WorldArea#(" + worldAreaId + ")");
return new WorldArea(worldAreaId);
});
worldArea.addImage(fakeImage);
public void addImage(@NotNull FakeImage image, boolean isInit) {
WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds();
// Add image to renderer
for (WorldAreaId worldAreaId : imageWorldAreaIds) {
images.computeIfAbsent(worldAreaId, __ -> {
LOGGER.fine("Created WorldArea#(" + worldAreaId + ")");
return ConcurrentHashMap.newKeySet();
}).add(image);
}
// Set configuration changed flag
if (!isInit) {
hasConfigChanged.set(true);
}
// Increment count of placed images by player
UUID placedById = image.getPlacedBy().getUniqueId();
imagesCountByPlayer.compute(placedById, (__, prev) -> (prev == null) ? 1 : prev+1);
// Spawn image in players nearby
for (Player player : getPlayersInViewDistance(imageWorldAreaIds)) {
image.spawn(player);
}
}
/**
* Add image to renderer
* @param fakeImage Fake image instance
* @param image Fake image instance
*/
public void addImage(FakeImage fakeImage) {
addImage(fakeImage, false);
public void addImage(@NotNull FakeImage image) {
addImage(image, false);
}
/**
@@ -179,15 +242,15 @@ public class ImageRenderer implements Listener {
* @param face Fake image block face
* @return Fake image instance or NULL if not found
*/
public FakeImage getImage(Location location, BlockFace face) {
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;
}
@@ -204,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 Set<FakeImage> getImages(World world, int minX, int maxX, int minZ, int maxZ) {
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;
@@ -224,31 +287,99 @@ public class ImageRenderer implements Listener {
* Remove image from renderer
* @param image Fake image instance
*/
public void removeImage(FakeImage image) {
WorldAreaId worldAreaId = image.getWorldAreaId();
WorldArea worldArea = worldAreas.get(worldAreaId);
if (worldArea != null) {
worldArea.removeImage(image);
if (!worldArea.hasImages()) {
plugin.fine("Destroyed WorldArea#(" + worldAreaId + ")");
worldAreas.remove(worldAreaId);
public void removeImage(@NotNull FakeImage image) {
WorldAreaId[] imageWorldAreaIds = image.getWorldAreaIds();
// Destroy image from all players nearby
image.destroy();
// Remove image from renderer
for (WorldAreaId worldAreaId : imageWorldAreaIds) {
Set<FakeImage> worldAreaImages = images.get(worldAreaId);
worldAreaImages.remove(image);
if (worldAreaImages.isEmpty()) {
LOGGER.fine("Destroyed WorldArea#(" + worldAreaId + ")");
images.remove(worldAreaId);
}
}
// Set configuration changed flag
hasConfigChanged.set(true);
// Decrement count of placed images by player
UUID placedById = image.getPlacedBy().getUniqueId();
imagesCountByPlayer.compute(placedById, (__, prev) -> (prev != null && prev > 1) ? prev-1 : null);
}
/**
* Get available world areas from IDs
* @param ids World area IDs
* @return World area instances
* Get set of players who have placed images
* @return Offline players
*/
private Set<WorldArea> getWorldAreas(WorldAreaId[] ids) {
Set<WorldArea> instances = new HashSet<>();
for (WorldAreaId id : ids) {
if (!worldAreas.containsKey(id)) continue;
instances.add(worldAreas.get(id));
public @NotNull Set<OfflinePlayer> getPlayersWithPlacedImages() {
return imagesCountByPlayer.keySet().stream().map(Bukkit::getOfflinePlayer).collect(Collectors.toSet());
}
/**
* Get number of placed images
* @return Number of placed images
*/
public int size() {
return imagesCountByPlayer.values().stream().reduce(0, Integer::sum);
}
/**
* Get number of placed images grouped by player
* <p>
* NOTE: Response is sorted by image count (descending)
* @return Images count by player
*/
public @NotNull Map<OfflinePlayer, Integer> getImagesCountByPlayer() {
List<Map.Entry<UUID, Integer>> sortedEntries = new ArrayList<>(imagesCountByPlayer.entrySet());
sortedEntries.sort(Map.Entry.comparingByValue(Comparator.reverseOrder()));
Map<OfflinePlayer, Integer> sortedMap = new LinkedHashMap<>();
for (Map.Entry<UUID, Integer> entry : sortedEntries) {
sortedMap.put(Bukkit.getOfflinePlayer(entry.getKey()), entry.getValue());
}
return instances;
return sortedMap;
}
/**
* Get players in view distance of the provided world area IDs
* @param ids World area IDs
* @return Players inside those world areas
*/
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<Player, WorldAreaId> entry : playersLocation.entrySet()) {
if (neighborhood.contains(entry.getValue())) {
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;
}
/**
@@ -256,80 +387,91 @@ public class ImageRenderer implements Listener {
* @param player Player instance
* @param location New player location
*/
private void onPlayerLocationChange(Player player, Location location) {
UUID uuid = player.getUniqueId();
private void onPlayerLocationChange(@NotNull Player player, @NotNull Location location) {
// 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);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerJoin(PlayerJoinEvent event) {
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onPlayerJoin(@NotNull PlayerJoinEvent event) {
onPlayerLocationChange(event.getPlayer(), event.getPlayer().getLocation());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerQuit(PlayerQuitEvent event) {
@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);
}
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerRespawn(PlayerRespawnEvent event) {
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onPlayerRespawn(@NotNull PlayerRespawnEvent event) {
onPlayerLocationChange(event.getPlayer(), event.getPlayer().getLocation());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerChangedWorld(PlayerChangedWorldEvent event) {
onPlayerLocationChange(event.getPlayer(), event.getPlayer().getLocation());
}
@EventHandler(priority = EventPriority.MONITOR)
public void onPlayerMove(PlayerMoveEvent event) {
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onPlayerTeleport(@NotNull PlayerTeleportEvent event) {
if (event.getTo() == null) return;
if (event.getFrom().getChunk().equals(event.getTo().getChunk())) return;
// Player is about to change to another chunk, notify event
// Wait until next server tick before handling location change
// This is necessary as teleport events get fired *before* teleporting the player
YamipaPlugin plugin = YamipaPlugin.getInstance();
Bukkit.getScheduler().runTask(plugin, () -> onPlayerLocationChange(event.getPlayer(), event.getTo()));
}
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onPlayerMove(@NotNull PlayerMoveEvent event) {
if (event.getTo() == null) return;
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());
}
}
}
}
@@ -0,0 +1,232 @@
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;
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.hanging.HangingPlaceEvent;
import org.bukkit.event.inventory.PrepareItemCraftEvent;
import org.bukkit.inventory.CraftingInventory;
import org.bukkit.inventory.ItemStack;
import org.bukkit.inventory.PlayerInventory;
import org.bukkit.inventory.meta.ItemMeta;
import org.bukkit.persistence.PersistentDataContainer;
import org.bukkit.persistence.PersistentDataType;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collections;
import java.util.Objects;
public class ItemService extends InteractWithEntityListener implements Listener {
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
* @param image Image file
* @param amount Stack amount
* @param width Image width in blocks
* @param height Image height in blocks
* @param flags Image flags
* @return Image item
*/
public static @NotNull ItemStack getImageItem(@NotNull ImageFile image, int amount, int width, int height, int flags) {
ItemStack itemStack = new ItemStack(Material.ITEM_FRAME, amount);
ItemMeta itemMeta = Objects.requireNonNull(itemStack.getItemMeta());
// Set metadata
PersistentDataContainer itemData = itemMeta.getPersistentDataContainer();
itemMeta.setDisplayName(image.getFilename() + ChatColor.AQUA + " (" + width + "x" + height + ")");
itemMeta.setLore(Collections.singletonList("Yamipa image"));
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);
itemStack.setItemMeta(itemMeta);
return itemStack;
}
/**
* Start service
*/
public void start() {
register();
YamipaPlugin plugin = YamipaPlugin.getInstance();
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}
/**
* Stop service
*/
public void stop() {
unregister();
HandlerList.unregisterAll(this);
}
@EventHandler(priority = EventPriority.LOWEST)
public void onCraftItem(@NotNull PrepareItemCraftEvent event) {
CraftingInventory inventory = event.getInventory();
for (@Nullable ItemStack item : inventory.getMatrix()) {
if (item == null) continue;
// Get metadata from item
ItemMeta itemMeta = item.getItemMeta();
if (itemMeta == null) continue;
// Prevent crafting recipes with image items
if (itemMeta.getPersistentDataContainer().has(NSK_FILENAME, PersistentDataType.STRING)) {
inventory.setResult(new ItemStack(Material.AIR));
break;
}
}
}
@EventHandler(ignoreCancelled = true, priority = EventPriority.LOWEST)
public void onPlaceItem(@NotNull HangingPlaceEvent event) {
Player player = event.getPlayer();
if (player == null) return;
PlayerInventory inventory = player.getInventory();
// 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) {
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) {
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) {
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 = 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;
// 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());
YamipaPlugin plugin = YamipaPlugin.getInstance();
Bukkit.getScheduler().scheduleSyncDelayedTask(plugin, () -> block.getWorld().dropItem(dropLocation, imageItem));
}
return false;
}
@Override
public boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
// Intentionally left blank
return true;
}
}
@@ -1,122 +0,0 @@
package io.josemmo.bukkit.plugin.renderer;
import org.bukkit.entity.Player;
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(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 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 Set<FakeImage> getImages() {
return fakeImages;
}
/**
* Add fake image
* @param image Fake image instance
*/
public void addImage(FakeImage image) {
fakeImages.add(image);
for (Player player : players) {
image.spawn(player);
}
}
/**
* Remove fake image
* @param image Fake image instance
*/
public void removeImage(FakeImage image) {
fakeImages.remove(image);
for (Player player : players) {
image.destroy(player);
}
}
/**
* Load for player
* @param player Player instance
*/
public void load(Player player) {
players.add(player);
for (FakeImage image : fakeImages) {
image.spawn(player);
}
}
/**
* Remove player from world area
* @param player Player instance
*/
public void removePlayer(Player player) {
players.remove(player);
if (players.isEmpty()) {
for (FakeImage image : fakeImages) {
image.invalidate();
}
}
}
/**
* Unload for player
* @param player Player instance
*/
public void unload(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,21 +1,40 @@
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
* @param location Location instance
* @return World area ID
*/
public static WorldAreaId fromLocation(Location location) {
public static @NotNull WorldAreaId fromLocation(@NotNull Location location) {
Chunk chunk = location.getChunk();
return new WorldAreaId(chunk.getWorld(), chunk.getX() >> 2, chunk.getZ() >> 2);
}
@@ -26,7 +45,7 @@ public class WorldAreaId {
* @param x World area X coordinate
* @param z World area Z coordinate
*/
public WorldAreaId(World world, int x, int z) {
public WorldAreaId(@NotNull World world, int x, int z) {
this.world = world;
this.x = x;
this.z = z;
@@ -36,30 +55,349 @@ public class WorldAreaId {
* Get world instance
* @return World instance
*/
public World getWorld() {
public @NotNull World getWorld() {
return world;
}
/**
* 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 WorldAreaId[] getNeighborhood() {
return new WorldAreaId[]{
public @NotNull WorldAreaId[] getNeighborhood() {
// 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
public boolean equals(Object o) {
public boolean equals(@Nullable Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
WorldAreaId that = (WorldAreaId) o;
@@ -72,7 +410,7 @@ public class WorldAreaId {
}
@Override
public String toString() {
public @NotNull String toString() {
return world.getName() + "," + x + "," + z;
}
}
@@ -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,230 +1,117 @@
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.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.stream.ImageInputStream;
import java.awt.*;
import java.awt.image.BufferedImage;
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.nio.file.Path;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.regex.Pattern;
import java.util.stream.IntStream;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class ImageFile {
public static final String CACHE_EXT = "cache";
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
private final Map<String, FakeMap[][]> cache = new HashMap<>();
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, 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(String name, 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 String getName() {
return name;
}
/**
* Get buffered image
* @return Buffered image
* @throws IOException if not a valid image file
* @throws NullPointerException if file not found
*/
private BufferedImage getBufferedImage() throws Exception {
return ImageIO.read(new File(path));
}
/**
* Get resized buffered image
* @param width New width in pixels
* @param height New height in pixels
* @return Resized buffered image
* @throws IOException if not a valid image file
* @throws NullPointerException if file not found
*/
private BufferedImage getBufferedImage(int width, int height) throws Exception {
Image tmp = getBufferedImage().getScaledInstance(width, height, Image.SCALE_FAST);
BufferedImage resizedImage = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
// Copy data from temporary to resized instance
Graphics2D g2d = resizedImage.createGraphics();
g2d.drawImage(tmp, 0, 0, null);
g2d.dispose();
return resizedImage;
public @NotNull String getFilename() {
return filename;
}
/**
* Get original size in pixels
* @return Dimension instance or NULL if not a valid image file
*/
public Dimension getSize() {
try {
BufferedImage image = getBufferedImage();
return new Dimension(image.getWidth(), image.getHeight());
} catch (Exception e) {
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 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 Bi-dimensional array of maps
* @return Cached maps
*/
public synchronized FakeMap[][] getMapsAndSubscribe(FakeImage subscriber) {
@Blocking
public @NotNull CachedMapsFile getMapsAndSubscribe(@NotNull FakeImage subscriber) {
int width = subscriber.getWidth();
int height = subscriber.getHeight();
String cacheKey = width + "-" + height;
// Try to get maps from memory cache
if (cache.containsKey(cacheKey)) {
return cache.get(cacheKey);
// Prevent rendering the same image/dimensions pair multiple times
Lock lock = locks.computeIfAbsent(cacheKey, __ -> new ReentrantLock());
lock.lock();
// Get cached maps without locking this instance
CachedMapsFile maps = cache.get(cacheKey);
if (maps == null) {
maps = CachedMapsFile.from(this, width, height);
}
// 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 {
FakeMap[][] maps = readMapsFromCacheFile(cacheFile, width, height);
// Update state of this instance
synchronized (this) {
if (cache.get(cacheKey) != maps) {
cache.put(cacheKey, maps);
return maps;
} catch (Exception e) {
plugin.warning("Cache file \"" + cacheFile.getAbsolutePath() + "\" is corrupted");
}
}
// Generate maps from original image
FakeMap[][] matrix = new FakeMap[width][height];
try {
BufferedImage image = getBufferedImage(width*FakeMap.DIMENSION, height*FakeMap.DIMENSION);
int imgWidth = image.getWidth();
// Convert RGBA pixels to Minecraft color indexes
int[] rgbPixels = image.getRGB(
0, 0,
imgWidth, image.getHeight(),
null, 0,
imgWidth
);
byte[] pixels = new byte[rgbPixels.length];
IntStream.range(0, rgbPixels.length).parallel().forEach(i -> {
pixels[i] = FakeMap.pixelToIndex(rgbPixels[i]);
});
// Instantiate fake maps
for (int col=0; col<width; col++) {
for (int row=0; row<height; row++) {
matrix[col][row] = new FakeMap(pixels, imgWidth, col*FakeMap.DIMENSION, row*FakeMap.DIMENSION);
}
}
// Persist in disk cache
try {
writeMapsToCacheFile(matrix, width, height, cacheFile);
} catch (IOException e) {
plugin.log(Level.SEVERE, "Failed to write to cache file \"" + cacheFile.getAbsolutePath() + "\"", e);
}
} catch (Exception e) {
matrix = FakeMap.getErrorMatrix(width, height);
plugin.log(Level.SEVERE, "Failed to get image data from file \"" + path + "\"", e);
}
// Update subscribers for given cached maps
subscribers.computeIfAbsent(cacheKey, __ -> new HashSet<>());
subscribers.get(cacheKey).add(subscriber);
// Persist in memory cache and return
cache.put(cacheKey, matrix);
return matrix;
}
/**
* Read maps from cache file
* @param file Cache file
* @param width Width in blocks
* @param height Height in blocks
* @return Bi-dimensional array of maps
* @throws IOException if failed to parse cache file
*/
private FakeMap[][] readMapsFromCacheFile(File file, int width, int height) throws IOException {
try (FileInputStream stream = new FileInputStream(file)) {
FakeMap[][] matrix = new FakeMap[width][height];
for (int col=0; col<width; col++) {
for (int row=0; row<height; row++) {
byte[] buffer = new byte[FakeMap.DIMENSION*FakeMap.DIMENSION];
stream.read(buffer);
matrix[col][row] = new FakeMap(buffer);
}
}
return matrix;
}
}
/**
* Write maps to cache file
* @param maps Bi-dimensional array of maps
* @param width Width in blocks
* @param height Height in blocks
* @param file Cache file
* @throws IOException if failed to write to cache file
*/
private void writeMapsToCacheFile(FakeMap[][] maps, int width, int height, File file) throws IOException {
try (FileOutputStream stream = new FileOutputStream(file)) {
for (int col=0; col<width; col++) {
for (int row=0; row<height; row++) {
stream.write(maps[col][row].getPixels());
}
}
subscribers.computeIfAbsent(cacheKey, __ -> new HashSet<>()).add(subscriber);
lock.unlock();
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(FakeImage subscriber) {
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);
@@ -234,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,40 +1,48 @@
package io.josemmo.bukkit.plugin.storage;
import io.josemmo.bukkit.plugin.YamipaPlugin;
import org.bukkit.Bukkit;
import org.bukkit.scheduler.BukkitTask;
import java.io.File;
import java.io.IOException;
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.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(String basePath, 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 String getBasePath() {
public @NotNull Path getBasePath() {
return basePath;
}
@@ -42,94 +50,35 @@ public class ImageStorage {
* Get cache path
* @return Cache path
*/
public 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();
}
/**
@@ -137,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 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;
}
}
/**
@@ -153,7 +157,54 @@ public class ImageStorage {
* @param filename Filename
* @return Image instance or NULL if not found
*/
public synchronized ImageFile get(String filename) {
return cachedImages.get(filename);
public synchronized @Nullable ImageFile get(@NotNull String 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;
}
}
}
}
}
@@ -1,33 +1,102 @@
package io.josemmo.bukkit.plugin.utils;
import com.comphenix.protocol.ProtocolLibrary;
import io.josemmo.bukkit.plugin.YamipaPlugin;
import net.md_5.bungee.api.ChatMessageType;
import net.md_5.bungee.api.chat.TextComponent;
import io.josemmo.bukkit.plugin.packets.ActionBarPacket;
import org.bukkit.Bukkit;
import org.bukkit.entity.Player;
import org.bukkit.scheduler.BukkitTask;
import org.jetbrains.annotations.NotNull;
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;
private BukkitTask task = null;
private ActionBar(@NotNull Player player, @NotNull String message) {
this.player = player;
this.message = message;
}
/**
* Send action bar message
* @param player Player who will receive the message
* @param message Message to send
*/
public static void send(Player player, String message) {
player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(message));
public static void send(@NotNull Player player, @NotNull String message) {
ActionBar instance = new ActionBar(player, message);
instance.sendOnce();
}
/**
* Keep sending action bar message
* @param player Player who will receive the message
* @param message Message to send
* @return New task instance
* @return New instance
*/
public static BukkitTask repeat(Player player, String message) {
return Bukkit.getScheduler().runTaskTimer(plugin, () -> {
ActionBar.send(player, message);
}, 0L, 40L);
public static @NotNull ActionBar repeat(@NotNull Player player, @NotNull String message) {
ActionBar instance = new ActionBar(player, message);
instance.start();
return instance;
}
/**
* Set message
* @param message Message
* @return This instance
*/
public ActionBar setMessage(@NotNull String message) {
this.message = message;
return sendOnce();
}
/**
* Clear message
* @return This instance
*/
public ActionBar clear() {
message = "";
stop();
return sendOnce();
}
/**
* Send message once
* @return This instance
*/
public ActionBar sendOnce() {
ActionBarPacket actionBarPacket = new ActionBarPacket();
actionBarPacket.setText(message);
try {
ProtocolLibrary.getProtocolManager().sendServerPacket(player, actionBarPacket);
} catch (Exception e) {
LOGGER.severe("Failed to send ActionBar to " + player.getName(), e);
}
return this;
}
/**
* Start sending message indefinitely
* @return This instance
*/
public ActionBar start() {
if (task == null) {
task = Bukkit.getScheduler().runTaskTimer(plugin, this::sendOnce, 0L, 40L);
}
return this;
}
/**
* Stop sending message indefinitely
* @return This instance
*/
public ActionBar stop() {
if (task != null) {
task.cancel();
task = null;
}
return this;
}
}
@@ -1,26 +1,31 @@
package io.josemmo.bukkit.plugin.utils;
import java.io.FileOutputStream;
import org.jetbrains.annotations.NotNull;
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<>();
/**
* Get rows
* @return List of rows
*/
public List<String[]> getRows() {
public @NotNull List<String[]> getRows() {
return data;
}
@@ -28,7 +33,7 @@ public class CsvConfiguration {
* Add row
* @param row Row
*/
public void addRow(String[] row) {
public void addRow(@NotNull String[] row) {
data.add(row);
}
@@ -37,14 +42,25 @@ public class CsvConfiguration {
* @param path File path
* @throws IOException if failed to read file
*/
public void load(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));
}
});
});
}
}
/**
@@ -52,8 +68,8 @@ public class CsvConfiguration {
* @param path File path
* @throws IOException if failed to write file
*/
public void save(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");
}
@@ -1,6 +1,7 @@
package io.josemmo.bukkit.plugin.utils;
import org.bukkit.block.BlockFace;
import org.jetbrains.annotations.NotNull;
public class DirectionUtils {
/**
@@ -8,7 +9,7 @@ public class DirectionUtils {
* @param yaw Yaw angle
* @return Cardinal direction
*/
public static BlockFace getCardinalDirection(float yaw) {
public static @NotNull BlockFace getCardinalDirection(float yaw) {
if (yaw < 0) {
yaw += 360;
}
@@ -0,0 +1,125 @@
package io.josemmo.bukkit.plugin.utils;
import com.comphenix.protocol.PacketType;
import com.comphenix.protocol.ProtocolLibrary;
import com.comphenix.protocol.events.ListenerPriority;
import com.comphenix.protocol.events.ListeningWhitelist;
import com.comphenix.protocol.events.PacketEvent;
import com.comphenix.protocol.events.PacketListener;
import com.comphenix.protocol.wrappers.EnumWrappers;
import io.josemmo.bukkit.plugin.YamipaPlugin;
import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
import org.bukkit.entity.Player;
import org.bukkit.plugin.Plugin;
import org.jetbrains.annotations.NotNull;
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
* @param player Initiating player
* @param block Target block
* @param face Target block face
* @return Whether to allow original event (<code>true</code>) or not (<code>false</code>)
*/
public abstract boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face);
/**
* On player interact listener
* @param player Initiating player
* @param block Target block
* @param face Target block face
* @return Whether to allow original event (<code>true</code>) or not (<code>false</code>)
*/
public abstract boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face);
/**
* Get listener priority
* @return Listener priority
*/
public @NotNull ListenerPriority getPriority() {
return ListenerPriority.LOWEST;
}
/**
* Register listener
*/
public void register() {
ProtocolLibrary.getProtocolManager().addPacketListener(this);
}
/**
* Unregister listener
*/
public void unregister() {
ProtocolLibrary.getProtocolManager().removePacketListener(this);
}
@Override
public final void onPacketReceiving(@NotNull PacketEvent event) {
Player player = event.getPlayer();
// Discard out-of-range packets
List<Block> lastTwoTargetBlocks = player.getLastTwoTargetBlocks(null, MAX_BLOCK_DISTANCE);
if (lastTwoTargetBlocks.size() != 2) return;
Block targetBlock = lastTwoTargetBlocks.get(1);
if (!targetBlock.getType().isSolid()) return;
// Get target block face
Block adjacentBlock = lastTwoTargetBlocks.get(0);
BlockFace targetBlockFace = targetBlock.getFace(adjacentBlock);
if (targetBlockFace == null) return;
// Get action
EnumWrappers.EntityUseAction action;
if (Internals.MINECRAFT_VERSION < 17) {
action = event.getPacket().getEntityUseActions().read(0);
} else {
action = event.getPacket().getEnumEntityUseActions().read(0).getAction();
}
// Notify handler synchronously
boolean allowEvent = true;
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)
if (!allowEvent) {
event.setCancelled(true);
}
}
@Override
public final void onPacketSending(PacketEvent event) {
// Intentionally left blank
}
@Override
public final ListeningWhitelist getReceivingWhitelist() {
return ListeningWhitelist.newBuilder()
.priority(getPriority())
.types(PacketType.Play.Client.USE_ENTITY)
.build();
}
@Override
public final ListeningWhitelist getSendingWhitelist() {
return ListeningWhitelist.EMPTY_WHITELIST;
}
@Override
public final Plugin getPlugin() {
return YamipaPlugin.getInstance();
}
}
@@ -0,0 +1,94 @@
package io.josemmo.bukkit.plugin.utils;
import com.mojang.brigadier.CommandDispatcher;
import org.bukkit.Bukkit;
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;
public class Internals {
public static final float MINECRAFT_VERSION;
private static final CommandDispatcher<?> DISPATCHER;
private static final CommandMap COMMAND_MAP;
private static @Nullable Method GET_BUKKIT_SENDER_METHOD = null;
static {
try {
// Get Minecraft version
String version = Bukkit.getVersion();
version = version.substring(version.lastIndexOf("(MC: 1.")+7, version.length()-1);
MINECRAFT_VERSION = Float.parseFloat(version);
// Get "org.bukkit.craftbukkit.CraftServer" references
Server obcInstance = Bukkit.getServer();
Class<?> obcClass = obcInstance.getClass();
// Get "net.minecraft.server.MinecraftServer" references
Object nmsInstance = obcClass.getDeclaredMethod("getServer").invoke(obcInstance);
Class<?> nmsClass = nmsInstance.getClass().getSuperclass();
if (MINECRAFT_VERSION >= 20.6) {
// Get "net.minecraft.commands.Commands" references
Object nmsCommandsInstance = nmsClass.getDeclaredMethod("getCommands").invoke(nmsInstance);
Class<?> nmsCommandsClass = nmsCommandsInstance.getClass();
// 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);
} catch (Exception e) {
throw new RuntimeException("Failed to get internal classes due to incompatible Minecraft server", e);
}
}
/**
* Get Brigadier command dispatcher instance
* @return Command dispatcher instance
*/
public static @NotNull CommandDispatcher<?> getDispatcher() {
return DISPATCHER;
}
/**
* Get Bukkit command map instance
* @return Command map instance
*/
public static @NotNull CommandMap getCommandMap() {
return COMMAND_MAP;
}
/**
* Get Bukkit sender from Brigadier context source
* @param source Brigadier command context source
* @return Command sender instance
*/
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);
}
}
}
@@ -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);
}
}
@@ -1,5 +1,6 @@
package io.josemmo.bukkit.plugin.utils;
import com.comphenix.protocol.events.ListenerPriority;
import io.josemmo.bukkit.plugin.YamipaPlugin;
import org.bukkit.ChatColor;
import org.bukkit.Location;
@@ -7,29 +8,33 @@ import org.bukkit.block.Block;
import org.bukkit.block.BlockFace;
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.block.Action;
import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.scheduler.BukkitTask;
import org.bukkit.event.player.PlayerQuitEvent;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
import java.util.function.BiConsumer;
public class SelectBlockTask implements Listener {
private static final YamipaPlugin plugin = YamipaPlugin.getInstance();
private static final ConcurrentMap<UUID, Boolean> hasAnotherTask = new ConcurrentHashMap<>();
public class SelectBlockTask {
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;
private BiConsumer<Location, BlockFace> success;
private Runnable failure;
private BukkitTask actionBarTask;
private ActionBar actionBar;
/**
* Class constructor
* @param player Target player instance
*/
public SelectBlockTask(Player player) {
public SelectBlockTask(@NotNull Player player) {
this.player = player;
}
@@ -37,7 +42,7 @@ public class SelectBlockTask implements Listener {
* Set on success callback
* @param callback Success callback
*/
public void onSuccess(BiConsumer<Location, BlockFace> callback) {
public void onSuccess(@Nullable BiConsumer<@NotNull Location, @NotNull BlockFace> callback) {
this.success = callback;
}
@@ -45,7 +50,7 @@ public class SelectBlockTask implements Listener {
* Set on failure (e.g. canceled) callback
* @param callback Failure callback
*/
public void onFailure(Runnable callback) {
public void onFailure(@Nullable Runnable callback) {
this.failure = callback;
}
@@ -53,55 +58,131 @@ public class SelectBlockTask implements Listener {
* Run task
* @param helpMessage Help message for the player
*/
public void run(String helpMessage) {
public void run(@NotNull String helpMessage) {
UUID uuid = player.getUniqueId();
// Has this player another active task?
if (hasAnotherTask.getOrDefault(uuid, false)) {
if (instances.containsKey(uuid)) {
player.sendMessage(ChatColor.RED + "You already have a pending action!");
return;
}
// Create listener singleton if needed
if (listener == null) {
listener = new SelectBlockTaskListener();
listener.register();
LOGGER.fine("Created SelectBlockTaskListener singleton");
}
// Start task
hasAnotherTask.put(uuid, true);
plugin.getServer().getPluginManager().registerEvents(this, plugin);
actionBarTask = ActionBar.repeat(player, ChatColor.GREEN + helpMessage + ChatColor.RESET +
" — " + ChatColor.RED + "Left click to cancel");
instances.put(uuid, this);
actionBar = ActionBar.repeat(player, ChatColor.GREEN + helpMessage + ChatColor.RESET +
" - " + ChatColor.RED + "Left click to cancel");
}
/**
* Cancel task
*/
public void cancel() {
HandlerList.unregisterAll(this);
if (actionBarTask != null) {
actionBarTask.cancel();
if (actionBar != null) {
actionBar.clear();
actionBar = null;
}
instances.remove(player.getUniqueId());
// Destroy listener singleton if no more active tasks
if (instances.isEmpty()) {
listener.unregister();
listener = null;
LOGGER.fine("Destroyed SelectBlockTaskListener singleton");
}
hasAnotherTask.remove(player.getUniqueId());
}
@EventHandler
public void onPlayerInteraction(PlayerInteractEvent event) {
Action action = event.getAction();
Block block = event.getClickedBlock();
// Player canceled the task
if (action == Action.LEFT_CLICK_AIR || action == Action.LEFT_CLICK_BLOCK) {
event.setCancelled(true);
cancel();
if (failure != null) {
failure.run();
}
return;
/**
* Internal listener for handling player events
*/
private static class SelectBlockTaskListener extends InteractWithEntityListener implements Listener {
@Override
public void register() {
super.register();
YamipaPlugin plugin = YamipaPlugin.getInstance();
plugin.getServer().getPluginManager().registerEvents(this, plugin);
}
// Player selected a block
if (action == Action.RIGHT_CLICK_BLOCK && block != null) {
event.setCancelled(true);
cancel();
if (success != null) {
success.accept(block.getLocation(), event.getBlockFace());
@Override
public void unregister() {
super.unregister();
HandlerList.unregisterAll(this);
}
@Override
public @NotNull ListenerPriority getPriority() {
return ListenerPriority.LOW;
}
@EventHandler(ignoreCancelled = true, priority = EventPriority.LOW)
public void onBlockInteraction(@NotNull PlayerInteractEvent event) {
Action action = event.getAction();
Player player = event.getPlayer();
Block block = event.getClickedBlock();
if (block == null) return;
BlockFace face = event.getBlockFace();
// Handle event
boolean allowEvent = true;
if (action == Action.LEFT_CLICK_AIR || action == Action.LEFT_CLICK_BLOCK) {
allowEvent = handle(player, null, null);
} else if (action == Action.RIGHT_CLICK_BLOCK) {
allowEvent = handle(player, block, face);
}
// Cancel event (if needed)
if (!allowEvent) {
event.setCancelled(true);
}
}
@EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR)
public void onPlayerQuit(@NotNull PlayerQuitEvent event) {
UUID uuid = event.getPlayer().getUniqueId();
SelectBlockTask task = instances.get(uuid);
if (task != null) {
task.cancel();
}
}
@Override
public boolean onAttack(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
return handle(player, null, null);
}
@Override
public boolean onInteract(@NotNull Player player, @NotNull Block block, @NotNull BlockFace face) {
return handle(player, block, face);
}
private boolean handle(@NotNull Player player, @Nullable Block block, @Nullable BlockFace face) {
// Get task responsible for handling this event
UUID uuid = player.getUniqueId();
SelectBlockTask task = instances.get(uuid);
if (task == null) return true;
// Cancel task
task.cancel();
// Notify failure listener
if (block == null || face == null) {
if (task.failure != null) {
task.failure.run();
}
return false;
}
// Notify success listener
if (task.success != null) {
task.success.accept(block.getLocation(), face);
}
return false;
}
}
}
+83 -5
View File
@@ -1,25 +1,103 @@
name: ${project.name}
version: ${project.version}
main: io.josemmo.bukkit.plugin.YamipaPlugin
api-version: 1.13
api-version: 1.16
depend: [ProtocolLib]
softdepend:
- BentoBox
- GriefPrevention
- GroupManager
- Hyperverse
- LuckPerms
- Multiverse-Core
- My_Worlds
- PhantomWorlds
- Towny
- WorldGuard
permissions:
# All plugin permissions
yamipa.*:
default: op
children:
yamipa.list: true
yamipa.command.*: true
yamipa.item.*: true
# Legacy children
yamipa.clear: true
yamipa.describe: true
yamipa.download: true
yamipa.give: true
yamipa.list: true
yamipa.place: true
yamipa.remove: true
yamipa.remove.radius: true
yamipa.list:
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:
default: op
yamipa.download:
default: op
yamipa.give:
default: op
yamipa.list:
default: op
yamipa.place:
default: op
yamipa.remove:
default: op
yamipa.remove.radius:
yamipa.top:
default: op