Compare commits

..
136 Commits
Author SHA1 Message Date
Ben Woo f6b3ed0f8b Merge pull request #3484 from Multiverse/fix/acf-locales
Allow locales folder to add and override acf messages
2026-06-05 15:23:48 +09:00
Ben Woo afce49b8d8 Merge pull request #3485 from Multiverse/chore/plugin-yml
Update link and description in plugins.yml
2026-06-05 15:22:08 +09:00
Ben Woo b9271195e9 Update link and description in plugins.yml 2026-06-05 12:59:05 +08:00
Ben Woo c76faf0353 Allow locales folder to add and override acf messages 2026-06-05 12:55:15 +08:00
Ben Woo a27b2186c5 Merge pull request #3481 from Multiverse/fix/load-world-migration
Fix folder detection during 26.1 migration at world load
2026-05-24 11:06:42 +08:00
Ben Woo 97d4f6cea2 Fix folder detection during 26.1 migration at world load 2026-05-24 11:02:17 +08:00
Ben Woo e4f3b4612c Merge pull request #3450 from Multiverse/v5.7
V5.7
2026-05-20 14:42:34 +08:00
Ben Woo 5c822340c9 Merge pull request #3480 from Multiverse/feat/portal-form-message
Add portal form disabled messages for end and nether portals
2026-05-20 14:32:27 +08:00
Ben Woo 665bd1c9ae Merge pull request #3479 from Multiverse/fix/key-bugs
Fix crashes with regen and cloning world
2026-05-20 14:31:45 +08:00
Ben Woo 2bc3965283 Fix crashes with regen and cloning world 2026-05-20 14:27:41 +08:00
Ben Woo 54dd5bfb34 Add portal form disabled messages for end and nether portals 2026-05-20 12:55:19 +08:00
Ben Woo c2c1cc4f0a Merge pull request #3473 from Multiverse/feat/better-dumps
Add support for new dumps viewer with more debug info and better ui
2026-05-20 12:21:00 +08:00
Ben Woo b8364a00b5 Add support for new dumps viewer with more debug info and better ui 2026-05-13 16:37:05 +08:00
Ben Woo 6e0ebf752b Merge pull request #3475 from Multiverse/feat/completion-filter
Improve tab completion for namespaced keys and properties
2026-05-13 14:52:48 +08:00
Ben Woo ebb809f899 Merge pull request #3472 from Multiverse/fix/entity-purge-player
Fix entity purging should ignore players
2026-05-13 14:21:01 +08:00
Ben Woo 8b1aea5d99 Improve tab completion for namespaced keys and properties 2026-05-13 14:17:47 +08:00
Ben Woo 4f92268ba2 Fix entity purging should ignore players 2026-05-09 11:38:31 +08:00
Ben Woo 39ac80be2c Merge pull request #3471 from Multiverse/refactor/jspecify
Remove use of jspecify annotation in favour of jetbrains
2026-05-08 20:42:24 +08:00
Ben Woo 5f43d756e5 Merge pull request #3470 from Multiverse/fix/name-key-lookup
Fix world exist checking not looking up both name and key
2026-05-08 20:41:37 +08:00
Ben Woo 50efb7e262 Remove use of jspecify annotation in favour of jetbrains 2026-05-08 20:39:05 +08:00
Ben Woo 142ef02ca0 Fix world exist checking not looking up both name and key 2026-05-08 20:37:06 +08:00
Ben Woo 54900f55a9 Merge pull request #3469 from Multiverse/feat/world-meta
Add support for setting metadata key value for each world
2026-05-08 15:02:43 +08:00
Ben Woo dbc91e401c Proper yaml key format checking for map node key 2026-05-08 14:43:40 +08:00
Ben Woo e32b478cef Add support for setting metadata key value for each world 2026-05-07 17:57:57 +08:00
Ben Woo e82bc005f2 Merge pull request #3468 from Multiverse/fix/regen-bonus-chest
Retain bonus chest setting on regen world
2026-05-06 23:14:19 +08:00
Ben Woo 504f17eb1a Bump mockbukkit to version that supports bonus chest 2026-05-06 22:21:36 +08:00
Ben Woo a54a3b6152 Merge pull request #3467 from Multiverse/refactor/final-api-class
Make MultiverseCoreApi class final to prevent subclassing
2026-05-06 22:19:34 +08:00
Ben Woo 0fdc74ab56 Retain bonus chest setting on regen world 2026-05-06 22:18:05 +08:00
Ben Woo e29b851c61 Make MultiverseCoreApi class final to prevent subclassing 2026-05-06 20:31:58 +08:00
Ben Woo 534f2743c4 Merge pull request #3466 from Multiverse/fix/warn-keep-spawn
Notify users about the removal of the keep spawn in memory feature in Minecraft 1.21.9+
2026-05-06 15:50:32 +08:00
Ben Woo c486f0306e Notify users about the removal of the keep spawn in memory feature in Minecraft 1.21.9+ 2026-05-06 15:44:53 +08:00
Ben Woo 392d66ac85 Merge pull request #3465 from Multiverse/feat/generator-settings
Store generator settings used to ensure it persist during regen
2026-05-06 15:01:49 +08:00
Ben Woo 6d88c6c8c4 Add generator-settings property to test files 2026-05-06 14:59:01 +08:00
Ben Woo 2e6dc23b1b Merge pull request #3464 from Multiverse/fix/remove-world-name
Fix unable to remove world config with legacy name as key
2026-05-06 14:55:26 +08:00
Ben Woo bf05abc3ca Fix unable to remove world config with legacy name as key 2026-05-06 14:51:56 +08:00
Ben Woo d65cde74e0 Store generator settings used to ensure it persist during regen 2026-05-05 21:10:43 +08:00
Ben Woo 357532d500 Add missing world properties to InfoCommand output and remove unused spawning info methods 2026-05-05 20:32:57 +08:00
Ben Woo a59ec16737 Merge pull request #3462 from Multiverse/feat/worldborder-ticks
Add support for setting world border time with ticks
2026-05-05 20:17:41 +08:00
Ben Woo cab6358f03 Merge pull request #3463 from Multiverse/refactor/get-coordinate-scale
Improve getCoordinateScale method checking
2026-05-05 16:38:27 +08:00
Ben Woo 5ad47da967 Add tests for TickDuration parsing and conversion methods 2026-05-05 16:22:54 +08:00
Ben Woo 603ab5bc0e Improve getCoordinateScale method checking 2026-05-05 16:02:44 +08:00
Ben Woo 5e7637bc42 Add support for setting world border time with ticks 2026-05-05 15:57:45 +08:00
Ben Woo bc07b24360 Merge pull request #3461 from Multiverse/feat/remove-player-dest
Add support for custom destination with `--remove-player` flag
2026-05-05 15:56:00 +08:00
Ben Woo 5451e55f17 Add support for custom destination with --remove-player flag 2026-05-04 16:53:38 +08:00
Ben Woo fd8d0261aa Merge pull request #3459 from Multiverse/feat/bonus-chest-force-spawn
Add support for creating world with bonus chest and force spawn
2026-05-04 14:19:59 +08:00
Ben Woo a7d067222a Merge pull request #3458 from Multiverse/feat/pre-creature-spawn
Add preCreatureSpawn event handler to optimize spawn checks
2026-05-04 14:18:49 +08:00
Ben Woo 0b18c8d832 Merge pull request #3456 from Multiverse/feat/potential-worlds
Add support for potential worlds finding with new 26.1 dimensions format
2026-05-04 14:18:37 +08:00
Ben Woo 7937d889da Add preCreatureSpawn event handler to optimize spawn checks 2026-05-04 14:15:16 +08:00
Ben Woo 867462b6ed Merge pull request #3460 from Multiverse/chore/bump-mockbukkit
Bump mockbukkit to fix skipped tests
2026-05-04 14:13:52 +08:00
Ben Woo ed923491b5 Merge pull request #3457 from Multiverse/fix/command-typo
Fix command typo in spawn adjustment logging message
2026-05-04 14:13:41 +08:00
Ben Woo dfeb72e501 Bump mockbukkit to fix skipped tests 2026-05-04 14:09:52 +08:00
Ben Woo 35133ee647 Add support for creating world with bonus chest and force spawn 2026-05-04 14:09:21 +08:00
Ben Woo 3e62084b23 Fix command typo in spawn adjustment logging message 2026-05-03 20:42:03 +08:00
Ben Woo 944acd8387 Add support for potential worlds finding with new 26.1 dimensions format 2026-05-03 20:01:39 +08:00
Ben Woo 9460f0b2b1 Merge pull request #3454 from Multiverse/feat/key-lookup
Add support for looking up worlds with namespace key string
2026-05-03 19:54:10 +08:00
Ben Woo 9ed58375d4 Merge pull request #3455 from Multiverse/fix/isIteratingOverLevels
Fix isIteratingOverLevels field not found
2026-05-03 19:24:12 +08:00
Ben Woo f470df6f2c Fix isIteratingOverLevels field not found 2026-05-03 17:32:47 +08:00
Ben Woo c291df201f Add support for looking up worlds with namespace key string 2026-05-03 17:28:49 +08:00
Ben Woo 58402b010d Merge pull request #3452 from Multiverse/fix/instantiation
Prevent instantiation of utility classes by adding private constructors
2026-05-03 17:16:29 +08:00
Ben Woo 8060514e47 Merge pull request #3453 from Multiverse/refactor/coord-scale
Move getCoordinateScale to WorldCompatibility
2026-05-03 17:16:15 +08:00
Ben Woo ac86b691ba Merge pull request #3451 from Multiverse/feat/info-key
Add world key to output in `/mv info` command
2026-05-03 12:57:33 +08:00
Ben Woo ee40eb5822 Prevent instantiation of utility classes by adding private constructors 2026-05-03 12:56:44 +08:00
Ben Woo 3052489952 Move getCoordinateScale to WorldCompatibility 2026-05-03 12:55:53 +08:00
Ben Woo a6cbe44411 Add world key to output in /mv info command 2026-05-03 12:39:01 +08:00
Ben Woo 65db598fd8 Merge pull request #3449 from Multiverse/feat/namespace-key
Migrate world config to store worlds using namespace key
2026-05-03 11:40:00 +08:00
Ben Woo 272e118eed Create, import and clone worlds with namespaced key 2026-05-02 23:20:25 +08:00
Ben Woo 9643cce272 Migrate world config to store worlds using namespace key 2026-05-02 16:18:18 +08:00
Ben Woo 372b600d68 Merge pull request #3447 from Multiverse/refactor/fail-if
Refactor long chained if-else to use Attempt#failIf
2026-05-01 22:57:43 +08:00
Ben Woo c9db931ad5 Merge pull request #3446 from Multiverse/refactor/try-reflect
Refactor compatibility utilities to use Try for method and class retrieval
2026-05-01 22:56:56 +08:00
Ben Woo 8a1bbebb0b Refactor long chained if-else to use Attempt#failIf 2026-05-01 13:53:22 +08:00
Ben Woo 192ee269fa Refactor compatibility utilities to use Try for method and class retrieval 2026-05-01 12:10:42 +08:00
Ben Woo 6c86c41f98 Merge pull request #3445 from Multiverse/chore/26.1.2-platform
Update platform upload version list to include 26.1.2
2026-04-30 22:42:35 +08:00
Ben Woo 0afd4dbb62 Update platform upload version list to include 26.1.2 2026-04-30 22:35:44 +08:00
Ben Woo f527fbd693 Merge pull request #3441 from xiaohuang2004/patch-2
Update multiverse-core_zh.properties
2026-04-16 10:22:04 +08:00
xiaohuang2004 b50687db3b Fix formatting in multiverse-core_zh.properties 2026-04-13 11:50:57 +08:00
xiaohuang2004 a28a071e1e Fix punctuation in Chinese property file 2026-04-13 11:48:04 +08:00
xiaohuang2004 66dc376633 Update multiverse-core_zh.properties
The Chinese translation has been updated based on the latest English file, and some translations have been optimized simultaneously for more precise expression of meaning.
2026-04-10 15:41:53 +08:00
Ben Woo 5e8e9380e3 Merge pull request #3440 from Multiverse/fix/26.1-folders-lowercased
Fix world folder being lowercased due to namespace changes in 26.1
2026-04-08 16:14:28 +08:00
Ben Woo 4a16fa2297 Fix world folder being lowercased due to namespace changes in 26.1 2026-04-08 14:20:01 +08:00
Ben Woo b9e07a85d0 Merge pull request #3439 from Multiverse/fix/papermc-26.1-migration
Add support for detecting PaperMC 26.1 folder migration
2026-04-08 11:11:29 +08:00
Ben Woo 53c7459c1d Add support for detecting PaperMC 26.1 folder migration 2026-04-08 11:00:25 +08:00
Ben Woo 112505bc93 Merge pull request #3437 from Multiverse/fix/ansi-reset
Fix ANSI color format reset in log messages
2026-04-04 20:55:50 +08:00
Ben Woo e27fe73faa Fix ANSI color format reset in log messages 2026-04-04 14:46:06 +08:00
Ben Woo 9bc2b4dfaf Merge pull request #3422 from Multiverse/feat/clone-unloaded
Add support for cloning unloaded worlds
2026-04-04 14:32:14 +08:00
Ben Woo 3ba8beefc5 Merge pull request #3436 from Multiverse/fix/clone-paper-metadata
Fix clone not working due to duplicated Paper world metadata in 26.1
2026-04-04 13:28:16 +08:00
Ben Woo 05b6b380e0 Fix clone not working due to duplicated Paper world metadata in 26.1 2026-04-04 12:31:25 +08:00
Ben Woo b58ea2e04b Update error messages for invalid world folder following the format of 2026-04-04 12:17:03 +08:00
Ben Woo f4405874d1 Merge branch 'main' into feat/clone-unloaded 2026-04-04 12:05:01 +08:00
Ben Woo 247685c93d Merge pull request #3435 from Multiverse/feat/26.1-compatibility
Update world folder discovery and checking to support new 26.1 format
2026-04-04 11:38:16 +08:00
Ben Woo 490107b5d5 Fix tests due to world folder format changes 2026-04-04 11:35:30 +08:00
Ben Woo 122e35103a Fix some formatting and documentation 2026-04-04 11:22:08 +08:00
Ben Woo 25eeefbc77 Update platform upload version list to include 26.1 2026-04-04 11:16:12 +08:00
Ben Woo 6b5ce2aebb Update world folder discovery and checking to support new 26.1 format 2026-04-04 11:13:43 +08:00
Ben Woo e9905ca281 Merge pull request #3434 from Multiverse/fix/load-flags
Fix `--skip-folder-check` flag not showing for `/mv load` command
2026-04-03 22:01:02 +08:00
Ben Woo e002282b13 Fix --skip-folder-check flag not showing for /mv load command 2026-04-03 21:56:13 +08:00
Ben Woo 39711527fa Merge pull request #3431 from Multiverse/feat/placeholder-isloaded
Add PlaceholderAPI support for showing world load status (`%multiverse-core_isloaded_<worldname>%`)
2026-03-10 22:31:42 +08:00
Ben Woo 8d9b29ff87 Add PlaceholderAPI support for showing world load status (%multiverse-core_isloaded_<worldname>%) 2026-03-10 22:27:38 +08:00
Ben Woo 82fe350afa Merge pull request #3429 from Multiverse/feat/apply-passenger-mode
Apply passenger mode to join location and first spawn as well
2026-03-09 10:48:15 +08:00
Ben Woo a7b4b9bbda Merge pull request #3430 from Multiverse/chore/access-modifier
Change entry fee migrator class access modifier to private
2026-03-09 10:47:35 +08:00
Ben Woo 15f511b660 Change entry fee migrator class access modifier to private 2026-03-09 10:40:00 +08:00
Ben Woo 8347cf8fbb Apply passenger mode to join location and first spawn as well 2026-03-09 10:39:13 +08:00
Ben Woo 4c27fea26b Merge pull request #3427 from Multiverse/fix/regen-biome
Fix biome not applying during world regen
2026-02-26 10:43:47 +08:00
Ben Woo 4111da8fd6 Fix biome not applying during world regen 2026-02-26 10:34:13 +08:00
Ben Woo 167f6f8a37 Merge pull request #3423 from Multiverse/fix/placeholder-null
Fix null error when world name for non-player placeholders not specified
2026-01-28 10:22:42 +08:00
Ben Woo 325b5d7643 Fix null error when world name for non-player placeholders not specified 2026-01-28 10:16:03 +08:00
Ben Woo 5dfb098d5c Add support for cloning unloaded worlds 2026-01-26 12:18:47 +08:00
Ben Woo 6c50bfb08e Merge pull request #3421 from Multiverse/fix/null-to-location
Better null checking for to and from event locations
2026-01-20 22:50:49 +08:00
Ben Woo 37c0580828 Merge pull request #3420 from Multiverse/chore/bump-mockbukkit
Bump mockbukkit and associated mc version to 1.21.11
2026-01-20 22:49:48 +08:00
Ben Woo e1a42cec92 Bump mockbukkit and associated mc version to 1.21.11 2026-01-20 22:45:13 +08:00
Ben Woo a6cc8bd68e Better null checking for to and from event locations 2026-01-20 22:44:04 +08:00
Ben Woo ee4cd18522 Merge pull request #3410 from Multiverse/v5.5-2
V5.5-2
2026-01-03 23:17:13 +08:00
Ben Woo b6c309a332 Merge pull request #3417 from Multiverse/fix/since-annotation
Add @since annotation for worldPropertyStrings method in CreateWorldOptions
2026-01-03 23:01:44 +08:00
Ben Woo 87571e7037 Add @since annotation for worldPropertyStrings method in CreateWorldOptions 2026-01-03 22:59:18 +08:00
Ben Woo 0822b1460a Merge pull request #3415 from Multiverse/refactor/string-formatter
Enhance StringFormatter methods to handle null inputs and improve type annotations
2026-01-03 22:23:19 +08:00
Ben Woo 1220998c12 Merge pull request #3416 from Multiverse/chore/logging
Add a logging message when plugin shutdown
2026-01-02 14:27:06 +08:00
Ben Woo e078c5e5dd Add a logging message when plugin shutdown 2026-01-02 14:08:49 +08:00
Ben Woo c1ef8f656b Enhance StringFormatter methods to handle null inputs and improve type annotations 2026-01-02 09:49:35 +08:00
Ben Woo 86e2112d87 Merge pull request #3414 from Multiverse/feat/properties-flag
Add support for customising world properties during world creation
2026-01-02 09:39:36 +08:00
Ben Woo 8a77dc807e Add --properties syntax to create command 2026-01-02 09:37:08 +08:00
Ben Woo 77382dacf3 Add support for customising world properties during world creation 2026-01-02 09:14:00 +08:00
Ben Woo 588a8e1baf Merge pull request #3413 from Multiverse/fix/seperated-typo
Fix spelling error for addOnToCommaSeparated method name
2026-01-02 08:59:18 +08:00
Ben Woo 5bd37d17c3 Fix spelling error for addOnToCommaSeparated method name 2026-01-01 23:32:10 +08:00
Ben Woo 815637e00f Merge pull request #3412 from Multiverse/feat/world-change-priority
Add event priority configuration for PlayerChangedWorldEvent
2026-01-01 23:19:22 +08:00
Ben Woo 78194389eb Merge pull request #3411 from Multiverse/feat/enforce-delay
Add configurable delay for gamemode and flight enforcement on world change
2026-01-01 23:13:52 +08:00
Ben Woo 2db55fab3b Add event priority configuration for PlayerChangedWorldEvent 2026-01-01 23:12:42 +08:00
Ben Woo c505da76ec Fix config test 2026-01-01 22:36:26 +08:00
Ben Woo 6152620541 Merge pull request #3409 from Multiverse/fix/worldborder-safety
Add world border check for spawn safety
2026-01-01 22:30:46 +08:00
Ben Woo cf70b3b839 Add configurable delay for gamemode and flight enforcement on world change 2026-01-01 22:30:10 +08:00
Ben Woo 527085713c Add world border check for spawn safety 2026-01-01 15:03:24 +08:00
Ben Woo f446c94dbc Merge pull request #3407 from Multiverse/v5.5
V5.5
2026-01-01 13:48:07 +08:00
Ben Woo aac0111dde Merge pull request #3408 from Multiverse/fix/case-insensitive-worldname
Fix case-insensitive world name handling
2026-01-01 12:59:53 +08:00
Ben Woo f66e673ea9 Fix case-insensitive world name handling 2026-01-01 12:42:18 +08:00
Ben Woo 1434ca176a Merge pull request #3406 from Multiverse/feat/papi-config
Add config options `warn-invalid-papi-format` and `invalid-papi-format-returns-blank`
2025-12-31 22:36:48 +08:00
Ben Woo 212b68437e Add config options warn-invalid-papi-format and invalid-papi-format-returns-blank 2025-12-31 21:14:56 +08:00
Ben Woo 05d01884ad Merge pull request #3405 from Multiverse/feat/papi-unloaded-world
Allow placerholders to work on unloaded worlds
2025-12-31 20:48:42 +08:00
Ben Woo 65c399740e Allow placerholders to work on unloaded worlds 2025-12-31 20:41:08 +08:00
114 changed files with 6094 additions and 1129 deletions

No files matched your search

@@ -107,7 +107,7 @@ jobs:
files: '["${{ github.workspace }}/${{ inputs.plugin_name }}-${{ steps.release-info.outputs.tag_name }}.jar"]'
name: ${{ steps.release-info.outputs.tag_name }}
changelog: ${{ steps.release-artifact.outputs.body }}
game_versions: 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
game_versions: 26.1.2, 26.1.1, 26.1, 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
version_type: ${{ steps.parse-release-type.outputs.release_type }}
loaders: bukkit, spigot, paper, purpur
dependencies: ${{ inputs.modrinth_dependencies }}
@@ -121,7 +121,7 @@ jobs:
changelog: ${{ steps.release-artifact.outputs.body }}
changelog_type: markdown
display_name: ${{ steps.release-info.outputs.tag_name }}
game_versions: 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
game_versions: 26.1.2, 26.1.1, 26.1, 1.21.11, 1.21.10, 1.21.9, 1.21.8, 1.21.7, 1.21.6, 1.21.5, 1.21.4, 1.21.3, 1.21.2, 1.21.1, 1.21, 1.20.6, 1.20.5, 1.20.4, 1.20.3, 1.20.2, 1.20.1, 1.20, 1.19.4, 1.19.3, 1.19.2, 1.19.1, 1.19, 1.18.2
release_type: ${{ steps.parse-release-type.outputs.release_type }}
project_relations: ${{ inputs.dbo_project_relations }}
file_path: ${{ github.workspace }}/${{ inputs.plugin_name }}-${{ steps.release-info.outputs.tag_name }}.jar
@@ -136,5 +136,5 @@ jobs:
channel: ${{ steps.parse-release-type.outputs.release_type }}
files: '[{"path": "${{ github.workspace }}/${{ inputs.plugin_name }}-${{ steps.release-info.outputs.tag_name }}.jar", "platforms": ["PAPER"]}]'
description: ${{ steps.release-artifact.outputs.body }}
platform_dependencies: '{"PAPER": ["1.18.2-1.21.11"]}'
platform_dependencies: '{"PAPER": ["1.18.2-26.1.2"]}'
plugin_dependencies: ${{ inputs.hangar_plugin_dependencies }}
+2 -2
View File
@@ -25,9 +25,9 @@ repositories {
}
configure(apiDependencies) {
serverApiVersion = '1.21.8-R0.1-SNAPSHOT'
serverApiVersion = '1.21.11-R0.1-SNAPSHOT'
mockBukkitServerApiVersion = '1.21'
mockBukkitVersion = '4.84.0'
mockBukkitVersion = '4.110.0'
}
dependencies {
@@ -126,6 +126,7 @@ public class MultiverseCore extends MultiverseModule {
MultiverseCoreApi.shutdown();
shutdownDependencyInjection();
PluginServiceLocatorFactory.get().shutdown();
Logging.info("- Disabled");
Logging.shutdown();
}
@@ -225,11 +226,12 @@ public class MultiverseCore extends MultiverseModule {
* Logs the enable message.
*/
private void logEnableMessage() {
Logging.config("\u001B[32mVersion %s (API v%s) Enabled - By %s\u001B[39m",
Logging.config("\u001B[32mVersion %s (API v%s) Enabled - By %s\u001B[0m",
this.getDescription().getVersion(), getVersionAsNumber(), StringFormatter.joinAnd(getDescription().getAuthors()));
if (configProvider.get().isShowingDonateMessage()) {
Logging.config("\u001B[32mLoving Multiverse-Core? Please consider supporting the project with a small donation: https://github.com/sponsors/Multiverse\u001B[39m");
Logging.config("\u001B[32mLoving Multiverse-Core? Please consider supporting the project with a " +
"small donation: https://github.com/sponsors/Multiverse\u001B[0m");
}
}
@@ -24,7 +24,7 @@ import java.util.function.Consumer;
/**
* Provides access to the MultiverseCore API.
*/
public class MultiverseCoreApi {
public final class MultiverseCoreApi {
private static MultiverseCoreApi instance;
private static final List<Consumer<MultiverseCoreApi>> whenLoadedCallbacks = new ArrayList<>();
@@ -1,6 +1,7 @@
package org.mvplugins.multiverse.core;
import com.google.common.collect.Lists;
import io.vavr.control.Option;
import jakarta.annotation.PostConstruct;
import jakarta.inject.Inject;
import me.clip.placeholderapi.expansion.PlaceholderExpansion;
@@ -12,11 +13,13 @@ import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.config.CoreConfig;
import org.mvplugins.multiverse.core.economy.MVEconomist;
import org.mvplugins.multiverse.core.utils.MinecraftTimeFormatter;
import org.mvplugins.multiverse.core.utils.REPatterns;
import org.mvplugins.multiverse.core.utils.StringFormatter;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
import java.util.List;
@@ -26,12 +29,17 @@ import java.util.Locale;
final class PlaceholderExpansionHook extends PlaceholderExpansion {
private final MultiverseCore plugin;
private final CoreConfig coreConfig;
private final WorldManager worldManager;
private final MVEconomist economist;
@Inject
public PlaceholderExpansionHook(MultiverseCore plugin, WorldManager worldManager, MVEconomist economist) {
public PlaceholderExpansionHook(@NotNull MultiverseCore plugin,
@NotNull CoreConfig coreConfig,
@NotNull WorldManager worldManager,
@NotNull MVEconomist economist) {
this.plugin = plugin;
this.coreConfig = coreConfig;
this.worldManager = worldManager;
this.economist = economist;
}
@@ -42,6 +50,13 @@ final class PlaceholderExpansionHook extends PlaceholderExpansion {
return super.register();
}
@Override
public void warning(String msg) {
if (coreConfig.getWarnInvalidPapiFormat()) {
super.warning(msg);
}
}
@Override
public @NotNull String getIdentifier() {
return "multiverse-core";
@@ -80,136 +95,115 @@ final class PlaceholderExpansionHook extends PlaceholderExpansion {
return null;
}
final var placeholder = paramsArray.remove(0);
final var placeholder = paramsArray.remove(0).toLowerCase(Locale.ENGLISH);
String worldName = parseWorldName(offlinePlayer, paramsArray);
if (worldName == null) return null;
return worldManager.getLoadedWorld(worldName)
.onEmpty(() -> warning("Multiverse World not found: " + worldName))
.map(world -> getWorldPlaceHolderValue(placeholder, paramsArray, world))
.getOrNull();
return parseWorldName(offlinePlayer, paramsArray)
.flatMap(worldName -> worldManager.getWorld(worldName)
.onEmpty(() -> warning("Multiverse World not found: " + worldName)))
.flatMap(world -> world.asLoadedWorld()
.flatMap(loadedWorld -> getLoadedWorldPlaceHolderValue(placeholder, paramsArray, loadedWorld))
.orElse(() -> getWorldPlaceHolderValue(placeholder, paramsArray, world)))
.getOrElse(() -> coreConfig.getInvalidPapiFormatReturnsBlank() ? "" : null);
}
private @Nullable String parseWorldName(OfflinePlayer offlinePlayer, List<String> paramsArray) {
private @NotNull Option<String> parseWorldName(OfflinePlayer offlinePlayer, List<String> paramsArray) {
// No world defined, get from player
if (paramsArray.isEmpty()) {
if (offlinePlayer instanceof Player player) {
return player.getWorld().getName();
return Option.of(player.getWorld().getName());
} else {
warning("You must specify a world name for non-player placeholders");
return null;
return Option.none();
}
}
// Try get from params
String paramWorldName = paramsArray.get(paramsArray.size() - 1);
if (worldManager.isLoadedWorld(paramWorldName)) {
if (worldManager.isWorld(paramWorldName)) {
paramsArray.remove(paramsArray.size() - 1);
return paramWorldName;
return Option.of(paramWorldName);
}
// Param not a world, fallback to player
if (offlinePlayer instanceof Player player) {
return player.getWorld().getName();
return Option.of(player.getWorld().getName());
}
warning("Multiverse World not found: " + paramWorldName);
return null;
return Option.none();
}
private @Nullable String getWorldPlaceHolderValue(@NotNull String placeholder,
@NotNull List<String> placeholderParams,
@NotNull LoadedMultiverseWorld world) {
// Switch to find what specific placeholder we want
switch (placeholder.toLowerCase(Locale.ENGLISH)) {
case "alias" -> {
return world.getAliasOrName();
private @NotNull Option<String> getWorldPlaceHolderValue(@NotNull String placeholder,
@NotNull List<String> placeholderParams,
@NotNull MultiverseWorld world) {
return Option.of(switch (placeholder) {
case "alias" -> world.getAliasOrName();
case "animalspawn" -> String.valueOf(world.getEntitySpawnConfig()
.getSpawnCategoryConfig(SpawnCategory.ANIMAL)
.isSpawn());
case "autoheal" -> String.valueOf(world.getAutoHeal());
case "blacklist" -> String.join(", ", world.getWorldBlacklist());
case "currency" -> String.valueOf(world.getCurrency());
case "difficulty" -> world.getDifficulty().toString();
case "entryfee" -> economist.formatPrice(world.getPrice(), world.getCurrency());
case "environment" -> world.getEnvironment().toString().toLowerCase();
case "flight" -> String.valueOf(world.isAllowFlight());
case "gamemode" -> world.getGameMode().toString().toLowerCase();
case "generator" -> world.getGenerator();
case "hunger" -> String.valueOf(world.isHunger());
case "isloaded" -> String.valueOf(world.isLoaded());
case "key" -> String.valueOf(world.getKey());
case "meta" -> {
if (placeholderParams.isEmpty()) {
warning("No meta key specified.");
yield null;
}
String metaKey = String.join("_", placeholderParams);
yield world.getMeta(metaKey)
.getOrElse(() -> {
warning("Meta key '" + metaKey + "' not found for world '" + world.getName() + "'.");
return null;
});
}
case "animalspawn" -> {
return String.valueOf(world.getEntitySpawnConfig().getSpawnCategoryConfig(SpawnCategory.ANIMAL).isSpawn());
case "monstersspawn" -> String.valueOf(world.getEntitySpawnConfig()
.getSpawnCategoryConfig(SpawnCategory.MONSTER)
.isSpawn());
case "name" -> world.getName();
case "playerlimit" -> String.valueOf(world.getPlayerLimit());
case "price" -> String.valueOf(world.getPrice());
case "pvp" -> String.valueOf(world.getPvp());
case "seed" -> String.valueOf(world.getSeed());
case "weather" -> String.valueOf(world.isAllowWeather());
case "playercount", "time", "type" -> {
warning("Placeholder '" + placeholder + "' is only available for loaded worlds.");
yield null;
}
case "autoheal" -> {
return String.valueOf(world.getAutoHeal());
}
case "blacklist" -> {
return String.join(", ", world.getWorldBlacklist());
}
case "currency" -> {
return String.valueOf(world.getCurrency());
}
case "difficulty" -> {
return world.getDifficulty().toString();
}
case "entryfee" -> {
return economist.formatPrice(world.getPrice(), world.getCurrency());
}
case "environment" -> {
return world.getEnvironment().toString().toLowerCase();
}
case "flight" -> {
return String.valueOf(world.isAllowFlight());
}
case "gamemode" -> {
return world.getGameMode().toString().toLowerCase();
}
case "generator" -> {
return world.getGenerator();
}
case "hunger" -> {
return String.valueOf(world.isHunger());
}
case "monstersspawn" -> {
return String.valueOf(world.getEntitySpawnConfig().getSpawnCategoryConfig(SpawnCategory.MONSTER).isSpawn());
}
case "name" -> {
return world.getName();
}
case "playercount" -> {
return String.valueOf(world.getBukkitWorld()
.map(World::getPlayers)
.map(List::size)
.getOrElse(-1));
}
case "playerlimit" -> {
return String.valueOf(world.getPlayerLimit());
}
case "price" -> {
return String.valueOf(world.getPrice());
}
case "pvp" -> {
return String.valueOf(world.getPvp());
}
case "seed" -> {
return String.valueOf(world.getSeed());
default -> {
warning("Unknown Placeholder: " + placeholder);
yield null;
}
});
}
private @NotNull Option<String> getLoadedWorldPlaceHolderValue(@NotNull String placeholder,
@NotNull List<String> placeholderParams,
@NotNull LoadedMultiverseWorld world) {
return Option.of(switch (placeholder) {
case "playercount" -> String.valueOf(world.getBukkitWorld()
.map(World::getPlayers)
.map(List::size)
.getOrElse(-1));
case "time" -> {
String timeFormat = !placeholderParams.isEmpty() ? placeholderParams.get(0) : "";
long time = world.getBukkitWorld().map(World::getTime).getOrElse(0L);
switch (timeFormat) {
case "" -> {
return String.valueOf(time);
}
case "12h" -> {
return MinecraftTimeFormatter.format12h(time);
}
case "24h" -> {
return MinecraftTimeFormatter.format24h(time);
}
default -> {
return MinecraftTimeFormatter.formatTime(time, timeFormat);
}
}
yield switch (timeFormat) {
case "" -> String.valueOf(time);
case "12h" -> MinecraftTimeFormatter.format12h(time);
case "24h" -> MinecraftTimeFormatter.format24h(time);
default -> MinecraftTimeFormatter.formatTime(time, timeFormat);
};
}
case "type" -> {
return world.getBukkitWorld().map(World::getWorldType).map(Enum::name).getOrElse("null");
}
case "weather" -> {
return String.valueOf(world.isAllowWeather());
}
default -> {
warning("Unknown placeholder: " + placeholder);
return null;
}
}
case "type" -> world.getBukkitWorld().map(World::getWorldType).map(Enum::name).getOrElse("null");
default -> null;
});
}
}
@@ -0,0 +1,60 @@
package org.mvplugins.multiverse.core.command;
import co.aikar.commands.CommandCompletionContext;
import co.aikar.commands.CommandCompletionFilter;
import co.aikar.commands.apachecommonslang.ApacheCommonsLangUtil;
import org.jetbrains.annotations.ApiStatus;
import java.util.Locale;
/**
* Utility filters for command completion matching.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public final class MVCommandCompletionFilters {
/**
* Matches namespaced keys (for example, {@code minecraft:stone}) using namespace-aware checks.
* <p>
* This filter accepts completions that:
* <ul>
* <li>start with the current input,</li>
* <li>have a namespace starting with the input, or</li>
* <li>have a key value containing the input.</li>
* </ul>
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static final CommandCompletionFilter NAMESPACED_KEY = (context, completion) -> {
String[] split = completion.split(":", 2);
if (split.length < 2) {
return ApacheCommonsLangUtil.startsWithIgnoreCase(completion, context.getInput());
}
String lowerCase = context.getInput().toLowerCase(Locale.ROOT);
return ApacheCommonsLangUtil.startsWithIgnoreCase(completion, context.getInput())
|| split[0].toLowerCase(Locale.ROOT).startsWith(lowerCase)
|| split[1].toLowerCase(Locale.ROOT).contains(lowerCase);
};
/**
* Gets the namespaced-key completion filter with a generic type-safe signature.
* Use this method instead of {@link MVCommandCompletionFilters#NAMESPACED_KEY}
* to avoid raw type unchecked warnings.
*
* @param <C> completion context type
* @return the namespaced key filter
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static <C extends CommandCompletionContext> CommandCompletionFilter<C> namespacedKey() {
return NAMESPACED_KEY;
}
private MVCommandCompletionFilters() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}
@@ -11,6 +11,7 @@ import java.util.Map;
import java.util.stream.Collectors;
import co.aikar.commands.BukkitCommandCompletionContext;
import co.aikar.commands.CommandCompletionFilter;
import co.aikar.commands.CommandIssuer;
import co.aikar.commands.PaperCommandCompletions;
import co.aikar.commands.RegisteredCommand;
@@ -52,8 +53,9 @@ import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.generators.GeneratorPlugin;
import org.mvplugins.multiverse.core.world.generators.GeneratorProvider;
import org.mvplugins.multiverse.core.world.helpers.PotentialWorldFinder;
import static org.mvplugins.multiverse.core.utils.StringFormatter.addonToCommaSeperated;
import static org.mvplugins.multiverse.core.utils.StringFormatter.addOnToCommaSeparated;
@Service
public class MVCommandCompletions extends PaperCommandCompletions {
@@ -65,6 +67,7 @@ public class MVCommandCompletions extends PaperCommandCompletions {
private final CorePermissionsChecker corePermissionsChecker;
private final AnchorManager anchorManager;
private final GeneratorProvider generatorProvider;
private final PotentialWorldFinder potentialWorldFinder;
@Inject
MVCommandCompletions(
@@ -74,7 +77,8 @@ public class MVCommandCompletions extends PaperCommandCompletions {
@NotNull CoreConfig config,
@NotNull CorePermissionsChecker corePermissionsChecker,
@NotNull AnchorManager anchorManager,
@NotNull GeneratorProvider generatorProvider
@NotNull GeneratorProvider generatorProvider,
@NotNull PotentialWorldFinder potentialWorldFinder
) {
super(mvCommandManager);
this.commandManager = mvCommandManager;
@@ -84,27 +88,29 @@ public class MVCommandCompletions extends PaperCommandCompletions {
this.corePermissionsChecker = corePermissionsChecker;
this.anchorManager = anchorManager;
this.generatorProvider = generatorProvider;
this.potentialWorldFinder = potentialWorldFinder;
registerAsyncCompletion("anchornames", this::suggestAnchorNames);
registerAsyncCompletion("anchornames", this::suggestAnchorNames, CommandCompletionFilter.contains());
registerAsyncCompletion("commands", this::suggestCommands);
registerAsyncCompletion("destinations", this::suggestDestinations);
registerStaticCompletion("difficulties", suggestEnums(Difficulty.class));
registerStaticCompletion("environments", List.of("normal", "nether", "the_end")); // Don't tab complete the "custom" environment
registerAsyncCompletion("flags", this::suggestFlags);
registerStaticCompletion("gamemodes", suggestEnums(GameMode.class));
registerStaticCompletion("gamerules", this::suggestGamerules);
registerStaticCompletion("gamerules", this::suggestGamerules, MVCommandCompletionFilters.namespacedKey());
registerAsyncCompletion("gamerulesvalues", this::suggestGamerulesValues);
registerAsyncCompletion("generatorplugins", this::suggestGeneratorPlugins);
registerStaticCompletion("materials", suggestEnums(Material.class));
registerStaticCompletion("mvconfigs", config.getStringPropertyHandle().getAllPropertyNames());
registerAsyncCompletion("generatorplugins", this::suggestGeneratorPlugins, CommandCompletionFilter.contains());
registerStaticCompletion("materials", suggestEnums(Material.class), CommandCompletionFilter.contains());
registerStaticCompletion("mvconfigs", config.getStringPropertyHandle().getAllPropertyNames(), CommandCompletionFilter.contains());
registerAsyncCompletion("mvconfigvalues", this::suggestMVConfigValues);
registerAsyncCompletion("mvworlds", this::suggestMVWorlds);
registerAsyncCompletion("mvworldpropsname", this::suggestMVWorldPropsName);
registerAsyncCompletion("mvworlds", this::suggestMVWorlds, MVCommandCompletionFilters.namespacedKey());
registerAsyncCompletion("mvworldmetakey", this::suggestMVWorldMetaKey, CommandCompletionFilter.contains());
registerAsyncCompletion("mvworldpropsname", this::suggestMVWorldPropsName, CommandCompletionFilter.contains());
registerAsyncCompletion("mvworldpropsvalue", this::suggestMVWorldPropsValue);
registerCompletion("playersarray", this::suggestPlayersArray); // getting online players cannot be async
registerCompletion("playersarray", this::suggestPlayersArray, CommandCompletionFilter.contains()); // getting online players cannot be async
registerStaticCompletion("propsmodifyaction", suggestEnums(PropertyModifyAction.class));
registerStaticCompletion("spawncategories", suggestEnums(SpawnCategory.class));
registerAsyncCompletion("spawncategorypropsname", this::suggestSpawnCategoryPropsName);
registerAsyncCompletion("spawncategorypropsname", this::suggestSpawnCategoryPropsName, CommandCompletionFilter.contains());
registerAsyncCompletion("spawncategorypropsvalue", this::suggestSpawnCategoryPropsValue);
setDefaultCompletion("destinations", DestinationInstance.class);
@@ -163,7 +169,7 @@ public class MVCommandCompletions extends PaperCommandCompletions {
}
}
if (context.hasConfig("multiple")) {
return addonToCommaSeperated(context.getInput(), handler.getCompletions(context));
return addOnToCommaSeparated(context.getInput(), handler.getCompletions(context));
}
return handler.getCompletions(context);
}
@@ -297,13 +303,20 @@ public class MVCommandCompletions extends PaperCommandCompletions {
.toList();
}
case "potential" -> {
return worldManager.getPotentialWorlds();
return potentialWorldFinder.findPotentialWorlds();
}
}
Logging.severe("Invalid MVWorld scope: " + scope);
return Collections.emptyList();
}
private Collection<String> suggestMVWorldMetaKey(BukkitCommandCompletionContext context) {
return Try.of(() -> context.getContextValue(MultiverseWorld.class))
.map(MultiverseWorld::getAllMeta)
.map(Map::keySet)
.getOrElse(Collections.emptySet());
}
private Collection<String> suggestMVWorldPropsName(BukkitCommandCompletionContext context) {
return Try.of(() -> {
MultiverseWorld world = context.getContextValue(MultiverseWorldValue.class).value();
@@ -334,7 +347,7 @@ public class MVCommandCompletions extends PaperCommandCompletions {
matchedPlayers.add(name);
}
}
return addonToCommaSeperated(context.getInput(), matchedPlayers);
return addOnToCommaSeparated(context.getInput(), matchedPlayers);
}
private Collection<String> suggestSpawnCategoryPropsName(BukkitCommandCompletionContext context) {
@@ -42,6 +42,7 @@ import org.mvplugins.multiverse.core.exceptions.command.MVInvalidCommandArgument
import org.mvplugins.multiverse.core.locale.message.Message;
import org.mvplugins.multiverse.core.utils.PlayerFinder;
import org.mvplugins.multiverse.core.utils.REPatterns;
import org.mvplugins.multiverse.core.utils.tick.TickDuration;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
@@ -93,6 +94,7 @@ public class MVCommandContexts extends PaperCommandContexts {
registerIssuerAwareContext(PlayerArrayValue.class, playerArrayContextBuilder().generateContext(PlayerArrayValue::new));
registerIssuerAwareContext(PlayerLocation.class, this::parsePlayerLocation);
registerContext(SpawnCategory[].class, this::parseSpawnCategories);
registerContext(TickDuration.class, this::parseTickDuration);
}
private MVCommandIssuer parseMVCommandIssuer(BukkitCommandExecutionContext context) {
@@ -393,4 +395,13 @@ public class MVCommandContexts extends PaperCommandContexts {
}
return categories.toArray(new SpawnCategory[0]);
}
private TickDuration parseTickDuration(BukkitCommandExecutionContext context) {
String arg = context.popFirstArg();
return TickDuration.parseString(arg)
.getOrElseThrow(failure ->
new InvalidCommandArgument("Invalid time duration format: \"" +arg + "\". Use a number " +
"followed by an optional suffix (s for seconds, d for game days) or just a number " +
"for ticks. Examples: 100, 5s, 2d"));
}
}
@@ -62,7 +62,6 @@ public class MVCommandManager extends PaperCommandManager {
this.config = config;
this.pluginLocales = new PluginLocales(this);
this.locales = this.pluginLocales;
this.pluginLocales.loadLanguages();
MVCommandConditions.load(this, worldManager, worldNameChecker);
this.enableUnstableAPI("help");
@@ -5,8 +5,10 @@ import java.util.Collection;
import java.util.List;
import java.util.Locale;
import java.util.function.Function;
import java.util.function.Supplier;
import co.aikar.commands.InvalidCommandArgument;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -40,34 +42,34 @@ public class CommandValueFlag<T> extends CommandFlag {
private final Class<T> type;
private final boolean optional;
private final T defaultValue;
private final Supplier<? extends T> defaultValueSupplier;
private final Function<String, T> context;
private final Function<String, Collection<String>> completion;
/**
* Creates a new flag.
*
* @param key The key for the new flag.
* @param aliases The aliases that also refer to this flag.
* @param type The type of the value.
* @param optional Allow for flag without value.
* @param defaultValue The default value if optional is true and user does not specify a value.
* @param context Function to parse string into value type.
* @param completion Function to get completion for this flag.
* @param key The key for the new flag.
* @param aliases The aliases that also refer to this flag.
* @param type The type of the value.
* @param optional Allow for flag without value.
* @param defaultValueSupplier The default value if optional is true and user does not specify a value.
* @param context Function to parse string into value type.
* @param completion Function to get completion for this flag.
*/
protected CommandValueFlag(
@NotNull String key,
@NotNull List<String> aliases,
@NotNull Class<T> type,
boolean optional,
@Nullable T defaultValue,
@Nullable Supplier<? extends T> defaultValueSupplier,
@Nullable Function<String, T> context,
@Nullable Function<String, Collection<String>> completion
) {
super(key, aliases);
this.type = type;
this.optional = optional;
this.defaultValue = defaultValue;
this.defaultValueSupplier = defaultValueSupplier;
this.context = context;
this.completion = completion;
}
@@ -96,7 +98,7 @@ public class CommandValueFlag<T> extends CommandFlag {
* @return The default value.
*/
public @Nullable T getDefaultValue() {
return defaultValue;
return defaultValueSupplier == null ? null : defaultValueSupplier.get();
}
/**
@@ -126,7 +128,7 @@ public class CommandValueFlag<T> extends CommandFlag {
public static class Builder<T, S extends Builder<T, S>> extends CommandFlag.Builder<S> {
protected final Class<T> type;
protected boolean optional = false;
protected T defaultValue = null;
protected Supplier<? extends T> defaultValueSupplier = null;
protected Function<String, T> context = null;
protected Function<String, Collection<String>> completion = null;
@@ -158,7 +160,21 @@ public class CommandValueFlag<T> extends CommandFlag {
* @return The builder.
*/
public @NotNull S defaultValue(@NotNull T defaultValue) {
this.defaultValue = defaultValue;
return defaultValue(() -> defaultValue);
}
/**
* Set the default value supplier. Used if optional is true and user does not specify a value.
* Supplier is only called when command is executed with the flag is present in input.
*
* @param defaultValueSupplier The default value supplier
* @return The builder
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull S defaultValue(@NotNull Supplier<? extends T> defaultValueSupplier) {
this.defaultValueSupplier = defaultValueSupplier;
return (S) this;
}
@@ -194,7 +210,7 @@ public class CommandValueFlag<T> extends CommandFlag {
if (context == null && !String.class.equals(type)) {
throw new IllegalStateException("Context is required for non-string value flags");
}
return new CommandValueFlag<>(key, aliases, type, optional, defaultValue, context, completion);
return new CommandValueFlag<>(key, aliases, type, optional, defaultValueSupplier, context, completion);
}
}
@@ -207,7 +223,7 @@ public class CommandValueFlag<T> extends CommandFlag {
public static class EnumBuilder<T extends Enum<T>, S extends EnumBuilder<T, S>> extends CommandFlag.Builder<S> {
protected final Class<T> type;
protected boolean optional = false;
protected T defaultValue = null;
protected Supplier<? extends T> defaultValueSupplier = null;
protected Function<String, T> context = null;
protected Function<String, Collection<String>> completion = null;
@@ -253,7 +269,21 @@ public class CommandValueFlag<T> extends CommandFlag {
* @return The builder.
*/
public @NotNull S defaultValue(@NotNull T defaultValue) {
this.defaultValue = defaultValue;
return defaultValue(() -> defaultValue);
}
/**
* Set the default value to supply. Used if optional is true and user does not specify a value.
* Supplier is only called when command is executed with the flag is present in input.
*
* @param defaultValueSupplier The default value supplier.
* @return The builder.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull S defaultValue(@NotNull Supplier<? extends T> defaultValueSupplier) {
this.defaultValueSupplier = defaultValueSupplier;
return (S) this;
}
@@ -264,7 +294,7 @@ public class CommandValueFlag<T> extends CommandFlag {
*/
@Override
public @NotNull CommandValueFlag<T> build() {
return new CommandValueFlag<>(key, aliases, type, optional, defaultValue, context, completion);
return new CommandValueFlag<>(key, aliases, type, optional, defaultValueSupplier, context, completion);
}
}
}
@@ -0,0 +1,65 @@
package org.mvplugins.multiverse.core.command.flags;
import co.aikar.commands.InvalidCommandArgument;
import jakarta.inject.Inject;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.CommandValueFlag;
import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
import org.mvplugins.multiverse.core.destination.DestinationInstance;
import org.mvplugins.multiverse.core.destination.DestinationsProvider;
import org.mvplugins.multiverse.core.destination.core.WorldDestination;
import org.mvplugins.multiverse.core.exceptions.command.MVInvalidCommandArgument;
import org.mvplugins.multiverse.core.world.WorldManager;
@ApiStatus.AvailableSince("5.7")
@Service
public class RemovePlayerDestinationFlags extends FlagBuilder {
public static final String NAME = "removeplayer";
private WorldManager worldManager;
private DestinationsProvider destinationsProvider;
private WorldDestination worldDestination;
protected RemovePlayerDestinationFlags(
@NotNull String name,
@NotNull CommandFlagsManager flagsManager,
@NotNull WorldManager worldManager,
@NotNull DestinationsProvider destinationsProvider,
@NotNull WorldDestination worldDestination
) {
super(name, flagsManager);
this.worldManager = worldManager;
this.destinationsProvider = destinationsProvider;
this.worldDestination = worldDestination;
}
@Inject
private RemovePlayerDestinationFlags(
@NotNull CommandFlagsManager flagsManager,
@NotNull WorldManager worldManager,
@NotNull DestinationsProvider destinationsProvider,
@NotNull WorldDestination worldDestination
) {
super(NAME, flagsManager);
this.destinationsProvider = destinationsProvider;
this.worldManager = worldManager;
this.worldDestination = worldDestination;
}
public final CommandValueFlag<DestinationInstance> removePlayers = flag(CommandValueFlag.builder("--remove-players", DestinationInstance.class)
.addAlias("-r")
.defaultValue(() -> worldManager.getDefaultWorld()
.map(defaultWorld -> worldDestination.fromWorld(defaultWorld))
.getOrElseThrow(() -> new InvalidCommandArgument("No default world found, so the --remove-players flag requires a destination argument."))) //TODO: locale
.completion(input -> destinationsProvider.suggestDestinationStrings(Bukkit.getConsoleSender(), input))
.context(input -> destinationsProvider.parseDestination(input)
.getOrThrow(failure ->
MVInvalidCommandArgument.of(failure.getFailureMessage())))
.optional()
.build());
}
@@ -1,12 +1,19 @@
package org.mvplugins.multiverse.core.command.flags;
import jakarta.inject.Inject;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
/**
* @deprecated The --remove-players flag is being removed in favor of a more flexible system that allows for specifying
* a destination for players to be teleported to when a world is unloaded. See {@link RemovePlayerDestinationFlags}.
*/
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
@Service
public class RemovePlayerFlags extends FlagBuilder {
@@ -20,7 +20,7 @@ import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.options.CloneWorldOptions;
@@ -48,7 +48,7 @@ class CloneCommand extends CoreCommand {
@Syntax("<world>")
@Description("{@@mv-core.clone.world.description}")
LoadedMultiverseWorld world,
MultiverseWorld world,
@Syntax("<new-world-name>")
@Description("{@@mv-core.clone.newWorld.description}")
@@ -114,7 +114,7 @@ class CloneCommand extends CoreCommand {
@Override
@CommandAlias("mvcl|mvclone")
void onCloneCommand(MVCommandIssuer issuer, LoadedMultiverseWorld world, String newWorldName, String[] flags) {
void onCloneCommand(MVCommandIssuer issuer, MultiverseWorld world, String newWorldName, String[] flags) {
super.onCloneCommand(issuer, world, newWorldName, flags);
}
}
@@ -11,6 +11,7 @@ import co.aikar.commands.annotation.Optional;
import co.aikar.commands.annotation.Subcommand;
import co.aikar.commands.annotation.Syntax;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Try;
import jakarta.inject.Inject;
import org.bukkit.World;
import org.bukkit.WorldType;
@@ -19,14 +20,16 @@ import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.LegacyAliasCommand;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.MVCommandManager;
import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.CommandValueFlag;
import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.exceptions.command.MVInvalidCommandArgument;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.utils.StringFormatter;
import org.mvplugins.multiverse.core.utils.position.EntityPosition;
import org.mvplugins.multiverse.core.utils.result.Attempt.Failure;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
@@ -52,8 +55,9 @@ class CreateCommand extends CoreCommand {
@Subcommand("create")
@CommandPermission("multiverse.core.create")
@CommandCompletion("@empty @environments @flags:groupName=" + Flags.NAME)
@Syntax("<name> <environment> [--seed <seed> --generator <generator[:id]> --world-type <worldtype> --adjust-spawn "
+ "--no-structures --biome <biome>]")
@Syntax("<name> <environment> [--seed <seed> --generator <generator[:id]> --world-type <worldtype> " +
"--adjust-spawn --no-structures --generate-bonus-chest --force-spawn-position <x,y,z:pitch:yaw> " +
"--biome <biome> --generator-settings <json-settings> --properties <prop1=value1,prop2=value2,...>]")
@Description("{@@mv-core.create.description}")
void onCreateCommand(
MVCommandIssuer issuer,
@@ -67,8 +71,9 @@ class CreateCommand extends CoreCommand {
World.Environment environment,
@Optional
@Syntax("[--seed <seed> --generator <generator[:id]> --world-type <worldtype> --adjust-spawn "
+ "--no-structures --biome <biome>]")
@Syntax("[--seed <seed> --generator <generator[:id]> --world-type <worldtype> --adjust-spawn " +
"--no-structures --generate-bonus-chest --force-spawn-position <x,y,z:pitch:yaw> --biome <biome> " +
"--generator-settings <json-settings> --properties <prop1=value1,prop2=value2,...>]")
@Description("{@@mv-core.create.flags.description}")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
@@ -78,14 +83,17 @@ class CreateCommand extends CoreCommand {
issuer.sendInfo(MVCorei18n.CREATE_LOADING);
worldManager.createWorld(CreateWorldOptions.worldName(worldName)
.biome(parsedFlags.flagValue(flags.biome, ""))
.environment(environment)
.seed(parsedFlags.flagValue(flags.seed))
.worldType(parsedFlags.flagValue(flags.worldType, WorldType.NORMAL))
.useSpawnAdjust(!parsedFlags.hasFlag(flags.noAdjustSpawn))
.generator(parsedFlags.flagValue(flags.generator, ""))
.generatorSettings(parsedFlags.flagValue(flags.generatorSettings, ""))
.generateStructures(!parsedFlags.hasFlag(flags.noStructures)))
.biome(parsedFlags.flagValue(flags.biome, ""))
.bonusChest(parsedFlags.hasFlag(flags.bonusChest))
.environment(environment)
.forcedSpawnPosition(parsedFlags.flagValue(flags.forceSpawnPosition))
.generator(parsedFlags.flagValue(flags.generator, ""))
.generatorSettings(parsedFlags.flagValue(flags.generatorSettings, ""))
.generateStructures(!parsedFlags.hasFlag(flags.noStructures))
.seed(parsedFlags.flagValue(flags.seed))
.useSpawnAdjust(!parsedFlags.hasFlag(flags.noAdjustSpawn))
.worldPropertyStrings(StringFormatter.parseCSVMap(parsedFlags.flagValue(flags.properties)))
.worldType(parsedFlags.flagValue(flags.worldType, WorldType.NORMAL)))
.onSuccess(newWorld -> messageSuccess(issuer, newWorld))
.onFailure(failure -> messageFailure(issuer, failure));
}
@@ -180,6 +188,19 @@ class CreateCommand extends CoreCommand {
.addAlias("-b")
.completion(input -> biomeProviderFactory.suggestBiomeString(input))
.build());
private final CommandValueFlag<String> properties = flag(CommandValueFlag.builder("--properties", String.class)
.addAlias("-p")
.build());
private final CommandFlag bonusChest = flag(CommandFlag.builder("--generate-bonus-chest")
.addAlias("-c")
.build());
private final CommandValueFlag<EntityPosition> forceSpawnPosition = flag(CommandValueFlag.builder("--force-spawn-position", EntityPosition.class)
.addAlias("-f")
.context(input -> Try.of(() -> EntityPosition.fromString(input)).getOrElseThrow(MVInvalidCommandArgument::causeBy))
.build());
}
@Service
@@ -15,11 +15,11 @@ import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.LegacyAliasCommand;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.MVCommandManager;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags;
import org.mvplugins.multiverse.core.command.queue.CommandQueueManager;
import org.mvplugins.multiverse.core.command.queue.CommandQueuePayload;
import org.mvplugins.multiverse.core.destination.DestinationInstance;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.Message;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
@@ -31,6 +31,8 @@ import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.helpers.PlayerWorldTeleporter;
import org.mvplugins.multiverse.core.world.options.DeleteWorldOptions;
import java.util.Objects;
@Service
class DeleteCommand extends CoreCommand {
@@ -38,7 +40,7 @@ class DeleteCommand extends CoreCommand {
private final WorldManager worldManager;
private final PlayerWorldTeleporter playerWorldTeleporter;
private final WorldTickDeferrer worldTickDeferrer;
private final RemovePlayerFlags flags;
private final RemovePlayerDestinationFlags flags;
@Inject
DeleteCommand(
@@ -46,7 +48,7 @@ class DeleteCommand extends CoreCommand {
@NotNull WorldManager worldManager,
@NotNull PlayerWorldTeleporter playerWorldTeleporter,
@NotNull WorldTickDeferrer worldTickDeferrer,
@NotNull RemovePlayerFlags flags
@NotNull RemovePlayerDestinationFlags flags
) {
this.commandQueueManager = commandQueueManager;
this.worldManager = worldManager;
@@ -57,8 +59,8 @@ class DeleteCommand extends CoreCommand {
@Subcommand("delete")
@CommandPermission("multiverse.core.delete")
@CommandCompletion("@mvworlds:scope=both @flags:groupName=" + RemovePlayerFlags.NAME)
@Syntax("<world>")
@CommandCompletion("@mvworlds:scope=both @flags:groupName=" + RemovePlayerDestinationFlags.NAME)
@Syntax("<world> [--remove-players [destination]]")
@Description("{@@mv-core.delete.description}")
void onDeleteCommand(
MVCommandIssuer issuer,
@@ -69,7 +71,7 @@ class DeleteCommand extends CoreCommand {
MultiverseWorld world,
@Optional
@Syntax("[--remove-players]")
@Syntax("[--remove-players [destination]]")
@Description("")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
@@ -84,10 +86,11 @@ class DeleteCommand extends CoreCommand {
private void runDeleteCommand(MVCommandIssuer issuer, MultiverseWorld world, ParsedCommandFlags parsedFlags) {
issuer.sendInfo(MVCorei18n.DELETE_DELETING, Replace.WORLD.with(world.getName()));
var future = parsedFlags.hasFlag(flags.removePlayers)
DestinationInstance<?, ?> removeToDestination = parsedFlags.flagValue(flags.removePlayers);
var future = Objects.nonNull(removeToDestination)
&& world.isLoaded()
&& world instanceof LoadedMultiverseWorld loadedWorld
? playerWorldTeleporter.removeFromWorld(loadedWorld)
? playerWorldTeleporter.transferAllFromWorldToDestination(loadedWorld, removeToDestination)
: AsyncAttemptsAggregate.emptySuccess();
future.onSuccess(() -> worldTickDeferrer.deferWorldTick(() -> doWorldDeleting(issuer, world)))
@@ -113,7 +116,7 @@ class DeleteCommand extends CoreCommand {
@NotNull WorldManager worldManager,
@NotNull PlayerWorldTeleporter playerWorldTeleporter,
@NotNull WorldTickDeferrer worldTickDeferrer,
@NotNull RemovePlayerFlags flags) {
@NotNull RemovePlayerDestinationFlags flags) {
super(commandQueueManager, worldManager, playerWorldTeleporter, worldTickDeferrer, flags);
}
@@ -12,13 +12,10 @@ import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
import org.mvplugins.multiverse.core.commands.DumpsLogPoster.LogsType;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.commands.DumpsLogPoster.UploadType;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.MVCommandManager;
import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandValueFlag;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
@Service
final class DumpsCommand extends CoreCommand {
@@ -35,22 +32,17 @@ final class DumpsCommand extends CoreCommand {
@Subcommand("dumps")
@CommandPermission("multiverse.core.dumps")
@CommandCompletion("@flags:groupName=" + Flags.NAME)
@Syntax("[--logs <mclogs | append>] [--upload <pastesdev | pastegg>] [--paranoid]")
@Syntax("[--upload <pastesdev|mclogs>]")
@Description("{@@mv-core.dumps.description}")
void onDumpsCommand(
MVCommandIssuer issuer,
@Optional
@Syntax("[--logs <mclogs | append>] [--upload <pastesdev | pastegg>] [--paranoid]")
@Syntax("[--upload <pastesdev|mclogs>]")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
// Grab all our flags
boolean paranoid = parsedFlags.hasFlag(flags.paranoid);
LogsType logsType = parsedFlags.flagValue(flags.logs, LogsType.MCLOGS);
UploadType servicesType = parsedFlags.flagValue(flags.upload, UploadType.PASTESDEV);
dumpsService.postLogs(issuer, logsType, servicesType, paranoid);
dumpsService.postLogs(issuer, servicesType);
}
@Service
@@ -63,19 +55,9 @@ final class DumpsCommand extends CoreCommand {
super(NAME, flagsManager);
}
private final CommandValueFlag<LogsType> logs = flag(CommandValueFlag
.enumBuilder("--logs", LogsType.class)
.addAlias("-l")
.build());
private final CommandValueFlag<UploadType> upload = flag(CommandValueFlag
.enumBuilder("--upload", UploadType.class)
.addAlias("-u")
.build());
// Does not upload logs or plugin list (except if --logs mclogs is there)
private final CommandFlag paranoid = flag(CommandFlag.builder("--paranoid")
.addAlias("-p")
.build());
}
}
@@ -1,11 +1,19 @@
package org.mvplugins.multiverse.core.commands;
import java.lang.management.ManagementFactory;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
import net.minidev.json.JSONArray;
import net.minidev.json.JSONObject;
import org.bukkit.plugin.Plugin;
import org.bukkit.scheduler.BukkitRunnable;
import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.MultiverseCore;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.event.MVDumpsDebugInfoEvent;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
@@ -18,8 +26,10 @@ final class DumpsLogPoster extends BukkitRunnable {
enum UploadType {
// BEGIN CHECKSTYLE-SUPPRESSION: JavadocVariable
PASTEGG(PasteServiceType.PASTEGG, "paste.gg"),
PASTESDEV(PasteServiceType.PASTESDEV, "pastes.dev");
// PASTEBIN(PasteServiceType.PASTEBIN, "pastebin.com"),
PASTESDEV(PasteServiceType.PASTESDEV, "pastes.dev"),
MCLOGS(PasteServiceType.MCLOGS, "mclo.gs"),
;
// END CHECKSTYLE-SUPPRESSION: JavadocVariable
private final PasteServiceType pasteServiceType;
@@ -31,34 +41,21 @@ final class DumpsLogPoster extends BukkitRunnable {
}
}
enum LogsType {
/**
* Appends the log file to the debug info.
*/
APPEND,
/**
* Uploads the log file to a paste service.
*/
MCLOGS
}
@NotNull
private final MultiverseCore plugin;
private final MVCommandIssuer issuer;
private final LogsType logsType;
private final UploadType uploadType;
private final boolean paranoid;
private final String logs;
private final MVDumpsDebugInfoEvent versionEvent;
DumpsLogPoster(@NotNull MVCommandIssuer issuer,
@NotNull DumpsLogPoster.LogsType logsType,
DumpsLogPoster(@NotNull MultiverseCore core,
@NotNull MVCommandIssuer issuer,
@NotNull DumpsLogPoster.UploadType uploadType,
boolean paranoid,
@NotNull String logs,
@NotNull MVDumpsDebugInfoEvent versionEvent) {
this.plugin = core;
this.issuer = issuer;
this.logsType = logsType;
this.uploadType = uploadType;
this.paranoid = paranoid;
this.logs = logs;
this.versionEvent = versionEvent;
}
@@ -69,24 +66,12 @@ final class DumpsLogPoster extends BukkitRunnable {
handleVersionEvent();
}
@SuppressWarnings("checkstyle:MissingSwitchDefault")
private void handleLogs() {
Logging.finer("Logs type is: " + logsType);
if (!paranoid) {
switch (logsType) {
case MCLOGS -> sendDumpsUrl("Logs", postRawDataToMcLogs(logs));
case APPEND -> versionEvent.putDetailedDebugInfo("latest.log", logs);
default -> Logging.finer("Not uploading logs.");
}
} else {
Logging.finer("Paranoid mode is on, not uploading logs.");
}
versionEvent.putDetailedDebugInfo("latest.log", logs);
}
private void handleVersionEvent() {
Logging.finer("Upload service is: " + uploadType);
final Map<String, String> files = versionEvent.getDetailedDebugInfo();
sendDumpsUrl(uploadType.serviceName, postFilesToService(files));
}
@@ -95,37 +80,77 @@ final class DumpsLogPoster extends BukkitRunnable {
issuer.sendInfo(MVCorei18n.DUMPS_URL_LIST, "{service}", service, "{link}", url);
}
private String postRawDataToMcLogs(@NotNull String rawPasteData) {
PasteService pasteService = PasteServiceFactory.getService(PasteServiceType.MCLOGS, true);
return pasteToService(() -> pasteService.postData(rawPasteData));
}
private String postFilesToService(@NotNull Map<String, String> pasteFiles) {
PasteService pasteService = PasteServiceFactory.getService(uploadType.pasteServiceType, true);
return pasteToService(() -> {
if (pasteService.supportsMultiFile()) {
return pasteService.postData(pasteFiles);
} else {
return pasteService.postData(this.encodeAsString(pasteFiles));
}
});
PasteService pasteService = PasteServiceFactory.getService(uploadType.pasteServiceType, false);
return pasteToService(() -> pasteService.postData(encodePasteAsJson(pasteFiles)));
}
private String encodeAsString(Map<String, String> files) {
StringBuilder uploadData = new StringBuilder();
for (Map.Entry<String, String> entry : files.entrySet()) {
String file = entry.getKey();
String data = entry.getValue();
uploadData.append("# ---------- ")
.append(file)
.append(" ----------\n\n")
.append(data)
.append("\n\n");
private String encodePasteAsJson(Map<String, String> files) {
JSONObject rootObject = new JSONObject();
JSONArray plugins = new JSONArray();
JSONArray filesObject = new JSONArray();
for (Plugin plugin : plugin.getServer().getPluginManager().getPlugins()) {
plugins.add(new JSONObject(Map.of(
"name", plugin.getName(),
"version", plugin.getDescription().getVersion(),
"description", Option.of(plugin.getDescription().getDescription()).getOrElse(""),
"authors", plugin.getDescription().getAuthors(),
"website", Option.of(plugin.getDescription().getWebsite()).getOrElse(""),
"enabled", plugin.isEnabled()
)));
}
return uploadData.toString();
for (Map.Entry<String, String> entry : files.entrySet()) {
String fileType = "text";
if (entry.getKey().endsWith(".yml") || entry.getKey().endsWith(".yaml")) {
fileType = "yaml";
} else if (entry.getKey().endsWith(".json")) {
fileType = "json";
} else if (entry.getKey().endsWith(".md")) {
fileType = "markdown";
} else if (entry.getKey().endsWith(".log")) {
fileType = "accesslog";
}
filesObject.add(new JSONObject(Map.of(
"name", entry.getKey(),
"data", entry.getValue(),
"type", fileType
)));
}
rootObject.put("server", new JSONObject(Map.of(
"name", plugin.getServer().getName(),
"version", plugin.getServer().getVersion(),
"bukkitVersion", plugin.getServer().getBukkitVersion(),
"onlineMode", plugin.getServer().getOnlineMode(),
"javaVersion", Runtime.version().toString(),
"operatingSystem", System.getProperty("os.name") + " " + System.getProperty("os.version") + " " + System.getProperty("os.arch"),
"uptime", calculateUptimeString(),
"memory", "%d free / %d total / %d max".formatted(
toMb(Runtime.getRuntime().freeMemory()),
toMb(Runtime.getRuntime().totalMemory()),
toMb(Runtime.getRuntime().maxMemory()))
)));
rootObject.put("plugins", plugins);
rootObject.put("files", filesObject);
rootObject.put("createdAt", Instant.now().toString());
return rootObject.toJSONString();
}
private long toMb(long value) {
return value / 1024 / 1024;
}
private String calculateUptimeString() {
long uptimeMs = System.currentTimeMillis() - ManagementFactory.getRuntimeMXBean().getStartTime();
long totalSeconds = uptimeMs / 1000;
long hours = totalSeconds / 3600;
long minutes = (totalSeconds % 3600) / 60;
long seconds = totalSeconds % 60;
return String.format("%d hrs, %d mins, %d sec", hours, minutes, seconds);
}
private String pasteToService(LogPaster paster) {
@@ -5,17 +5,15 @@ import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import com.dumptruckman.minecraft.util.Logging;
import jakarta.inject.Inject;
import org.apache.commons.lang3.StringUtils;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.MultiverseCore;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.module.MultiverseModulesRegistry;
import org.mvplugins.multiverse.core.commands.DumpsLogPoster.LogsType;
import org.mvplugins.multiverse.core.commands.DumpsLogPoster.UploadType;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.event.MVDumpsDebugInfoEvent;
@@ -39,16 +37,12 @@ final class DumpsService {
this.fileUtils = fileUtils;
}
void postLogs(MVCommandIssuer issuer, LogsType logsType, UploadType servicesType, boolean paranoid) {
void postLogs(MVCommandIssuer issuer, UploadType servicesType) {
issuer.sendInfo(MVCorei18n.DUMPS_STARTING);
// Initialise and add info to the debug event
MVDumpsDebugInfoEvent versionEvent = createAndCallDebugInfoEvent();
// Add plugin list if user isn't paranoid
if (!paranoid) {
versionEvent.putDetailedDebugInfo("plugins.md", "# Plugins\n\n" + getPluginList());
}
new DumpsLogPoster(issuer, logsType, servicesType, paranoid, getLogs(), versionEvent)
new DumpsLogPoster(plugin, issuer, servicesType, getLogs(), versionEvent)
.runTaskAsynchronously(plugin);
}
@@ -73,18 +67,18 @@ final class DumpsService {
private @NotNull String readLogsFromFile(Path logsPath) {
String logs = "Could not read log";
// Try reading as ANSI encoded
// Try reading as UTF-8 first
try {
logs = Files.readString(logsPath, StandardCharsets.UTF_8);
} catch (IOException e) {
Logging.severe("Could not read logs/latest.log. See below for stack trace");
Logging.severe("Could not read logs/latest.log as UTF-8. Trying ISO-8859-1. See below for stack trace");
e.printStackTrace();
try {
logs = Files.readString(logsPath, StandardCharsets.UTF_8);
logs = Files.readString(logsPath, StandardCharsets.ISO_8859_1);
} catch (IOException ex) {
// It is some other strange encoding
Logging.severe("Could not read ./logs/latest.log. See below for stack trace");
Logging.severe("Could not read ./logs/latest.log as ISO-8859-1. See below for stack trace");
ex.printStackTrace();
}
}
@@ -92,15 +86,6 @@ final class DumpsService {
return logs;
}
private String getDebugInfoString() {
return "# Multiverse-Core Version info" + "\n\n"
+ " - Multiverse-Core Version: " + this.plugin.getDescription().getVersion() + '\n'
+ " - Bukkit Version: " + this.plugin.getServer().getVersion() + '\n'
+ " - Loaded Worlds: " + worldManager.getLoadedWorlds() + '\n'
+ " - Multiverse Plugins Loaded: " + StringFormatter.joinAnd(MultiverseModulesRegistry.get().getRegisteredPlugins()) + '\n'
+ " - Multiverse Plugins Count: " + MultiverseModulesRegistry.get().getPluginCount() + '\n';
}
private MVDumpsDebugInfoEvent createAndCallDebugInfoEvent() {
MVDumpsDebugInfoEvent event = new MVDumpsDebugInfoEvent();
addDebugInfoToEvent(event);
@@ -112,34 +97,63 @@ final class DumpsService {
// Add the legacy file, but as markdown, so it's readable
event.putDetailedDebugInfo("version.md", this.getDebugInfoString());
// add config.yml
File configFile = new File(plugin.getDataFolder(), "config.yml");
event.putDetailedDebugInfo("Multiverse-Core/config.yml", configFile);
// add worlds.yml
File worldsFile = new File(plugin.getDataFolder(), "worlds.yml");
event.putDetailedDebugInfo("Multiverse-Core/worlds.yml", worldsFile);
// Add bukkit.yml if we found it
if (fileUtils.getBukkitConfig() != null) {
event.putDetailedDebugInfo(fileUtils.getBukkitConfig().getPath(), fileUtils.getBukkitConfig());
} else {
Logging.warning("/mv dumps could not find bukkit.yml. Not including file");
}
// Add server.properties if we found it
if (fileUtils.getServerProperties() != null) {
event.putDetailedDebugInfo(fileUtils.getServerProperties().getPath(), fileUtils.getServerProperties());
event.putDetailedDebugInfo(fileUtils.getServerProperties().getName(), fileUtils.getServerProperties());
} else {
Logging.warning("/mv dumps could not find server.properties. Not including file");
}
// Add bukkit.yml if we found it
if (fileUtils.getBukkitConfig() != null) {
event.putDetailedDebugInfo(fileUtils.getBukkitConfig().getName(), fileUtils.getBukkitConfig());
} else {
Logging.warning("/mv dumps could not find bukkit.yml. Not including file");
}
// Add spigot.yml
File spigotYml = fileUtils.getServerFolder().toPath().resolve("spigot.yml").toFile();
if (spigotYml.isFile()) {
event.putDetailedDebugInfo(spigotYml.getName(), spigotYml);
} else {
Logging.warning("/mv dumps could not find spigot.yml. Not including file");
}
// Add paper-global.yml
File paperGlobalYml = fileUtils.getServerFolder().toPath().resolve("config/paper-global.yml").toFile();
if (paperGlobalYml.isFile()) {
event.putDetailedDebugInfo(paperGlobalYml.getName(), paperGlobalYml);
} else {
Logging.warning("/mv dumps could not find paper-global.yml. Not including file");
}
// add paper-world-defaults.yml
File paperWorldDefaultsYml = fileUtils.getServerFolder().toPath().resolve("config/paper-world-defaults.yml").toFile();
if (paperWorldDefaultsYml.isFile()) {
event.putDetailedDebugInfo(paperWorldDefaultsYml.getName(), paperWorldDefaultsYml);
} else {
Logging.warning("/mv dumps could not find paper-global.yml. Not including file");
}
// add multiverse-core config.yml
File configFile = new File(plugin.getDataFolder(), "config.yml");
event.putDetailedDebugInfo("multiverse-core/config.yml", configFile);
// add multiverse-core worlds.yml
File worldsFile = new File(plugin.getDataFolder(), "worlds.yml");
event.putDetailedDebugInfo("multiverse-core/worlds.yml", worldsFile);
// add multiverse-core anchors.yml
File anchorsFile = new File(plugin.getDataFolder(), "anchors.yml");
event.putDetailedDebugInfo("multiverse-core/anchors.yml", anchorsFile);
}
private String getPluginList() {
return " - " + StringUtils.join(Arrays.stream(plugin.getServer().getPluginManager().getPlugins())
.map(Object::toString)
.sorted()
.toList(), "\n - ");
private String getDebugInfoString() {
return "# Multiverse-Core Version info" + "\n\n"
+ " - Multiverse-Core Version: " + this.plugin.getDescription().getVersion() + '\n'
+ " - Bukkit Version: " + this.plugin.getServer().getVersion() + '\n'
+ " - Loaded Worlds: " + worldManager.getLoadedWorlds() + '\n'
+ " - Multiverse Plugins Loaded: " + StringFormatter.joinAnd(MultiverseModulesRegistry.get().getRegisteredPlugins()) + '\n'
+ " - Multiverse Plugins Count: " + MultiverseModulesRegistry.get().getPluginCount() + '\n';
}
}
@@ -48,7 +48,7 @@ class ImportCommand extends CoreCommand {
void onImportCommand(
MVCommandIssuer issuer,
@Conditions("worldname:scope=new")
// @Conditions("worldname:scope=new")
@Syntax("<name>")
@Description("{@@mv-core.import.name.description}")
String worldName,
@@ -67,7 +67,8 @@ class ImportCommand extends CoreCommand {
worldManager.importWorld(ImportWorldOptions.worldName(worldName)
.biome(parsedFlags.flagValue(flags.biome, ""))
.environment(environment)
.generator(parsedFlags.flagValue(flags.generator, String.class))
.generator(parsedFlags.flagValue(flags.generator))
.generatorSettings(parsedFlags.flagValue(flags.generatorSettings, ""))
.useSpawnAdjust(!parsedFlags.hasFlag(flags.noAdjustSpawn))
.doFolderCheck(!parsedFlags.hasFlag(flags.skipFolderCheck)))
.onSuccess(newWorld -> {
@@ -110,6 +111,11 @@ class ImportCommand extends CoreCommand {
private final CommandFlag skipFolderCheck = flag(CommandFlag.builder("--skip-folder-check")
.addAlias("-f")
.build());
private final CommandValueFlag<String> generatorSettings = flag(CommandValueFlag
.builder("--generator-settings", String.class)
.addAlias("-gs")
.build());
}
@Service
@@ -87,6 +87,7 @@ class InfoCommand extends CoreCommand {
private Map<String, String> getInfo(LoadedMultiverseWorld world) {
Map<String, String> outMap = new LinkedHashMap<>();
outMap.put("World Key", world.getKey().toString());
outMap.put("World Name", world.getName());
outMap.put("World Alias", world.getAlias());
outMap.put("World UID", world.getUID().toString());
@@ -103,6 +104,7 @@ class InfoCommand extends CoreCommand {
outMap.put("World Type", world.getWorldType().map(WorldType::getName).getOrNull());
outMap.put("Biome", world.getBiome());
outMap.put("Generator", world.getGenerator());
outMap.put("Generator Settings", world.getGeneratorSettings());
outMap.put("Generate Structures", world.canGenerateStructures().map(String::valueOf).getOrNull());
outMap.put("Auto Load", String.valueOf(world.isAutoLoad()));
@@ -112,13 +114,12 @@ class InfoCommand extends CoreCommand {
outMap.put("World Scale", String.valueOf(world.getScale()));
outMap.put("Weather Enabled", String.valueOf(world.isAllowWeather()));
outMap.put("Allow Flight", String.valueOf(world.isAllowFlight()));
outMap.put("Grant Advancements", String.valueOf(world.isAllowAdvancementGrant()));
outMap.put("Auto Heal", String.valueOf(world.getAutoHeal()));
outMap.put("Hunger Depletes", String.valueOf(world.isHunger()));
outMap.put("Keep Spawn In Memory", String.valueOf(world.isKeepSpawnInMemory()));
outMap.put("PVP Enabled", String.valueOf(world.getPvp()));
outMap.put("Portal Form", String.valueOf(world.getPortalForm()));
outMap.put("Player Limit", String.valueOf(world.getPlayerLimit()));
// getAnimalSpawningInfo(outMap, world);
// getMonsterSpawningInfo(outMap, world);
outMap.put("World Blacklist", String.join(", ", world.getWorldBlacklist()));
return outMap;
@@ -135,30 +136,6 @@ class InfoCommand extends CoreCommand {
}
}
// private void getAnimalSpawningInfo(Map<String, String> outMap, MultiverseWorld world) {
// if (world.isSpawningAnimals()) {
// outMap.put("Spawning Animals", "ALL");
// } else {
// if (!world.getSpawningAnimalsExceptions().isEmpty()) {
// outMap.put("Spawning Animals", world.getSpawningAnimalsExceptions().toString());
// } else {
// outMap.put("Spawning Animals", "NONE");
// }
// }
// }
//
// private void getMonsterSpawningInfo(Map<String, String> outMap, MultiverseWorld world) {
// if (world.isSpawningMonsters()) {
// outMap.put("Spawning Monsters", "ALL");
// } else {
// if (!world.getSpawningMonstersExceptions().isEmpty()) {
// outMap.put("Spawning Monsters", world.getSpawningMonstersExceptions().toString());
// } else {
// outMap.put("Spawning Monsters", "NONE");
// }
// }
// }
@Service
private static final class LegacyAlias extends InfoCommand implements LegacyAliasCommand {
@Inject
@@ -16,8 +16,8 @@ import org.mvplugins.multiverse.core.command.LegacyAliasCommand;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.FlagBuilder;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
@@ -38,7 +38,7 @@ class LoadCommand extends CoreCommand {
@Subcommand("load")
@CommandPermission("multiverse.core.load")
@CommandCompletion("@mvworlds:scope=unloaded")
@CommandCompletion("@mvworlds:scope=unloaded @flags:groupName=" + Flags.NAME)
@Syntax("<world>")
@Description("{@@mv-core.load.description}")
void onLoadCommand(
@@ -49,7 +49,7 @@ class LoadCommand extends CoreCommand {
MultiverseWorld world,
@Optional
@Syntax("[--remove-players]")
@Syntax("[--skip-folder-check]")
@Description("")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
@@ -67,9 +67,9 @@ class LoadCommand extends CoreCommand {
}
@Service
private static final class Flags extends RemovePlayerFlags {
private static final class Flags extends FlagBuilder {
private static final String NAME = "mvunload";
private static final String NAME = "mvload";
@Inject
private Flags(@NotNull CommandFlagsManager flagsManager) {
@@ -0,0 +1,121 @@
package org.mvplugins.multiverse.core.commands;
import co.aikar.commands.annotation.CommandCompletion;
import co.aikar.commands.annotation.CommandPermission;
import co.aikar.commands.annotation.Description;
import co.aikar.commands.annotation.Flags;
import co.aikar.commands.annotation.Optional;
import co.aikar.commands.annotation.Single;
import co.aikar.commands.annotation.Subcommand;
import co.aikar.commands.annotation.Syntax;
import jakarta.inject.Inject;
import org.bukkit.ChatColor;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.command.flags.PageFilterFlags;
import org.mvplugins.multiverse.core.display.ContentDisplay;
import org.mvplugins.multiverse.core.display.filters.DefaultContentFilter;
import org.mvplugins.multiverse.core.display.handlers.PagedSendHandler;
import org.mvplugins.multiverse.core.display.parsers.MapContentProvider;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.Message;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
import java.util.Locale;
import static org.mvplugins.multiverse.core.locale.message.MessageReplacement.replace;
@Service
@Subcommand("meta")
final class MetaCommand extends CoreCommand {
private final WorldManager worldManager;
private final PageFilterFlags flags;
@Inject
MetaCommand(@NotNull WorldManager worldManager, @NotNull PageFilterFlags flags) {
this.worldManager = worldManager;
this.flags = flags;
}
@Subcommand("info")
@CommandPermission("multiverse.core.meta.info")
@CommandCompletion("@mvworlds:scope=both|@flags:resolveUntil=arg1,groupName=" + PageFilterFlags.NAME + " " +
"@flags:groupName=" + PageFilterFlags.NAME)
@Syntax("[world] [--page <page>] [--filter <filter>]")
@Description("{@@mv-core.meta.info.description}")
void infoCommand(
MVCommandIssuer issuer,
@Flags("resolve=issuerAware,maxArgForAware=0")
@Syntax("[world]")
@Description("{@@mv-core.meta.info.world.description}")
MultiverseWorld world,
@Optional
@Syntax("[--page <page>] [--filter <filter>]")
String[] flagArray
) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
ContentDisplay.create()
.addContent(MapContentProvider.forContent(world.getAllMeta())
.withKeyColor(ChatColor.AQUA)
.withValueColor(ChatColor.WHITE))
.withSendHandler(PagedSendHandler.create()
.withHeader(Message.of(MVCorei18n.META_INFO_HEADER, replace("{world}").with(world.getName())))
.noContentMessage(Message.of(MVCorei18n.META_INFO_NOCONTENT, replace("{world}").with(world.getName())))
.withTargetPage(parsedFlags.flagValue(flags.page, 1))
.withFilter(parsedFlags.flagValue(flags.filter, DefaultContentFilter.get())))
.send(issuer);
}
@Subcommand("modify")
@CommandPermission("multiverse.core.meta.modify")
@CommandCompletion("@mvworlds:scope=both set|remove @mvworldmetakey @empty")
@Syntax("[world] <set|remove> <key> [value]")
void modifyCommand(
MVCommandIssuer issuer,
@Flags("resolve=issuerAware,maxArgForAware=4")
@Syntax("[world]")
MultiverseWorld world,
@Syntax("<set|remove>")
String action,
@Syntax("<key>")
String key,
@Optional
@Single
@Syntax("[value]")
String value
) {
switch (action.toLowerCase(Locale.ROOT)) {
case "set" -> world.setMeta(key, value)
.onSuccess(ignore -> issuer.sendInfo(MVCorei18n.META_MODIFY_SET_SUCCESS,
replace("{key}").with(key),
replace("{value}").with(value),
Replace.WORLD.with(world.getName())))
.onFailure(throwable -> issuer.sendError(MVCorei18n.META_MODIFY_SET_FAILURE,
replace("{key}").with(key),
replace("{value}").with(value),
Replace.WORLD.with(world.getName()),
Replace.ERROR.with(throwable)));
case "remove" -> world.removeMeta(key)
.onSuccess(ignore -> issuer.sendInfo(MVCorei18n.META_MODIFY_REMOVE_SUCCESS,
replace("{key}").with(key),
Replace.WORLD.with(world.getName())))
.onFailure(throwable -> issuer.sendError(MVCorei18n.META_MODIFY_REMOVE_FAILURE,
replace("{key}").with(key),
Replace.WORLD.with(world.getName()),
Replace.ERROR.with(throwable)));
default -> issuer.sendError(MVCorei18n.META_MODIFY_INVALIDACTION, replace("{action}").with(action));
}
worldManager.saveWorldsConfig();
}
}
@@ -2,6 +2,7 @@ package org.mvplugins.multiverse.core.commands;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import co.aikar.commands.ACFUtil;
import co.aikar.commands.annotation.CommandAlias;
@@ -23,9 +24,12 @@ import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.CommandValueFlag;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags;
import org.mvplugins.multiverse.core.command.queue.CommandQueueManager;
import org.mvplugins.multiverse.core.command.queue.CommandQueuePayload;
import org.mvplugins.multiverse.core.destination.DestinationInstance;
import org.mvplugins.multiverse.core.destination.DestinationsProvider;
import org.mvplugins.multiverse.core.destination.core.WorldDestination;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.Message;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
@@ -65,7 +69,8 @@ class RegenCommand extends CoreCommand {
@Subcommand("regen")
@CommandPermission("multiverse.core.regen")
@CommandCompletion("@mvworlds:scope=loaded @flags:groupName=" + Flags.NAME)
@Syntax("<world> [--seed [seed] --reset-world-config --reset-gamerules --reset-world-border --remove-players]")
@Syntax("<world> [--seed [seed]] [--reset-world-config] [--reset-gamerules] [--reset-world-border] " +
"[--remove-players [destination]]")
@Description("{@@mv-core.regen.description}")
void onRegenCommand(
MVCommandIssuer issuer,
@@ -75,7 +80,8 @@ class RegenCommand extends CoreCommand {
LoadedMultiverseWorld world,
@Optional
@Syntax("[--seed [seed] --reset-world-config --reset-gamerules --reset-world-border --remove-players]")
@Syntax("[--seed [seed]] [--reset-world-config] [--reset-gamerules] [--reset-world-border] " +
"[--remove-players [destination]]")
@Description("{@@mv-core.regen.other.description}")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
@@ -91,8 +97,9 @@ class RegenCommand extends CoreCommand {
issuer.sendInfo(MVCorei18n.REGEN_REGENERATING, Replace.WORLD.with(world.getName()));
List<Player> worldPlayers = world.getPlayers().getOrElse(Collections.emptyList());
var future = parsedFlags.hasFlag(flags.removePlayers)
? playerWorldTeleporter.removeFromWorld(world)
DestinationInstance<?, ?> removeToDestination = parsedFlags.flagValue(flags.removePlayers);
var future = Objects.nonNull(removeToDestination)
? playerWorldTeleporter.transferAllFromWorldToDestination(world, removeToDestination)
: AsyncAttemptsAggregate.emptySuccess();
// todo: using future will hide stacktrace
@@ -128,13 +135,18 @@ class RegenCommand extends CoreCommand {
}
@Service
private static final class Flags extends RemovePlayerFlags {
private static final class Flags extends RemovePlayerDestinationFlags {
private static final String NAME = "mvregen";
@Inject
private Flags(@NotNull CommandFlagsManager flagsManager) {
super(NAME, flagsManager);
private Flags(
@NotNull CommandFlagsManager flagsManager,
@NotNull WorldManager worldManager,
@NotNull DestinationsProvider destinationsProvider,
@NotNull WorldDestination worldDestination
) {
super(NAME, flagsManager, worldManager, destinationsProvider, worldDestination);
}
private final CommandValueFlag<String> seed = flag(CommandValueFlag.builder("--seed", String.class)
@@ -14,11 +14,13 @@ import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.LegacyAliasCommand;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.MVCommandManager;
import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags;
import org.mvplugins.multiverse.core.destination.DestinationInstance;
import org.mvplugins.multiverse.core.destination.DestinationsProvider;
import org.mvplugins.multiverse.core.destination.core.WorldDestination;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.utils.result.AsyncAttemptsAggregate;
@@ -27,6 +29,8 @@ import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.helpers.PlayerWorldTeleporter;
import org.mvplugins.multiverse.core.world.options.RemoveWorldOptions;
import java.util.Objects;
@Service
class RemoveCommand extends CoreCommand {
@@ -48,7 +52,7 @@ class RemoveCommand extends CoreCommand {
@Subcommand("remove")
@CommandPermission("multiverse.core.remove")
@CommandCompletion("@mvworlds:scope=both @flags:groupName=" + Flags.NAME)
@Syntax("<world>")
@Syntax("<world> [--remove-players [destination]] [--no-unload-bukkit-world] [--no-save]")
@Description("{@@mv-core.remove.description}")
void onRemoveCommand(
MVCommandIssuer issuer,
@@ -58,13 +62,16 @@ class RemoveCommand extends CoreCommand {
MultiverseWorld world,
@Optional
@Syntax("[--remove-players]")
@Syntax("[--remove-players [destination]] [--no-unload-bukkit-world] [--no-save]")
@Description("")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
var future = parsedFlags.hasFlag(flags.removePlayers)
? worldManager.getLoadedWorld(world).map(playerWorldTeleporter::removeFromWorld).getOrElse(AsyncAttemptsAggregate::emptySuccess)
DestinationInstance<?, ?> removeToDestination = parsedFlags.flagValue(flags.removePlayers);
var future = Objects.nonNull(removeToDestination)
? world.asLoadedWorld()
.map(loadedWorld -> playerWorldTeleporter.transferAllFromWorldToDestination(loadedWorld, removeToDestination))
.getOrElse(AsyncAttemptsAggregate::emptySuccess)
: AsyncAttemptsAggregate.emptySuccess();
future.onSuccess(() -> doWorldRemoving(issuer, world, parsedFlags))
@@ -85,13 +92,18 @@ class RemoveCommand extends CoreCommand {
}
@Service
private static final class Flags extends RemovePlayerFlags {
private static final class Flags extends RemovePlayerDestinationFlags {
private static final String NAME = "mvremove";
@Inject
private Flags(@NotNull CommandFlagsManager flagsManager) {
super(NAME, flagsManager);
private Flags(
@NotNull CommandFlagsManager flagsManager,
@NotNull WorldManager worldManager,
@NotNull DestinationsProvider destinationsProvider,
@NotNull WorldDestination worldDestination
) {
super(NAME, flagsManager, worldManager, destinationsProvider, worldDestination);
}
private final CommandFlag noUnloadBukkitWorld = flag(CommandFlag.builder("--no-unload-bukkit-world")
@@ -14,11 +14,13 @@ import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.LegacyAliasCommand;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.command.MVCommandManager;
import org.mvplugins.multiverse.core.command.flag.CommandFlag;
import org.mvplugins.multiverse.core.command.flag.CommandFlagsManager;
import org.mvplugins.multiverse.core.command.flag.ParsedCommandFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerFlags;
import org.mvplugins.multiverse.core.command.flags.RemovePlayerDestinationFlags;
import org.mvplugins.multiverse.core.destination.DestinationInstance;
import org.mvplugins.multiverse.core.destination.DestinationsProvider;
import org.mvplugins.multiverse.core.destination.core.WorldDestination;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.utils.result.AsyncAttemptsAggregate;
@@ -27,6 +29,8 @@ import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.helpers.PlayerWorldTeleporter;
import org.mvplugins.multiverse.core.world.options.UnloadWorldOptions;
import java.util.Objects;
@Service
class UnloadCommand extends CoreCommand {
@@ -48,7 +52,7 @@ class UnloadCommand extends CoreCommand {
@Subcommand("unload")
@CommandPermission("multiverse.core.unload")
@CommandCompletion("@mvworlds @flags:groupName=" + Flags.NAME)
@Syntax("<world>")
@Syntax("<world> [--remove-players [destination]] [--no-save]")
@Description("{@@mv-core.unload.description}")
void onUnloadCommand(
MVCommandIssuer issuer,
@@ -58,15 +62,16 @@ class UnloadCommand extends CoreCommand {
LoadedMultiverseWorld world,
@Optional
@Syntax("[--remove-players] [--no-save]")
@Syntax("[--remove-players [destination]] [--no-save]")
@Description("{@@mv-core.gamerules.description.page}")
String[] flagArray) {
ParsedCommandFlags parsedFlags = flags.parse(flagArray);
issuer.sendInfo(MVCorei18n.UNLOAD_UNLOADING, Replace.WORLD.with(world.getAliasOrName()));
var future = parsedFlags.hasFlag(flags.removePlayers)
? playerWorldTeleporter.removeFromWorld(world)
DestinationInstance<?, ?> removeToDestination = parsedFlags.flagValue(flags.removePlayers);
var future = Objects.nonNull(removeToDestination)
? playerWorldTeleporter.transferAllFromWorldToDestination(world, removeToDestination)
: AsyncAttemptsAggregate.emptySuccess();
future.onSuccess(() -> doWorldUnloading(issuer, world, parsedFlags))
@@ -88,13 +93,18 @@ class UnloadCommand extends CoreCommand {
}
@Service
private static final class Flags extends RemovePlayerFlags {
private static final class Flags extends RemovePlayerDestinationFlags {
private static final String NAME = "mvunload";
@Inject
private Flags(@NotNull CommandFlagsManager flagsManager) {
super(NAME, flagsManager);
private Flags(
@NotNull CommandFlagsManager flagsManager,
@NotNull WorldManager worldManager,
@NotNull DestinationsProvider destinationsProvider,
@NotNull WorldDestination worldDestination
) {
super(NAME, flagsManager, worldManager, destinationsProvider, worldDestination);
}
private final CommandFlag noUnloadBukkitWorld = flag(CommandFlag.builder("--no-unload-bukkit-world")
@@ -12,8 +12,11 @@ import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.MVCommandIssuer;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.utils.compatibility.WorldBorderCompatibility;
import org.mvplugins.multiverse.core.utils.tick.TickDuration;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import java.time.temporal.ChronoUnit;
import java.util.function.Consumer;
import static org.mvplugins.multiverse.core.locale.message.MessageReplacement.replace;
@@ -33,15 +36,14 @@ final class WorldBorderCommand extends CoreCommand {
@Optional
@Default("0")
@Syntax("[time]")
int time,
TickDuration duration,
@Flags("resolve=issuerAware,maxArgForAware=0")
@Syntax("[world]")
LoadedMultiverseWorld world
) {
worldBorderAction(issuer, world, worldBorder -> {
onWorldBorderSet(issuer, worldBorder.getSize() + size, time, world);
});
worldBorderAction(issuer, world, worldBorder ->
onWorldBorderSet(issuer, worldBorder.getSize() + size, duration, world));
}
@Subcommand("center")
@@ -145,7 +147,7 @@ final class WorldBorderCommand extends CoreCommand {
@Optional
@Default("0")
@Syntax("[time]")
int time,
TickDuration duration,
@Flags("resolve=issuerAware,maxArgForAware=0")
@Syntax("[world]")
@@ -158,15 +160,18 @@ final class WorldBorderCommand extends CoreCommand {
Replace.WORLD.with(world.getAliasOrName()));
return;
}
worldBorder.setSize(size, time);
if (time <= 0) {
if (!WorldBorderCompatibility.supportsChangeSizeInTicks() && !duration.isExactTo(ChronoUnit.SECONDS)) {
setRoundOffError(issuer, duration);
}
WorldBorderCompatibility.changeSizeDuration(worldBorder, size, duration);
if (duration.toTicks() <= 0) {
issuer.sendMessage(MVCorei18n.WORLDBORDER_SET_IMMEDIATE,
replace("{size}").with(worldBorder.getSize()),
Replace.WORLD.with(world.getAliasOrName()));
} else {
issuer.sendMessage(previousSize > size ? MVCorei18n.WORLDBORDER_SET_GROWING : MVCorei18n.WORLDBORDER_SET_SHRINKING,
replace("{size}").with(size),
replace("{time}").with(time),
replace("{time}").with(String.format("%.2f", duration.toSeconds())),
Replace.WORLD.with(world.getAliasOrName()));
}
});
@@ -201,25 +206,34 @@ final class WorldBorderCommand extends CoreCommand {
MVCommandIssuer issuer,
@Syntax("<time>")
int time,
TickDuration duration,
@Flags("resolve=issuerAware,maxArgForAware=0")
@Syntax("[world]")
LoadedMultiverseWorld world
) {
worldBorderAction(issuer, world, worldBorder -> {
if (worldBorder.getWarningTime() == time) {
if (WorldBorderCompatibility.getWarningTimeTicks(worldBorder) == duration.toTicks()) {
issuer.sendMessage(MVCorei18n.WORLDBORDER_WARNINGTIME_NOTHINGCHANGED,
Replace.WORLD.with(world.getAliasOrName()));
return;
}
worldBorder.setWarningTime(time);
if (!WorldBorderCompatibility.supportsSetWarningTimeInTicks() && !duration.isExactTo(ChronoUnit.SECONDS)) {
setRoundOffError(issuer, duration);
}
WorldBorderCompatibility.setWarningTimeDuration(worldBorder, duration);
issuer.sendMessage(MVCorei18n.WORLDBORDER_WARNINGTIME_SUCCESS,
replace("{time}").with(worldBorder.getWarningTime()),
replace("{time}").with(String.format("%.2f", WorldBorderCompatibility.getWarningTime(worldBorder))),
Replace.WORLD.with(world.getAliasOrName()));
});
}
private void setRoundOffError(MVCommandIssuer issuer, TickDuration duration) {
issuer.sendError(MVCorei18n.WORLDBORDER_ROUNDOFF_WARNING,
replace("{fromTime}").with(String.format("%.2f", duration.toSeconds())),
replace("{toTime}").with(Math.round(duration.toSeconds())));
}
private void worldBorderAction(MVCommandIssuer issuer, LoadedMultiverseWorld world, Consumer<WorldBorder> worldBorderAction) {
Try.run(() -> world.getWorldBorder().peek(worldBorderAction))
.onFailure(error -> issuer.sendError(error.getLocalizedMessage()));
@@ -186,6 +186,16 @@ public final class CoreConfig {
return configHandle.get(configNodes.enforceFlight);
}
@ApiStatus.AvailableSince("5.5")
public Try<Void> setGamemodeAndFlightEnforceDelay(int delayTicks) {
return configHandle.set(configNodes.gamemodeAndFlightEnforceDelay, delayTicks);
}
@ApiStatus.AvailableSince("5.5")
public int getGamemodeAndFlightEnforceDelay() {
return configHandle.get(configNodes.gamemodeAndFlightEnforceDelay);
}
@ApiStatus.AvailableSince("5.3")
public Try<Void> setApplyEntitySpawnRate(boolean applyEntitySpawnRate) {
return configHandle.set(configNodes.applyEntitySpawnRate, applyEntitySpawnRate);
@@ -493,6 +503,26 @@ public final class CoreConfig {
return configHandle.get(configNodes.registerPapiHook);
}
@ApiStatus.AvailableSince("5.5")
public Try<Void> setWarnInvalidPapiFormat(boolean warnInvalidPapiFormat) {
return configHandle.set(configNodes.warnInvalidPapiFormat, warnInvalidPapiFormat);
}
@ApiStatus.AvailableSince("5.5")
public boolean getWarnInvalidPapiFormat() {
return configHandle.get(configNodes.warnInvalidPapiFormat);
}
@ApiStatus.AvailableSince("5.5")
public Try<Void> setInvalidPapiFormatReturnsBlank(boolean invalidPapiFormatReturnsBlank) {
return configHandle.set(configNodes.invalidPapiFormatReturnsBlank, invalidPapiFormatReturnsBlank);
}
@ApiStatus.AvailableSince("5.5")
public boolean getInvalidPapiFormatReturnsBlank() {
return configHandle.get(configNodes.invalidPapiFormatReturnsBlank);
}
/**
* {@inheritDoc}
*/
@@ -621,6 +651,16 @@ public final class CoreConfig {
return configHandle.get(configNodes.eventPriorityPlayerTeleport);
}
@ApiStatus.AvailableSince("5.5")
public Try<Void> setEventPriorityPlayerWorldChange(EventPriority eventPriorityPlayerWorldChange) {
return configHandle.set(configNodes.eventPriorityPlayerWorldChange, eventPriorityPlayerWorldChange);
}
@ApiStatus.AvailableSince("5.5")
public EventPriority getEventPriorityPlayerWorldChange() {
return configHandle.get(configNodes.eventPriorityPlayerWorldChange);
}
public Try<Void> setBukkitYmlPath(String bukkitYmlPath) {
return configHandle.set(configNodes.bukkitYmlPath, bukkitYmlPath);
}
@@ -135,6 +135,16 @@ final class CoreConfigNodes {
.name("enforce-flight")
.build());
final ConfigNode<Integer> gamemodeAndFlightEnforceDelay = node(ConfigNode.builder("world.gamemode-and-flight-enforce-delay", Integer.class)
.comment("")
.comment("Sets the delay in ticks before Multiverse enforces gamemode and flight ability on world change.")
.comment("Increase this value if you are experiencing issues with other plugins overriding gamemode or flight ability.")
.comment("Or set to 0 to enforce immediately during world change event.")
.defaultValue(1)
.name("gamemode-and-flight-enforce-delay")
.suggester((sender -> List.of("0", "1", "2", "5", "10")))
.build());
final ConfigNode<Boolean> applyEntitySpawnRate = node(ConfigNode.builder("world.apply-entity-spawn-rate", Boolean.class)
.comment("")
.comment("Sets whether Multiverse will apply the world's entity `tick-rate` config in worlds.yml.")
@@ -210,7 +220,10 @@ final class CoreConfigNodes {
final ConfigNode<PassengerModes> passengerMode = node(ConfigNode.builder("teleport.passenger-mode", PassengerModes.class)
.comment("")
.comment("Configures how passengers and vehicles are handled when an entity is teleported.")
.comment("Configures how passengers and vehicles are handled when an entity is teleported. This config is")
.comment("only applied to /mvtp, join-destination and first-spawn-override. It will not touch any other")
.comment("teleport by other plugins or commands.")
.comment("========= Available options: =========")
.comment(" default: Server will handle passengers and vehicles. On papermc 1.21.10+, all will be retained by default.")
.comment(" On older versions, all will be dismounted if world changes.")
.comment(" dismount_passengers: Passengers will be removed from the parent entity before the teleport.")
@@ -390,6 +403,23 @@ final class CoreConfigNodes {
.name("register-papi-hook")
.build());
final ConfigNode<Boolean> warnInvalidPapiFormat = node(ConfigNode.builder("messaging.warn-invalid-papi-format", Boolean.class)
.comment("")
.comment("This config option defines whether or not Multiverse should warn about invalid PlaceholderAPI formats.")
.comment("Only applies if PlaceholderAPI is installed, and only to multiverse placeholders.")
.defaultValue(true)
.name("warn-invalid-papi-format")
.build());
final ConfigNode<Boolean> invalidPapiFormatReturnsBlank = node(ConfigNode.builder("messaging.invalid-papi-format-returns-blank", Boolean.class)
.comment("")
.comment("By default, invalid formats will return the original placeholder string.")
.comment("This config option defines whether or not Multiverse should show invalid PlaceholderAPI formats as blank.")
.comment("Only applies if PlaceholderAPI is installed, and only to multiverse placeholders.")
.defaultValue(false)
.name("invalid-papi-format-returns-blank")
.build());
final ConfigNode<Locale> defaultLocale = node(ConfigNode.builder("messaging.default-locale", Locale.class)
.comment("")
.comment("This config option defines the default language Multiverse should use.")
@@ -528,6 +558,18 @@ final class CoreConfigNodes {
"full effect after a server restart."))
.build());
final ConfigNode<EventPriority> eventPriorityPlayerWorldChange = node(ConfigNode.builder("event-priority.player-changed-world", EventPriority.class)
.defaultValue(EventPriority.NORMAL)
.name("event-priority-player-changed-world")
.comment("")
.comment("This config option defines the priority for the PlayerChangedWorldEvent.")
.onLoadAndChange((oldValue, newValue) ->
eventPriorityMapper.get().setPriority("mvcore-player-changed-world", newValue))
.onChange((sender, oldValue, newValue) ->
sender.sendMessage(ChatColor.YELLOW + "'event-priority.player-changed-world' config option will only take " +
"full effect after a server restart."))
.build());
private final ConfigHeaderNode miscHeader = node(ConfigHeaderNode.builder("misc")
.comment("")
.comment("")
@@ -3,11 +3,9 @@ package org.mvplugins.multiverse.core.config.handle;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.logging.Logger;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
import io.vavr.control.Try;
import org.bukkit.Bukkit;
import org.bukkit.command.CommandSender;
@@ -19,6 +17,7 @@ import org.jetbrains.annotations.Nullable;
import org.mvplugins.multiverse.core.config.migration.ConfigMigrator;
import org.mvplugins.multiverse.core.config.node.ListValueNode;
import org.mvplugins.multiverse.core.config.node.MapValueNode;
import org.mvplugins.multiverse.core.config.node.NodeGroup;
import org.mvplugins.multiverse.core.config.node.ValueNode;
@@ -93,17 +92,13 @@ public abstract class BaseConfigurationHandle<C extends ConfigurationSection> {
}
protected <T> T deserializeNodeFromConfig(ValueNode<T> node) {
if (node.getSerializer() == null) {
return Option.of(config.getObject(node.getPath(), node.getType())).getOrElse(node::getDefaultValue);
}
return Try.of(() -> {
var value = config.get(node.getPath());
if (value == null) {
return node.getDefaultValue();
}
return node.getSerializer().deserialize(value, node.getType());
}).flatMap(value -> node.validate(value).map(ignore -> value))
.onFailure(e -> Logging.warning("Failed to deserialize node %s: %s", node.getPath(), e.getMessage()))
return value == null ? node.getDefaultValue() : node.deserialize(value);
})
.flatMap(value -> node.validate(value).map(ignore -> value))
.onFailure(e -> Logging.warning("Failed to deserialize node %s: %s",
node.getPath(), e.getMessage()))
.getOrElse(node::getDefaultValue);
}
@@ -124,12 +119,7 @@ public abstract class BaseConfigurationHandle<C extends ConfigurationSection> {
if (value == null) {
value = node.getDefaultValue();
}
if (node.getSerializer() != null) {
var serialized = node.getSerializer().serialize(value, node.getType());
config.set(node.getPath(), serialized);
} else {
config.set(node.getPath(), value);
}
config.set(node.getPath(), node.serialize(value));
}
/**
@@ -163,6 +153,15 @@ public abstract class BaseConfigurationHandle<C extends ConfigurationSection> {
return set(Bukkit.getConsoleSender(), node, value);
}
public <K, V> Try<Void> set(@NotNull MapValueNode<K, V> node, K key, V value) {
return node.validateEntry(key, value).map(ignore -> {
Map<K,V> map = get(node);
map.put(key, value);
// node.onSetEntryValue(null, key, null, value);
return null;
});
}
/**
* Sets the value of a node, if the validator is not null, it will be tested first.
*
@@ -221,6 +220,18 @@ public abstract class BaseConfigurationHandle<C extends ConfigurationSection> {
});
}
public <K, V> Try<Void> remove(@NotNull MapValueNode<K, V> node, K key) {
return node.validateKey(key).map(ignore -> {
Map<K,V> map = get(node);
V value = map.remove(key);
if (value == null) {
throw new IllegalArgumentException("Cannot remove entry as it is already not in the map!");
}
// node.onSetEntryValue(key, value, null);
return null;
});
}
/**
* Sets the default value of a node.
*
@@ -112,10 +112,5 @@ public abstract class FileConfigurationHandle<C extends FileConfiguration> exten
* @return The configuration handle.
*/
public abstract @NotNull FileConfigurationHandle<C> build();
@SuppressWarnings("unchecked")
protected B self() {
return (B) this;
}
}
}
@@ -180,6 +180,24 @@ public class ConfigNode<T> extends ConfigHeaderNode implements ValueNode<T> {
return serializer;
}
/**
* {@inheritDoc}
*/
@Override
public T deserialize(@Nullable Object object) {
return serializer == null
? (type.isInstance(object)) ? type.cast(object) : getDefaultValue()
: serializer.deserialize(object, type);
}
/**
* {@inheritDoc}
*/
@Override
public Object serialize(T value) {
return serializer == null ? value : serializer.serialize(value, type);
}
/**
* {@inheritDoc}
*/
@@ -120,9 +120,9 @@ public class ListConfigNode<I> extends ConfigNode<List<I>> implements ListValueN
private void setDefaultSuggester() {
if (itemSuggester instanceof SenderNodeSuggester senderItemSuggester) {
this.suggester = (SenderNodeSuggester)(sender, input) ->
StringFormatter.addonToCommaSeperated(input, senderItemSuggester.suggest(sender, input));
StringFormatter.addOnToCommaSeparated(input, senderItemSuggester.suggest(sender, input));
} else {
this.suggester = input -> StringFormatter.addonToCommaSeperated(input, itemSuggester.suggest(input));
this.suggester = input -> StringFormatter.addOnToCommaSeparated(input, itemSuggester.suggest(input));
}
}
@@ -283,7 +283,7 @@ public class ListConfigNode<I> extends ConfigNode<List<I>> implements ListValueN
//noinspection unchecked
super(path, (Class<List<I>>) (Object) List.class);
this.itemType = itemType;
this.defaultValue = () -> (List<I>) new ArrayList<>();
this.defaultValue = ArrayList::new;
}
/**
@@ -0,0 +1,603 @@
package org.mvplugins.multiverse.core.config.node;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Try;
import org.bukkit.command.CommandSender;
import org.bukkit.configuration.ConfigurationSection;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.mvplugins.multiverse.core.config.node.functions.DefaultStringParserProvider;
import org.mvplugins.multiverse.core.config.node.functions.DefaultSuggesterProvider;
import org.mvplugins.multiverse.core.config.node.functions.NodeChangeCallback;
import org.mvplugins.multiverse.core.config.node.functions.NodeStringParser;
import org.mvplugins.multiverse.core.config.node.functions.NodeSuggester;
import org.mvplugins.multiverse.core.config.node.functions.NodeValueCallback;
import org.mvplugins.multiverse.core.config.node.functions.SenderNodeStringParser;
import org.mvplugins.multiverse.core.config.node.functions.SenderNodeSuggester;
import org.mvplugins.multiverse.core.config.node.serializer.DefaultSerializerProvider;
import org.mvplugins.multiverse.core.config.node.serializer.NodeSerializer;
import org.mvplugins.multiverse.core.exceptions.MultiverseException;
import org.mvplugins.multiverse.core.utils.REPatterns;
import java.util.Collection;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Objects;
import java.util.function.Function;
import java.util.function.Supplier;
/**
* A config node that contains key-value mappings.
*
* @param <K> The key type.
* @param <V> The value type.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public class MapConfigNode<K, V> extends ConfigNode<Map<K, V>> implements MapValueNode<K, V> {
/**
* Creates a new builder for a {@link MapConfigNode}.
*
* @param path The path of the node.
* @param keyType The map key type.
* @param valueType The map value type.
* @param <K> The key type.
* @param <V> The value type.
* @return The new builder.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static @NotNull <K, V> Builder<K, V> mapBuilder(
@NotNull String path,
@NotNull Class<K> keyType,
@NotNull Class<V> valueType) {
return new Builder<>(path, keyType, valueType);
}
protected final @NotNull Class<K> keyType;
protected final @NotNull Class<V> valueType;
protected final @Nullable NodeSuggester keySuggester;
protected final @Nullable NodeSuggester valueSuggester;
protected final @Nullable NodeStringParser<K> keyStringParser;
protected final @Nullable NodeStringParser<V> valueStringParser;
protected final @Nullable NodeSerializer<K> keySerializer;
protected final @Nullable NodeSerializer<V> valueSerializer;
protected final @Nullable Function<K, Try<Void>> keyValidator;
protected final @Nullable Function<V, Try<Void>> valueValidator;
protected MapConfigNode(
@NotNull String path,
@NotNull String[] comments,
@Nullable String name,
@NotNull Class<Map<K, V>> type,
@NotNull String[] aliases,
@Nullable Supplier<Map<K, V>> defaultValueSupplier,
@Nullable NodeSuggester suggester,
@Nullable NodeStringParser<Map<K, V>> stringParser,
@Nullable NodeSerializer<Map<K, V>> serializer,
@Nullable Function<Map<K, V>, Try<Void>> validator,
@Nullable NodeValueCallback<Map<K, V>> onLoad,
@Nullable NodeChangeCallback<Map<K, V>> onLoadAndChange,
@Nullable NodeChangeCallback<Map<K, V>> onChange,
@NotNull Class<K> keyType,
@NotNull Class<V> valueType,
@Nullable NodeSuggester keySuggester,
@Nullable NodeSuggester valueSuggester,
@Nullable NodeStringParser<K> keyStringParser,
@Nullable NodeStringParser<V> valueStringParser,
@Nullable NodeSerializer<K> keySerializer,
@Nullable NodeSerializer<V> valueSerializer,
@Nullable Function<K, Try<Void>> keyValidator,
@Nullable Function<V, Try<Void>> valueValidator) {
super(path, comments, name, type, aliases, defaultValueSupplier, suggester, stringParser, serializer,
validator, onLoad, onLoadAndChange, onChange);
this.keyType = keyType;
this.valueType = valueType;
this.keySuggester = keySuggester != null
? keySuggester
: DefaultSuggesterProvider.getDefaultSuggester(keyType);
this.valueSuggester = valueSuggester != null
? valueSuggester
: DefaultSuggesterProvider.getDefaultSuggester(valueType);
this.keyStringParser = keyStringParser != null
? keyStringParser
: DefaultStringParserProvider.getDefaultStringParser(keyType);
this.valueStringParser = valueStringParser != null
? valueStringParser
: DefaultStringParserProvider.getDefaultStringParser(valueType);
this.keySerializer = keySerializer != null
? keySerializer
: DefaultSerializerProvider.getDefaultSerializer(keyType);
this.valueSerializer = valueSerializer != null
? valueSerializer
: DefaultSerializerProvider.getDefaultSerializer(valueType);
this.keyValidator = keyValidator != null
? keyValidator
: defaultYamlKeyValidator();
this.valueValidator = valueValidator;
setDefaults();
}
private Function<K, Try<Void>> defaultYamlKeyValidator() {
return key -> Try.of(() -> {
if (!REPatterns.YAML_KEY.matcher(String.valueOf(serializeKey(key))).matches()) {
throw new MultiverseException("Invalid yaml key: '" + key + "'. Keys can only " +
"contain alphanumeric characters, underscores and hyphens.");
}
return null;
});
}
private void setDefaults() {
if ((this.keySuggester != null || this.valueSuggester != null) && this.suggester == null) {
setDefaultSuggester();
}
if (this.keyStringParser != null && this.valueStringParser != null && this.stringParser == null) {
setDefaultStringParser();
}
if ((this.keyValidator != null || this.valueValidator != null) && this.validator == null) {
setDefaultValidator();
}
if ((this.keySerializer != null || this.valueSerializer != null) && this.serializer == null) {
setDefaultSerializer();
}
if (this.defaultValue == null) {
this.defaultValue = LinkedHashMap::new;
}
}
private void setDefaultSuggester() {
if (this.keySuggester instanceof SenderNodeSuggester || this.valueSuggester instanceof SenderNodeSuggester) {
this.suggester = (SenderNodeSuggester) this::suggestEntries;
} else {
this.suggester = input -> suggestEntries(null, input);
}
}
private @NotNull Collection<String> suggestEntries(@Nullable CommandSender sender, @Nullable String input) {
String content = input == null ? "" : input;
int lastComma = content.lastIndexOf(',');
String prefix = lastComma == -1 ? "" : content.substring(0, lastComma + 1);
String currentEntry = lastComma == -1 ? content : content.substring(lastComma + 1);
String[] keyValue = REPatterns.EQUALS.split(currentEntry, 2);
if (keyValue.length == 2 && valueSuggester != null) {
String keyPart = keyValue[0];
String valuePart = keyValue[1];
return suggestFrom(valueSuggester, sender, valuePart).stream()
.map(suggestedValue -> prefix + keyPart + "=" + suggestedValue)
.toList();
}
if (keySuggester == null) {
return Collections.emptyList();
}
return suggestFrom(keySuggester, sender, currentEntry).stream()
.map(suggestedKey -> prefix + suggestedKey + "=")
.toList();
}
private @NotNull Collection<String> suggestFrom(
@NotNull NodeSuggester suggester,
@Nullable CommandSender sender,
@Nullable String input) {
if (sender != null && suggester instanceof SenderNodeSuggester senderSuggester) {
return senderSuggester.suggest(sender, input);
}
return suggester.suggest(input);
}
private void setDefaultStringParser() {
this.stringParser = (input, type) -> Try.of(() -> {
Map<K, V> parsed = new LinkedHashMap<>();
if (input == null || input.isBlank()) {
return parsed;
}
for (String entry : REPatterns.SEMICOLON.split(input)) {
String[] keyValue = REPatterns.EQUALS.split(entry, 2);
if (keyValue.length != 2) {
throw new IllegalArgumentException("Invalid map entry '" + entry + "'. Expected format key=value");
}
NodeStringParser<K> keyParser = Objects.requireNonNull(keyStringParser, "keyStringParser");
NodeStringParser<V> valueParser = Objects.requireNonNull(valueStringParser, "valueStringParser");
K parsedKey = keyParser.parse(keyValue[0].trim(), keyType).get();
V parsedValue = valueParser.parse(keyValue[1].trim(), valueType).get();
parsed.put(parsedKey, parsedValue);
}
return parsed;
});
}
private void setDefaultValidator() {
this.validator = value -> Try.of(() -> {
if (value == null) {
return null;
}
for (Map.Entry<K, V> entry : value.entrySet()) {
if (keyValidator != null) {
keyValidator.apply(entry.getKey()).get();
}
if (valueValidator != null) {
valueValidator.apply(entry.getValue()).get();
}
}
return null;
});
}
private void setDefaultSerializer() {
this.serializer = new NodeSerializer<>() {
@Override
public Map<K, V> deserialize(Object object, Class<Map<K, V>> type) {
if (object == null) {
return new LinkedHashMap<>();
}
if (object instanceof Map<?, ?> rawMap) {
Map<K, V> result = new LinkedHashMap<>();
rawMap.forEach((key, value) -> {
Map.Entry<K, V> entry = deserializeEntry(key, value);
result.put(entry.getKey(), entry.getValue());
});
return result;
}
if (object instanceof ConfigurationSection section) {
Map<K, V> result = new LinkedHashMap<>();
for (String key : section.getKeys(false)) {
Object rawValue = section.get(key);
Map.Entry<K, V> entry = deserializeEntry(key, rawValue);
result.put(entry.getKey(), entry.getValue());
}
return result;
}
throw new IllegalArgumentException("Invalid map object '" + object + "'. Expected map or section.");
}
@Override
public Object serialize(Map<K, V> object, Class<Map<K, V>> type) {
Map<Object, Object> serialized = new LinkedHashMap<>();
if (object == null) {
return serialized;
}
object.forEach((key, value) -> {
Map.Entry<Object, Object> entry = serializeEntry(key, value);
serialized.put(entry.getKey(), entry.getValue());
});
return serialized;
}
};
}
/**
* {@inheritDoc}
*/
@Override
public @NotNull Try<Void> validateKey(@Nullable K key) {
return keyValidator == null ? Try.success(null) : keyValidator.apply(key);
}
/**
* {@inheritDoc}
*/
@Override
public @NotNull Try<Void> validateValue(@Nullable V value) {
return valueValidator == null ? Try.success(null) : valueValidator.apply(value);
}
/**
* {@inheritDoc}
*/
@Override
public @NotNull Try<Void> validateEntry(@Nullable K key, @Nullable V value) {
return validateKey(key).flatMap(ignored -> validateValue(value));
}
/**
* {@inheritDoc}
*/
@Override
public @NotNull Map.Entry<K, V> deserializeEntry(@Nullable Object key, @Nullable Object value) {
K deserializedKey = keySerializer == null
? keyType.isInstance(key) ? keyType.cast(key) : null
: keySerializer.deserialize(key, keyType);
V deserializedValue = valueSerializer == null
? keyType.isInstance(value) ? valueType.cast(value) : null
: valueSerializer.deserialize(value, valueType);
return new SimpleEntry<>(deserializedKey, deserializedValue);
}
/**
* {@inheritDoc}
*/
@Override
public @NotNull Map.Entry<Object, Object> serializeEntry(@Nullable K key, @Nullable V value) {
return new SimpleEntry<>(serializeKey(key), serializeValue(value));
}
private @Nullable Object serializeKey(@Nullable K key) {
return keySerializer == null ? key : keySerializer.serialize(key, keyType);
}
private @Nullable Object serializeValue(@Nullable V value) {
return valueSerializer == null ? value : valueSerializer.serialize(value, valueType);
}
private record SimpleEntry<K, V>(K key, V value) implements Map.Entry<K, V> {
@Override
public K getKey() {
return key;
}
@Override
public V getValue() {
return value;
}
@Override
public V setValue(V value) {
throw new UnsupportedOperationException();
}
}
/**
* Builder for {@link MapConfigNode} instances.
*
* <p>This builder extends {@link ConfigNode.Builder} with additional configuration specific to map entries:
* entry-level suggesters, string parsers, serializers and validators for both keys and values. When any
* entry-level component is omitted the builder provides a sensible default (for example default parsers,
* suggesters, serializers or an empty map default value).
*
* @param <K> The map key type.
* @param <V> The map value type.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static class Builder<K, V> extends ConfigNode.Builder<Map<K, V>, Builder<K, V>> {
protected final @NotNull Class<K> keyType;
protected final @NotNull Class<V> valueType;
protected @Nullable NodeSuggester keySuggester;
protected @Nullable NodeSuggester valueSuggester;
protected @Nullable NodeStringParser<K> keyStringParser;
protected @Nullable NodeStringParser<V> valueStringParser;
protected @Nullable NodeSerializer<K> keySerializer;
protected @Nullable NodeSerializer<V> valueSerializer;
protected @Nullable Function<K, Try<Void>> keyValidator;
protected @Nullable Function<V, Try<Void>> valueValidator;
/**
* Creates a new builder for a map config node.
*
* @param path The configuration path for the node.
* @param keyType The runtime type of the map keys.
* @param valueType The runtime type of the map values.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
protected Builder(@NotNull String path, @NotNull Class<K> keyType, @NotNull Class<V> valueType) {
//noinspection unchecked
super(path, (Class<Map<K, V>>) (Object) Map.class);
this.keyType = keyType;
this.valueType = valueType;
this.defaultValue = LinkedHashMap::new;
}
/**
* Sets a suggester for map keys. The suggester is used when computing tab-completion suggestions for the
* key part of an entry.
*
* @param keySuggester The suggester to use for keys.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> keySuggester(@NotNull NodeSuggester keySuggester) {
this.keySuggester = keySuggester;
return self();
}
/**
* Sets a sender-aware suggester for map keys. Use this when suggestions depend on the command sender
* (permissions, location, etc.).
*
* @param keySuggester The sender-aware suggester.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> keySuggester(@NotNull SenderNodeSuggester keySuggester) {
this.keySuggester = keySuggester;
return self();
}
/**
* Sets a suggester for map values. The suggester is used when computing tab-completion suggestions for the
* value part of an entry.
*
* @param valueSuggester The suggester to use for values.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> valueSuggester(@NotNull NodeSuggester valueSuggester) {
this.valueSuggester = valueSuggester;
return self();
}
/**
* Sets a sender-aware suggester for map values. Use this when value suggestions depend on the
* command sender.
*
* @param valueSuggester The sender-aware suggester.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> valueSuggester(@NotNull SenderNodeSuggester valueSuggester) {
this.valueSuggester = valueSuggester;
return self();
}
/**
* Sets the string parser for keys. The parser is used when parsing user-provided key strings into key
* instances (e.g. when parsing a map from a single-line string representation).
*
* @param keyStringParser The parser to use for keys.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> keyStringParser(@NotNull NodeStringParser<K> keyStringParser) {
this.keyStringParser = keyStringParser;
return self();
}
/**
* Sets a sender-aware string parser for keys. Use this when parsing depends on the sender context.
*
* @param keyStringParser The sender-aware key parser.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> keyStringParser(@NotNull SenderNodeStringParser<K> keyStringParser) {
this.keyStringParser = keyStringParser;
return self();
}
/**
* Sets the string parser for values. The parser converts the textual part after '=' in an entry into the
* value type.
*
* @param valueStringParser The parser to use for values.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> valueStringParser(@NotNull NodeStringParser<V> valueStringParser) {
this.valueStringParser = valueStringParser;
return self();
}
/**
* Sets a sender-aware string parser for values.
*
* @param valueStringParser The sender-aware value parser.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> valueStringParser(@NotNull SenderNodeStringParser<V> valueStringParser) {
this.valueStringParser = valueStringParser;
return self();
}
/**
* Sets the serializer for keys. Serializers convert keys to/from persisted forms stored in the configuration
* backend.
*
* @param keySerializer The key serializer.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> keySerializer(@NotNull NodeSerializer<K> keySerializer) {
this.keySerializer = keySerializer;
return self();
}
/**
* Sets the serializer for values.
*
* @param valueSerializer The value serializer.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> valueSerializer(@NotNull NodeSerializer<V> valueSerializer) {
this.valueSerializer = valueSerializer;
return self();
}
/**
* Sets a validator for keys. The validator should return a successful {@link Try} for valid keys or a failed
* {@link Try} describing the validation error for invalid keys.
*
* @param keyValidator The key validator.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> keyValidator(@NotNull Function<K, Try<Void>> keyValidator) {
this.keyValidator = keyValidator;
return self();
}
/**
* Sets a validator for values.
*
* @param valueValidator The value validator.
* @return This builder for chaining.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Builder<K, V> valueValidator(@NotNull Function<V, Try<Void>> valueValidator) {
this.valueValidator = valueValidator;
return self();
}
/**
* {@inheritDoc}
*/
@Override
public @NotNull MapConfigNode<K, V> build() {
return new MapConfigNode<>(
path,
comments.toArray(new String[0]),
name,
type,
aliases,
defaultValue,
suggester,
stringParser,
serializer,
validator,
onLoad,
onLoadAndChange,
onChange,
keyType,
valueType,
keySuggester,
valueSuggester,
keyStringParser,
valueStringParser,
keySerializer,
valueSerializer,
keyValidator,
valueValidator);
}
}
}
@@ -0,0 +1,93 @@
package org.mvplugins.multiverse.core.config.node;
import io.vavr.control.Try;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Map;
/**
* A {@link ValueNode} specialization that stores a {@link Map} of key-value pairs.
*
* @param <K> The key type.
* @param <V> The value type.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public interface MapValueNode<K, V> extends ValueNode<Map<K, V>> {
/**
* Validates a single map key.
* <br />
* Implementations should return a successful {@link Try} when the key is acceptable, or a failed
* {@link Try} when the key is invalid.
*
* @param key The key to validate, or {@code null} if the key is absent.
* @return A successful {@link Try} if the key is valid, or a failed {@link Try} if it is not.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull Try<Void> validateKey(@Nullable K key);
/**
* Validates a single map value.
* <br />
* Implementations should return a successful {@link Try} when the value is acceptable, or a failed
* {@link Try} when the value is invalid.
*
* @param value The value to validate, or {@code null} if the value is absent.
* @return A successful {@link Try} if the value is valid, or a failed {@link Try} if it is not.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull Try<Void> validateValue(@Nullable V value);
/**
* Validates a key-value entry.
* <br />
* This is typically implemented by composing {@link #validateKey(Object)} and {@link #validateValue(Object)}.
* If either part fails, the entry is considered invalid.
*
* @param key The entry key, or {@code null} if the key is absent.
* @param value The entry value, or {@code null} if the value is absent.
* @return A successful {@link Try} if both parts are valid, or a failed {@link Try} if either part is invalid.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull Try<Void> validateEntry(@Nullable K key, @Nullable V value);
/**
* Deserializes a raw key-value pair into a map entry.
* <br />
* This method converts a single persisted entry, not the full map. If either component cannot be converted,
* implementations may return {@code null} for that side of the entry.
*
* @param key The raw key object, or {@code null} if no key is present.
* @param value The raw value object, or {@code null} if no value is present.
* @return A map entry containing the deserialized key and value.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull Map.Entry<K, V> deserializeEntry(@Nullable Object key, @Nullable Object value);
/**
* Serializes a map entry into raw configuration objects.
* <br />
* This method converts a single entry to a persisted representation, not the full map. The returned entry
* contains the serialized key and serialized value.
*
* @param key The entry key, or {@code null} if the key is absent.
* @param value The entry value, or {@code null} if the value is absent.
* @return A map entry containing the serialized key and value.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull Map.Entry<Object, Object> serializeEntry(@Nullable K key, @Nullable V value);
}
@@ -34,7 +34,7 @@ public interface ValueNode<T> extends Node {
* Gets the aliases of this node. Serves as shorter or legacy alternatives the {@link #getName()} and must be
* unique within a node group.
*
* @return The aliases of this node.
* @return The aliases of this node, or an empty array if the node has no aliases.
*
* @since 5.1
*/
@@ -44,27 +44,36 @@ public interface ValueNode<T> extends Node {
}
/**
* Gets the default value with type {@link T} of the node.
* Gets the default value for this node.
*
* @return The default value of the node.
* <p>The returned value is used when no explicit value is present or when deserialization falls back to the
* node's default. Implementations may return {@code null} to indicate that no default value is available.
*
* @return The default value of the node, or {@code null} if none is configured.
*/
@Nullable T getDefaultValue();
/**
* Suggests possible string values for this node. Generated based on the current user input.
* Suggests possible string values for this node.
*
* @param input The current partial user input
* <p>The returned values are based on the current partial user input and should be suitable for command-line
* tab completion or other interactive hints.
*
* @param input The current partial user input, or {@code null} if no input has been typed yet.
* @return A collection of possible string values.
*/
@NotNull Collection<String> suggest(@Nullable String input);
/**
* Suggests possible string values for this node. Use contextural information from the sender such as
* sender name, permissions, or player location for better suggestions.
* Suggests possible string values for this node using sender context.
*
* @param sender The sender context.
* @param input The input string.
* @return A collection of possible string values
* <p>Implementations may use the sender's name, permissions, or player location to tailor the returned
* suggestions. If no sender-specific logic is available, implementations should fall back to
* {@link #suggest(String)}.
*
* @param sender The sender context.
* @param input The current partial user input, or {@code null} if no input has been typed yet.
* @return A collection of possible string values.
*
* @since 5.1
*/
@@ -72,20 +81,25 @@ public interface ValueNode<T> extends Node {
@NotNull Collection<String> suggest(@NotNull CommandSender sender, @Nullable String input);
/**
* Parses the given string into a value of type {@link T}. Used for property set by user input.
* Parses the given string into a value of type {@link T}.
*
* @param input The string to parse.
* @return The parsed value, or given exception if parsing failed.
* <p>This is typically used for values entered by a user or read from configuration text.
*
* @param input The string to parse, or {@code null} if the source value is absent.
* @return The parsed value, or a failed {@link Try} containing the parsing error.
*/
@NotNull Try<T> parseFromString(@Nullable String input);
/**
* Parses the given string into a value of type {@link T} with context from the sender.
* Used for property set by user input.
*
* @param sender The sender context.
* @param input The string to parse.
* @return The parsed value, or given exception if parsing failed.
* <p>Sender-aware implementations may use permissions or other sender-specific information when converting the
* input. If no sender-aware parser is available, implementations should fall back to
* {@link #parseFromString(String)}.
*
* @param sender The sender context.
* @param input The string to parse, or {@code null} if the source value is absent.
* @return The parsed value, or a failed {@link Try} containing the parsing error.
*
* @since 5.1
*/
@@ -97,23 +111,59 @@ public interface ValueNode<T> extends Node {
/**
* Gets the serializer for this node.
*
* @return The serializer for this node.
* <p>This method exists for backward compatibility while the preferred API is
* {@link #serialize(Object)} and {@link #deserialize(Object)}.
*
* @return The serializer for this node, or {@code null} if none is configured.
*
* @deprecated Use {@link #serialize(Object)} and {@link #deserialize(Object)} instead.
*/
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
@Nullable NodeSerializer<T> getSerializer();
/**
* Deserializes a raw configuration object into a value of type {@link T}.
*
* <p>The input is typically a value read from YAML or another configuration source. Implementations should
* return a sensible fallback, such as {@link #getDefaultValue()}, when the object cannot be converted.
*
* @param object The raw object to deserialize.
* @return The deserialized value, or {@code null} if the object cannot be converted and no fallback exists.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Nullable T deserialize(@Nullable Object object);
/**
* Serializes a value of type {@link T} into a configuration-friendly object.
*
* <p>The returned object should be suitable for persistence in a configuration file.
*
* @param value The value to serialize.
* @return The serialized representation, or {@code null} if the value cannot be serialized.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Nullable Object serialize(@Nullable T value);
/**
* Validates the value of this node.
*
* @param value The value to validate.
* @return An empty {@link Try} if the value is valid, or a {@link Try} containing an exception if the value is
* invalid.
* @return A successful {@link Try} if the value is valid, or a failed {@link Try} containing the validation
* exception if the value is invalid.
*/
Try<Void> validate(@Nullable T value);
/**
* Called when the value of this node is loaded by {@link BaseConfigurationHandle#load()}.
*
* @param value The loaded value.
* <p>This callback is invoked after the value has been deserialized and validated during a configuration load.
*
* @param value The loaded value, or {@code null} if the configured value could not be resolved.
*
* @since 5.4
*/
@@ -123,9 +173,12 @@ public interface ValueNode<T> extends Node {
/**
* Called when the value of this node is loaded or changed.
*
* @param sender The sender who triggered the change. If triggered by loading or no target sender is specified,
* it will be the console sender.
* @param oldValue The old value, will always be null on load.
* <p>This callback is shared by both initial load and subsequent updates. When invoked during a load, the
* {@code oldValue} parameter will be {@code null} and the sender will be the console sender.
*
* @param sender The sender who triggered the change. If triggered by loading or no target sender is specified,
* it will be the console sender.
* @param oldValue The previous value, or {@code null} when called during load.
* @param newValue The new value.
*
* @since 5.4
@@ -136,9 +189,11 @@ public interface ValueNode<T> extends Node {
/**
* Called when the value of this node is changed by {@link BaseConfigurationHandle#set(ValueNode, Object)}.
*
* @param sender The sender who changed the value, or console sender if no target sender specified.
* @param oldValue The old value.
* @param newValue The new value.
* <p>This callback is only invoked for explicit updates after the value already exists.
*
* @param sender The sender who changed the value, or the console sender if no target sender was specified.
* @param oldValue The old value.
* @param newValue The new value.
*
* @since 5.4
*/
@@ -148,12 +203,15 @@ public interface ValueNode<T> extends Node {
/**
* Called when the value of this node is set.
*
* <p>This is the legacy, senderless form of {@link #onLoadAndChange(CommandSender, Object, Object)}.
*
* @param oldValue The old value.
* @param newValue The new value.
*
* @deprecated Use {@link #onLoadAndChange(CommandSender, Object, Object)} instead.
*/
@Deprecated(since = "5.4", forRemoval = true)
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
default void onSetValue(@Nullable T oldValue, @Nullable T newValue) {
onLoadAndChange(Bukkit.getConsoleSender(), oldValue, newValue);
}
@@ -6,6 +6,7 @@ import co.aikar.locales.MessageKey;
import co.aikar.locales.MessageKeyProvider;
import jakarta.inject.Inject;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jvnet.hk2.annotations.Service;
@@ -14,13 +15,11 @@ import org.mvplugins.multiverse.core.config.CoreConfig;
import org.mvplugins.multiverse.core.destination.Destination;
import org.mvplugins.multiverse.core.destination.DestinationSuggestionPacket;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.teleportation.LocationManipulation;
import org.mvplugins.multiverse.core.utils.REPatterns;
import org.mvplugins.multiverse.core.utils.result.Attempt;
import org.mvplugins.multiverse.core.utils.result.FailureReason;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.WorldManager;
import org.mvplugins.multiverse.core.world.entrycheck.WorldEntryCheckerProvider;
@@ -56,6 +55,19 @@ public final class WorldDestination implements Destination<WorldDestination, Wor
return "w";
}
/**
* Directly create a world destination instance for a Multiverse World without any string parsing validation needed.
*
* @param multiverseWorld The destination's target world.
* @return The destination instance with the target world.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull WorldDestinationInstance fromWorld(MultiverseWorld multiverseWorld) {
return new WorldDestinationInstance(this, multiverseWorld, null, -1.0f);
}
/**
* {@inheritDoc}
*/
@@ -35,7 +35,7 @@ public final class DynamicListenerRegistration {
private static final boolean hasEventExecutorCreate;
static {
hasEventExecutorCreate = ReflectHelper.getMethod(EventExecutor.class, "create", Method.class, Class.class) != null;
hasEventExecutorCreate = ReflectHelper.hasMethod(EventExecutor.class, "create", Method.class, Class.class);
}
private final EventPriorityMapper eventPriorityMapper;
@@ -98,18 +98,22 @@ public final class DynamicListenerRegistration {
}
method.setAccessible(true);
Object methodOutput = ReflectHelper.invokeMethod(listener, method);
if (!(methodOutput instanceof EventRunnable eventRunnable)) {
Logging.warning("Event method %s in %s did not return a SingleEventListener",
method.getName(), listener.getClass().getName());
return;
}
EventExecutor executor = new EventRunnableExecutor<>(eventClass, eventRunnable);
EventPriority priority = getDynamicEventPriority(method);
boolean ignoreCancelled = isIgnoreIfCancelled(method);
Logging.finest("Registering dynamic event for %s with priority %s", eventClass.getName(), priority);
Bukkit.getPluginManager().registerEvent(eventClass, listener, priority, executor, plugin, ignoreCancelled);
ReflectHelper.tryInvokeMethod(listener, method)
.onFailure(e -> Logging.warning("Failed to invoke event method %s in %s: $s",
method.getName(), listener.getClass().getName(), e.getMessage()))
.filter(EventRunnable.class::isInstance)
.onFailure(e -> Logging.warning("Event method %s in %s did not return an EventRunnable instance!",
method.getName(), listener.getClass().getName()))
.map(EventRunnable.class::cast)
.map(eventRunnable -> new EventRunnableExecutor<>(eventClass, eventRunnable))
.peek(executor -> {
EventPriority priority = getDynamicEventPriority(method);
boolean ignoreCancelled = isIgnoreIfCancelled(method);
Bukkit.getPluginManager().registerEvent(eventClass, listener, priority, executor, plugin, ignoreCancelled);
Logging.finest("Registered dynamic event: %s, priority: %s, ignore if cancelled: %s",
eventClass.getName(), priority, ignoreCancelled);
});
}
private Class<? extends Event> getEventClass(Method method) {
@@ -129,13 +133,12 @@ public final class DynamicListenerRegistration {
}
private Class<? extends Event> getEventClassFromString(String className) {
Class<?> annotatedClass = ReflectHelper.getClass(className);
if (annotatedClass == null || !Event.class.isAssignableFrom(annotatedClass)) {
// Usually means the server software used did not implement the event
Logging.fine("Event class does not exist: %s", className);
return null;
}
return annotatedClass.asSubclass(Event.class);
return ReflectHelper.tryGetClass(className)
.onFailure(throwable -> Logging.fine("Failed to find event class: %s", className))
.filter(Event.class::isAssignableFrom)
.onFailure(throwable -> Logging.warning("Class is not an Event: %s", className))
.map(clazz -> (Class<? extends Event>) clazz.asSubclass(Event.class))
.getOrNull();
}
private EventPriority getDynamicEventPriority(Method method) {
@@ -7,6 +7,7 @@ import java.io.FileReader;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import com.dumptruckman.minecraft.util.Logging;
@@ -24,7 +25,7 @@ public final class MVDumpsDebugInfoEvent extends Event {
public MVDumpsDebugInfoEvent() {
debugInfoBuilder = new StringBuilder();
detailedDebugInfo = new HashMap<>();
detailedDebugInfo = new LinkedHashMap<>();
}
private static final HandlerList HANDLERS = new HandlerList();
@@ -1,8 +1,11 @@
package org.mvplugins.multiverse.core.event.world;
import org.bukkit.event.HandlerList;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
/**
* Called when a world has been successfully cloned.
@@ -10,14 +13,24 @@ import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
public final class MVWorldClonedEvent extends MultiverseWorldEvent<LoadedMultiverseWorld> {
private static final HandlerList HANDLERS = new HandlerList();
private final LoadedMultiverseWorld fromWorld;
private final MultiverseWorld fromWorld;
public MVWorldClonedEvent(LoadedMultiverseWorld world, LoadedMultiverseWorld fromWorld) {
public MVWorldClonedEvent(LoadedMultiverseWorld world, MultiverseWorld fromWorld) {
super(world);
this.fromWorld = fromWorld;
}
public LoadedMultiverseWorld getFromWorld() {
/**
* @deprecated Cloning can be done from unloaded worlds as well. Use {@link #getSourceWorld()} instead.
*/
@Deprecated(forRemoval = true, since = "5.6")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public @Nullable LoadedMultiverseWorld getFromWorld() {
return fromWorld.asLoadedWorld().getOrNull();
}
@ApiStatus.AvailableSince("5.6")
public @NotNull MultiverseWorld getSourceWorld() {
return fromWorld;
}
@@ -37,5 +50,4 @@ public final class MVWorldClonedEvent extends MultiverseWorldEvent<LoadedMultive
public static HandlerList getHandlerList() {
return HANDLERS;
}
}
@@ -1,6 +1,8 @@
package org.mvplugins.multiverse.core.exceptions.command;
import co.aikar.commands.InvalidCommandArgument;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.locale.message.LocalizableMessage;
import org.mvplugins.multiverse.core.locale.message.LocalizedMessage;
import org.mvplugins.multiverse.core.locale.message.Message;
@@ -9,6 +11,18 @@ import org.mvplugins.multiverse.core.locale.message.Message;
*/
public class MVInvalidCommandArgument extends InvalidCommandArgument {
@ApiStatus.AvailableSince("5.7")
public static MVInvalidCommandArgument causeBy(Throwable throwable) {
return causeBy(throwable, true);
}
@ApiStatus.AvailableSince("5.7")
public static MVInvalidCommandArgument causeBy(Throwable throwable, boolean showSyntax) {
return (throwable instanceof LocalizableMessage localizableMessage)
? of(localizableMessage.getLocalizableMessage(), showSyntax)
: new MVInvalidCommandArgument(throwable.getLocalizedMessage(), showSyntax);
}
public static MVInvalidCommandArgument of(Message message) {
return of(message, true);
}
@@ -19,6 +33,10 @@ public class MVInvalidCommandArgument extends InvalidCommandArgument {
: new MVInvalidCommandArgument(message, showSyntax);
}
private MVInvalidCommandArgument(String message, boolean showSyntax) {
super(message, showSyntax);
}
private MVInvalidCommandArgument(Message message, boolean showSyntax) {
super(message.formatted(), showSyntax);
}
@@ -7,6 +7,7 @@
package org.mvplugins.multiverse.core.listeners;
import com.destroystokyo.paper.event.entity.PreCreatureSpawnEvent;
import com.dumptruckman.minecraft.util.Logging;
import jakarta.inject.Inject;
import org.bukkit.entity.LivingEntity;
@@ -21,6 +22,8 @@ import org.bukkit.event.entity.FoodLevelChangeEvent;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.dynamiclistener.EventRunnable;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventClass;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventMethod;
import org.mvplugins.multiverse.core.world.WorldManager;
@@ -77,6 +80,37 @@ final class MVEntityListener implements CoreListener {
});
}
/**
* Fired before other spawn checks is done, helps in performance by cancelling early and preventing unnecessary
* checks for spawn reasons that are not allowed in the world.
*
* @return Event wrapper
*/
@EventClass("com.destroystokyo.paper.event.entity.PreCreatureSpawnEvent")
EventRunnable<?> preCreatureSpawn() {
return new EventRunnable<PreCreatureSpawnEvent>() {
@Override
public void onEvent(PreCreatureSpawnEvent event) {
// Always allow custom command and plugins to spawn creatures
if (event.getReason() == SpawnReason.CUSTOM
|| event.getReason() == SpawnReason.COMMAND
|| event.getReason() == SpawnReason.BREEDING
|| event.getReason() == SpawnReason.SPAWNER_EGG) {
return;
}
worldManager.getLoadedWorld(event.getSpawnLocation().getWorld())
.peek(world -> {
if (!world.getEntitySpawnConfig().shouldAllowSpawn(event.getType())) {
Logging.finest("Cancelling Pre Creature Spawn Event for: " + event.getType());
event.setCancelled(true);
event.setShouldAbortSpawn(true);
}
});
}
};
}
/**
* Handle Spawn Category settings.
*
@@ -7,9 +7,7 @@
package org.mvplugins.multiverse.core.listeners;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
@@ -38,6 +36,7 @@ import org.mvplugins.multiverse.core.destination.DestinationsProvider;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.DefaultEventPriority;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventMethod;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventPriorityKey;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.IgnoreIfCancelled;
import org.mvplugins.multiverse.core.economy.MVEconomist;
import org.mvplugins.multiverse.core.event.MVRespawnEvent;
import org.mvplugins.multiverse.core.locale.PluginLocales;
@@ -245,7 +244,7 @@ final class MVPlayerListener implements CoreListener {
}
private void teleportToDestinationOnJoin(Player player, DestinationInstance<?, ?> destination) {
asyncSafetyTeleporter.to(destination).teleportSingle(player)
asyncSafetyTeleporter.to(destination).passengerMode(config.getPassengerMode()).teleportSingle(player)
.onSuccess(result -> Logging.fine("Player %s has been teleported on join",
player.getName()))
.onFailure(failure -> Logging.warning("Failed to teleport player %s on join: %s",
@@ -257,9 +256,9 @@ final class MVPlayerListener implements CoreListener {
* @param event The Event that was fired.
*/
@EventMethod
@DefaultEventPriority(EventPriority.MONITOR)
@EventPriorityKey("mvcore-player-changed-world")
@DefaultEventPriority(EventPriority.NORMAL)
void playerChangedWorld(PlayerChangedWorldEvent event) {
// Permissions now determine whether or not to handle a gamemode.
this.handleGameModeAndFlight(event.getPlayer(), event.getPlayer().getWorld());
}
@@ -271,12 +270,28 @@ final class MVPlayerListener implements CoreListener {
@EventPriorityKey("mvcore-player-teleport")
@DefaultEventPriority(EventPriority.HIGHEST)
void playerTeleport(PlayerTeleportEvent event) {
Logging.finer("Got teleport event for player '"
+ event.getPlayer().getName() + "' with cause '" + event.getCause() + "'");
Player teleportee = event.getPlayer();
if (event.isCancelled()) {
Logging.finer("Teleport event for player '" + teleportee.getName() + "' was already cancelled.");
return;
}
Player teleportee = event.getPlayer();
Logging.finer("Got teleport event for player '"
+ teleportee.getName() + "' with cause '" + event.getCause() + "'");
Location fromLocation = event.getFrom();
if (fromLocation == null || fromLocation.getWorld() == null) { // should never be null, but just in case
Logging.finer("Teleport event for player '" + teleportee.getName() + "' has null from-location or world.");
return;
}
Location toLocation = event.getTo();
if (toLocation == null || toLocation.getWorld() == null) { // may be null on spigot
Logging.finer("Teleport event for player '" + teleportee.getName() + "' has null to-location or world.");
return;
}
Option<String> teleporterName = teleportQueue.popFromQueue(teleportee.getName());
CommandSender teleporter = teleporterName.map(name -> {
if (name.equalsIgnoreCase("CONSOLE")) {
@@ -297,17 +312,17 @@ final class MVPlayerListener implements CoreListener {
}
Logging.finer("Teleporter %s is teleporting %s from %s to %s", teleporter.getName(), teleportee.getName(),
event.getFrom(), event.getTo());
fromLocation, toLocation);
MultiverseWorld fromWorld = getWorldManager().getLoadedWorld(event.getFrom().getWorld()).getOrNull();
LoadedMultiverseWorld toWorld = getWorldManager().getLoadedWorld(event.getTo().getWorld()).getOrNull();
MultiverseWorld fromWorld = getWorldManager().getLoadedWorld(fromLocation.getWorld()).getOrNull();
LoadedMultiverseWorld toWorld = getWorldManager().getLoadedWorld(toLocation.getWorld()).getOrNull();
if (toWorld == null) {
Logging.fine("Player '" + teleportee.getName() + "' is teleporting to world '"
+ event.getTo().getWorld().getName() + "' which is not managed by Multiverse-Core. No further "
+ toLocation.getWorld().getName() + "' which is not managed by Multiverse-Core. No further "
+ "actions will be taken by Multiverse-Core.");
return;
}
if (event.getFrom().getWorld().equals(event.getTo().getWorld())) {
if (fromLocation.getWorld().equals(toLocation.getWorld())) {
// The player is Teleporting to the same world.
Logging.finer("Player '" + teleportee.getName() + "' is teleporting to the same world.");
this.stateSuccess(teleportee.getName(), toWorld.getName());
@@ -341,20 +356,19 @@ final class MVPlayerListener implements CoreListener {
*/
@EventMethod
@DefaultEventPriority(EventPriority.LOWEST)
@IgnoreIfCancelled
void playerPortalCheck(PlayerPortalEvent event) {
if (event.isCancelled()) {
return;
}
if (event.getFrom().getWorld() == null) {
Location fromLocation = event.getFrom();
if (fromLocation == null || fromLocation.getWorld() == null) {
Logging.warning("PlayerPortalEvent's from world is null!");
return;
}
if (event.getFrom().getWorld().getBlockAt(event.getFrom()).getType() == Material.NETHER_PORTAL) {
if (fromLocation.getWorld().getBlockAt(fromLocation).getType() == Material.NETHER_PORTAL) {
return;
}
// Player was actually outside of the portal, adjust the from location
Location newLocation = blockSafety.findPortalBlockNextTo(event.getFrom());
Location newLocation = blockSafety.findPortalBlockNextTo(fromLocation);
if (newLocation != null) {
event.setFrom(newLocation);
}
@@ -367,28 +381,33 @@ final class MVPlayerListener implements CoreListener {
@EventMethod
@EventPriorityKey("mvcore-player-portal")
@DefaultEventPriority(EventPriority.HIGH)
@IgnoreIfCancelled
void playerPortal(PlayerPortalEvent event) {
if (event.isCancelled()) {
Location fromLocation = event.getFrom();
if (fromLocation == null || fromLocation.getWorld() == null) { // should never be null, but just in case
Logging.finer("PlayerPortalEvent's from world is null!");
return;
}
if (event.getTo() == null || event.getTo().getWorld() == null) {
Location toLocation = event.getTo();
if (toLocation == null || toLocation.getWorld() == null) { // may be null on spigot
Logging.finer("PlayerPortalEvent's to world is null!");
return;
}
if (config.isUsingCustomPortalSearch()) {
event.setSearchRadius(config.getCustomPortalSearchRadius());
}
if (Objects.equals(event.getFrom().getWorld(), event.getTo().getWorld())) {
if (Objects.equals(fromLocation.getWorld(), toLocation.getWorld())) {
// The player is Portaling to the same world.
Logging.finer("Player '" + event.getPlayer().getName() + "' is portaling to the same world.");
return;
}
MultiverseWorld fromWorld = getWorldManager().getLoadedWorld(event.getFrom().getWorld()).getOrNull();
LoadedMultiverseWorld toWorld = getWorldManager().getLoadedWorld(event.getTo().getWorld()).getOrNull();
MultiverseWorld fromWorld = getWorldManager().getLoadedWorld(fromLocation.getWorld()).getOrNull();
LoadedMultiverseWorld toWorld = getWorldManager().getLoadedWorld(toLocation.getWorld()).getOrNull();
if (toWorld == null) {
Logging.fine("Player '" + event.getPlayer().getName() + "' is portaling to world '"
+ event.getTo().getWorld().getName() + "' which is not managed by Multiverse-Core. No further "
+ toLocation.getWorld().getName() + "' which is not managed by Multiverse-Core. No further "
+ "actions will be taken by Multiverse-Core.");
return;
}
@@ -402,21 +421,31 @@ final class MVPlayerListener implements CoreListener {
}
/**
* Handles the gamemode for the specified {@link Player}.
* Handles the gamemode for the specified {@link Player}. Delays the enforcement if configured to do so
* to ensure multiverse has final say over other plugins changing gamemodes.
*
* @param player The {@link Player}.
* @param world The {@link World} the player is supposed to be in.
*/
private void handleGameModeAndFlight(final Player player, World world) {
// We perform this task one tick later to MAKE SURE that the player actually reaches the
// destination world, otherwise we'd be changing the player mode if they havent moved anywhere.
this.server.getScheduler().runTaskLater(this.plugin, () -> {
if (!player.isOnline() || !player.getWorld().equals(world)) {
return;
}
Logging.finer("Handling gamemode and flight for player %s in world '%s'", player.getName(), world.getName());
enforcementHandler.handleFlightEnforcement(player);
enforcementHandler.handleGameModeEnforcement(player);
}, 1L);
if (config.getGamemodeAndFlightEnforceDelay() <= 0) {
doGameModeAndFlightEnforcement(player, world);
return;
}
server.getScheduler().runTaskLater(
this.plugin,
() -> doGameModeAndFlightEnforcement(player, world),
config.getGamemodeAndFlightEnforceDelay()
);
}
private void doGameModeAndFlightEnforcement(Player player, World world) {
if (!player.isOnline() || !player.getWorld().equals(world)) {
Logging.finer("Player %s is no longer online or not in the expected world '%s'", player.getName(), world.getName());
return;
}
Logging.finer("Handling gamemode and flight for player %s in world '%s'", player.getName(), world.getName());
enforcementHandler.handleFlightEnforcement(player);
enforcementHandler.handleGameModeEnforcement(player);
}
}
@@ -18,9 +18,11 @@ import org.bukkit.event.player.PlayerInteractEvent;
import org.bukkit.event.world.PortalCreateEvent;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.command.MVCommandManager;
import org.mvplugins.multiverse.core.config.CoreConfig;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.EventMethod;
import org.mvplugins.multiverse.core.dynamiclistener.annotations.IgnoreIfCancelled;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.world.WorldManager;
import static org.bukkit.PortalType.CUSTOM;
@@ -33,11 +35,15 @@ final class MVPortalListener implements CoreListener {
private final CoreConfig config;
private final WorldManager worldManager;
private final MVCommandManager commandManager;
@Inject
MVPortalListener(@NotNull CoreConfig config, @NotNull WorldManager worldManager) {
MVPortalListener(@NotNull CoreConfig config,
@NotNull WorldManager worldManager,
@NotNull MVCommandManager commandManager) {
this.config = config;
this.worldManager = worldManager;
this.commandManager = commandManager;
}
/**
@@ -59,6 +65,7 @@ final class MVPortalListener implements CoreListener {
if (!world.getPortalForm().isPortalAllowed(targetType)) {
Logging.fine("Cancelling creation of %s portal because portalForm disallows.", targetType);
event.setCancelled(true);
commandManager.getCommandIssuer(event.getEntity()).sendError(MVCorei18n.PORTALFORM_DISABLED_NETHER);
}
}).onEmpty(() ->
Logging.fine("World '%s' is not managed by Multiverse! Ignoring at PortalCreateEvent.",
@@ -101,6 +108,7 @@ final class MVPortalListener implements CoreListener {
if (!world.getPortalForm().isPortalAllowed(PortalType.ENDER)) {
Logging.fine("Cancelling creation of ENDER portal because portalForm disallows.");
event.setCancelled(true);
commandManager.getCommandIssuer(event.getPlayer()).sendError(MVCorei18n.PORTALFORM_DISABLED_END);
}
}).onEmpty(() ->
Logging.fine("World '%s' is not managed by Multiverse! Ignoring at PlayerInteractEvent.",
@@ -91,6 +91,7 @@ public enum MVCorei18n implements MessageKeyProvider {
// /mv dumps
DUMPS_DESCRIPTION,
DUMPS_STARTING,
DUMPS_URL_LIST,
// /mv gamerule set
@@ -146,6 +147,19 @@ public enum MVCorei18n implements MessageKeyProvider {
LOAD_LOADING,
LOAD_SUCCESS,
// /mv meta info
META_INFO_DESCRIPTION,
META_INFO_WORLD,
META_INFO_HEADER,
META_INFO_NOCONTENT,
// /mv meta modify
META_MODIFY_SET_SUCCESS,
META_MODIFY_SET_FAILURE,
META_MODIFY_REMOVE_SUCCESS,
META_MODIFY_REMOVE_FAILURE,
META_MODIFY_INVALIDACTION,
// /mv modify
MODIFY_DESCRIPTION,
MODIFY_WORLD_DESCRIPTION,
@@ -250,6 +264,8 @@ public enum MVCorei18n implements MessageKeyProvider {
WORLDBORDER_WARNINGTIME_NOTHINGCHANGED,
WORLDBORDER_WARNINGTIME_SUCCESS,
WORLDBORDER_ROUNDOFF_WARNING,
// commands error
COMMANDS_ERROR_PLAYERSONLY,
COMMANDS_ERROR_MULTIVERSEWORLDONLY,
@@ -278,6 +294,10 @@ public enum MVCorei18n implements MessageKeyProvider {
DESTINATION_SHARED_FAILUREREASON_WORLDNOTFOUND,
DESTINATION_PARSE_FAILUREREASON_INVALIDDESTINATIONID,
// portal form disabled
PORTALFORM_DISABLED_END,
PORTALFORM_DISABLED_NETHER,
// teleport failure reason
TELEPORTFAILUREREASON_NULL_DESTINATION,
TELEPORTFAILUREREASON_NULL_LOCATION,
@@ -292,6 +312,7 @@ public enum MVCorei18n implements MessageKeyProvider {
CLONEWORLD_WORLDEXISTFOLDER,
CLONEWORLD_WORLDEXISTUNLOADED,
CLONEWORLD_WORLDEXISTLOADED,
CLONEWORLD_FROMWORLDFOLDERINVALID,
CLONEWORLD_COPYFAILED,
CREATEWORLD_INVALIDWORLDNAME,
@@ -309,12 +330,14 @@ public enum MVCorei18n implements MessageKeyProvider {
IMPORTWORLD_WORLDEXISTLOADED,
IMPORTWORLD_WORLDFOLDERINVALID,
IMPORTWORLD_BUKKITENVIRONMENTMISMATCH,
IMPORTWORLD_BUKKITNAMESPACEDKEYMISMATCH,
LOADWORLD_WORLDALREADYLOADING,
LOADWORLD_WORLDNONEXISTENT,
LOADWORLD_WORLDEXISTFOLDER,
LOADWORLD_WORLDEXISTLOADED,
LOADWORLD_BUKKITENVIRONMENTMISMATCH,
LOADWORLD_BUKKITNAMESPACEDKEYMISMATCH,
REMOVEWORLD_WORLDNONEXISTENT,
@@ -351,6 +374,12 @@ public enum MVCorei18n implements MessageKeyProvider {
EXCEPTION_POSITIONPARSE_INVALIDCOORDINATES,
EXCEPTION_POSITIONPARSE_INVALIDNUMBER,
// world key parse failures
WORLDKEYPARSE_EMPTY,
WORLDKEYPARSE_INVALIDWORLDNAME,
WORLDKEYPARSE_INVALIDNAMESPACEDKEY,
WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED,
// generic
GENERIC_SUCCESS,
GENERIC_FAILURE,
@@ -1,6 +1,7 @@
package org.mvplugins.multiverse.core.locale.message;
import io.vavr.control.Either;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -107,6 +108,7 @@ public final class MessageReplacement {
GAMERULE(replace("{gamerule}")),
LOCATION(replace("{location}")),
NAME(replace("{name}")),
NAMESPACE(replace("{namespace}")),
PLAYER(replace("{player}")),
REASON(replace("{reason}")),
VALUE(replace("{value}")),
@@ -147,9 +147,10 @@ public abstract class MultiverseModule extends JavaPlugin {
Try.of(() -> commandManagerProvider.get())
.mapTry(MVCommandManager::getLocales)
.andThen(pluginLocales -> {
pluginLocales.addFileResClassLoader(this);
pluginLocales.addBundleClassLoader(this.getClassLoader());
pluginLocales.addFileResClassLoader(this);
pluginLocales.addMessageBundles(this.getName().toLowerCase(Locale.ENGLISH));
pluginLocales.loadLanguages();
})
.onFailure(e -> {
Logging.severe("Failed to register locales");
@@ -132,6 +132,10 @@ public final class BlockSafety {
*/
public boolean canSpawnAtBlockSafely(@NotNull Block block) {
Logging.finest("Checking spawn safety for location: %s, %s, %s", block.getX(), block.getY(), block.getZ());
if (!block.getWorld().getWorldBorder().isInside(block.getLocation())) {
Logging.finest("Location is outside world border.");
return false;
}
if (isUnsafeSpawnBody(block)) {
// Player body will be stuck in solid
Logging.finest("Unsafe location for player's body: " + block);
@@ -0,0 +1,92 @@
package org.mvplugins.multiverse.core.utils;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.util.Collection;
import java.util.HashMap;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
/**
* A map with case-insensitive String keys. All keys are stored in lower-case form.
*
* @param <T> the type of mapped values
*
* @since 5.5
*/
@ApiStatus.AvailableSince("5.5")
public class CaseInsensitiveStringMap<T> implements Map<String, T> {
private final Map<String, T> map;
public CaseInsensitiveStringMap() {
map = new HashMap<>();
}
@Override
public int size() {
return map.size();
}
@Override
public boolean isEmpty() {
return map.isEmpty();
}
@Override
public boolean containsKey(Object key) {
return map.containsKey(normalizeKey(key));
}
@Override
public boolean containsValue(Object value) {
return map.containsValue(value);
}
@Override
public T get(Object key) {
return map.get(normalizeKey(key));
}
@Override
public @Nullable T put(String key, T value) {
return map.put(normalizeKey(key), value);
}
@Override
public T remove(Object key) {
return map.remove(normalizeKey(key));
}
@Override
public void putAll(@NotNull Map<? extends String, ? extends T> m) {
m.forEach((key, value) -> map.put(normalizeKey(key), value));
}
@Override
public void clear() {
map.clear();
}
@Override
public @NotNull Set<String> keySet() {
return map.keySet();
}
@Override
public @NotNull Collection<T> values() {
return map.values();
}
@Override
public @NotNull Set<Entry<String, T>> entrySet() {
return map.entrySet();
}
private String normalizeKey(Object key) {
return String.valueOf(key).toLowerCase(Locale.ROOT);
}
}
@@ -18,6 +18,7 @@ import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import com.dumptruckman.minecraft.util.Logging;
@@ -210,28 +211,31 @@ public final class FileUtils {
private final Path sourceDir;
private final Path targetDir;
private final List<String> excludeFiles;
private final List<Path> excludeFiles;
private CopyDirFileVisitor(@NotNull Path sourceDir, @NotNull Path targetDir, @NotNull List<String> excludeFiles) {
this.sourceDir = sourceDir;
this.targetDir = targetDir;
this.excludeFiles = excludeFiles;
this.excludeFiles = excludeFiles.stream()
.map(sourceDir::resolve)
.toList();
Logging.finest(this.excludeFiles.stream().map(Path::toString).collect(Collectors.joining(", ", "Exclude files: [", "]")));
}
@Override
public @NotNull FileVisitResult preVisitDirectory(Path dir, @NotNull BasicFileAttributes attrs) throws IOException {
public @NotNull FileVisitResult preVisitDirectory(@NotNull Path dir, @NotNull BasicFileAttributes attrs) throws IOException {
Path newDir = targetDir.resolve(sourceDir.relativize(dir));
if (!Files.isDirectory(newDir)) {
Files.createDirectory(newDir);
Files.createDirectories(newDir);
}
return FileVisitResult.CONTINUE;
}
@Override
public @NotNull FileVisitResult visitFile(Path file, @NotNull BasicFileAttributes attrs) throws IOException {
public @NotNull FileVisitResult visitFile(@NotNull Path file, @NotNull BasicFileAttributes attrs) throws IOException {
// Pass files that are set to ignore
if (excludeFiles.contains(file.getFileName().toString())) {
Logging.finest("Ignoring file: " + file.getFileName());
if (excludeFiles.contains(file)) {
Logging.finest("Ignoring file: " + file);
return FileVisitResult.CONTINUE;
}
// Copy the files
@@ -1,8 +1,8 @@
package org.mvplugins.multiverse.core.utils;
import io.vavr.control.Try;
import org.checkerframework.checker.nullness.qual.Nullable;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.utils.tick.TickDuration;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
@@ -15,10 +15,6 @@ import java.time.format.DateTimeFormatter;
@ApiStatus.AvailableSince("5.1")
public final class MinecraftTimeFormatter {
private static final double TIME_MULTIPLIER = 3.6;
private static final long DAY_SECONDS = 24 * 60 * 60;
private static final long START_OFFSET = 6 * 60 * 60;
/**
* Formats Minecraft time to 12-hour format.
*
@@ -48,19 +44,14 @@ public final class MinecraftTimeFormatter {
* See <a href="https://docs.oracle.com/javase/8/docs/api/java/time/format/DateTimeFormatter.html">DateTimeFormatter documentation</a>
* for available patterns.
*
* @param time The Minecraft time to format.
* @param format The format string for the time.
* @param ticks The Minecraft time to format.
* @param format The format string for the time.
*
* @since 5.1
*/
@ApiStatus.AvailableSince("5.1")
public static String formatTime(long time, String format) {
// Convert Minecraft time to real-world time
long realTime = (long) ((time * TIME_MULTIPLIER) + START_OFFSET) % DAY_SECONDS; // Minecraft ticks to seconds
// Convert seconds to LocalTime
LocalTime localTime = LocalTime.ofSecondOfDay(realTime);
public static String formatTime(long ticks, String format) {
LocalTime localTime = TickDuration.ofTicks(ticks).toLocalTime();
return Try.of(() -> DateTimeFormatter.ofPattern(format))
.map(localTime::format)
.getOrElse("invalid time format: " + format);
@@ -3,6 +3,8 @@ package org.mvplugins.multiverse.core.utils;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import io.vavr.control.Try;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
@@ -15,8 +17,178 @@ public final class ReflectHelper {
* Try to get the {@link Class} based on its classpath.
*
* @param classPath The target classpath.
* @return A {@link Class} if found, else null.
* @return A {@link Try} containing the {@link Class} if found, else a failure.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static Try<Class<?>> tryGetClass(@NotNull String classPath) {
return Try.of(() -> Class.forName(classPath));
}
/**
* Check if the {@link Class} for a give classpath is present/valid.
*
* @param classPath Target classpath.
* @return True if class path is a valid class, else false.
*/
public static boolean hasClass(String classPath) {
return tryGetClass(classPath).isSuccess();
}
/**
* Try to get a {@link Method} from a given class.
*
* @param clazz The class to search the method on.
* @param methodName Name of the method to get.
* @param parameterTypes Parameters present for that method.
* @param <C> The class type.
* @return A {@link Try} containing the {@link Method} if found, else a failure.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static <C> Try<Method> tryGetMethod(@NotNull Class<C> clazz, @NotNull String methodName, Class<?>... parameterTypes) {
return Try.of(() -> clazz.getMethod(methodName, parameterTypes))
.orElse(Try.of(() -> {
Method method = clazz.getDeclaredMethod(methodName, parameterTypes);
method.setAccessible(true);
return method;
}));
}
/**
* Check if a {@link Method} exists on a given class.
*
* @param clazz The class to search the method on.
* @param methodName Name of the method to check.
* @param parameterTypes Parameters present for that method.
* @return True if method exists, else false.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean hasMethod(@NotNull Class<?> clazz, @NotNull String methodName, Class<?>... parameterTypes) {
return tryGetMethod(clazz, methodName, parameterTypes).isSuccess();
}
/**
* Try to invoke a {@link Method} on a class instance.
*
* @param classInstance Instance of the class responsible for the method.
* @param method The method to invoke.
* @param parameters Parameters needed when invoking the method.
* @param <C> The class type.
* @param <R> The return type of the method.
* @return A {@link Try} containing the return value of the method if found, else a failure.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
@SuppressWarnings("unchecked")
public static <C, R> Try<R> tryInvokeMethod(@NotNull C classInstance, @NotNull Method method, Object...parameters) {
return Try.of(() -> (R) method.invoke(classInstance, parameters));
}
/**
* Try to invoke a static {@link Method}.
*
* @param method The static method to invoke.
* @param parameters Parameters needed when invoking the method.
* @param <R> The return type of the method.
* @return A {@link Try} containing the return value of the method if found, else a failure.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
@SuppressWarnings("unchecked")
public static <R> Try<R> tryInvokeStaticMethod(@NotNull Method method, Object...parameters) {
return Try.of(() -> (R) method.invoke(null, parameters));
}
/**
* Try to get a {@link Field} from a given class.
*
* @param clazz The class to search the field on.
* @param fieldName Name of the field to get.
* @param <C> The class type.
* @return A {@link Try} containing the {@link Field} if found, else a failure.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static <C> Try<Field> tryGetField(@NotNull Class<C> clazz, @NotNull String fieldName) {
return Try.of(() -> clazz.getField(fieldName))
.orElse(Try.of(() -> {
Field field = clazz.getDeclaredField(fieldName);
field.setAccessible(true);
return field;
}));
}
/**
* Check if a {@link Field} exists on a given class.
*
* @param clazz The class to search the field on.
* @param fieldName Name of the field to check.
* @return True if field exists, else false.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean hasField(@NotNull Class<?> clazz, @NotNull String fieldName) {
return tryGetField(clazz, fieldName).isSuccess();
}
/**
* Try to get the value of a {@link Field} from an instance of the class responsible.
*
* @param classInstance Instance of the class to get the field value from.
* @param field The field to get the value from.
* @param fieldType Type of the field.
* @param <C> The class type.
* @param <V> The field value type.
* @return A {@link Try} containing the field value if found, else a failure.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static <C, V> Try<V> tryGetFieldValue(@NotNull C classInstance, @NotNull Field field, @NotNull Class<V> fieldType) {
return Try.of(() -> fieldType.cast(field.get(classInstance)));
}
/**
* Try to get the value of a static {@link Field}.
*
* @param field The static field to get the value from.
* @param fieldType Type of the field.
* @param <V> The field value type.
* @return A {@link Try} containing the field value if found, else a failure.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static <V> Try<V> tryGetStaticFieldValue(@NotNull Field field, @NotNull Class<V> fieldType) {
return Try.of(() -> fieldType.cast(field.get(null)));
}
/**
* Try to get the {@link Class} based on its classpath.
*
* @param classPath The target classpath.
* @return A {@link Class} if found, else null.
*
* @deprecated Use {@link #tryGetClass(String)} instead, which returns a {@link Try} that can be used to handle
* the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
public static Class<?> getClass(String classPath) {
try {
@@ -26,16 +198,6 @@ public final class ReflectHelper {
}
}
/**
* Check if the {@link Class} for a give classpath is present/valid.
*
* @param classPath Target classpath.
* @return True if class path is a valid class, else false.
*/
public static boolean hasClass(String classPath) {
return getClass(classPath) != null;
}
/**
* Try to get a {@link Method} from a given class.
*
@@ -44,7 +206,11 @@ public final class ReflectHelper {
* @param parameterTypes Parameters present for that method.
* @param <C> The class type.
* @return A {@link Method} if found, else null.
*
* @deprecated Use {@link #tryGetMethod(Class, String, Class[])} instead, which returns a {@link Try} that can be
* used to handle the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
public static <C> Method getMethod(Class<C> clazz, String methodName, Class<?>... parameterTypes) {
try {
@@ -64,7 +230,11 @@ public final class ReflectHelper {
* @param parameterTypes Parameters present for that method.
* @param <C> The class type.
* @return A {@link Method} if found, else null.
*
* @deprecated Use {@link #tryGetMethod(Class, String, Class[])} instead, which returns a {@link Try} that can be
* used to handle the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
public static <C> Method getMethod(C classInstance, String methodName, Class<?>... parameterTypes) {
return getMethod(classInstance.getClass(), methodName, parameterTypes);
@@ -79,8 +249,13 @@ public final class ReflectHelper {
* @param <C> The class type.
* @param <R> The return type.
* @return Return value of the method call if any, else null.
*
* @deprecated Use {@link #tryInvokeMethod(Object, Method, Object...)} instead, which returns a {@link Try} that can
* be used to handle the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
@SuppressWarnings("unchecked")
public static <C, R> R invokeMethod(C classInstance, Method method, Object...parameters) {
try {
return (R) method.invoke(classInstance, parameters);
@@ -96,7 +271,11 @@ public final class ReflectHelper {
* @param fieldName Name of the field to get.
* @param <C> The class type.
* @return A {@link Field} if found, else null.
*
* @deprecated Use {@link #tryGetField(Class, String)} instead, which returns a {@link Try} that can be used to
* handle the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
public static <C> Field getField(Class<C> clazz, String fieldName) {
try {
@@ -115,7 +294,11 @@ public final class ReflectHelper {
* @param fieldName Name of the field to get.
* @param <C> The class type.
* @return A {@link Field} if found, else null.
*
* @deprecated Use {@link #tryGetField(Class, String)} instead, which returns a {@link Try} that can be used to
* handle the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
public static <C> Field getField(C classInstance, String fieldName) {
return getField(classInstance.getClass(), fieldName);
@@ -130,7 +313,11 @@ public final class ReflectHelper {
* @param <C> The class type.
* @param <V> The field value type.
* @return The field value if any, else null.
*
* @deprecated Use {@link #tryGetFieldValue(Object, Field, Class)} instead, which returns a {@link Try} that can be
* used to handle the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
public static <C, V> V getFieldValue(C classInstance, @Nullable Field field, @NotNull Class<V> fieldType) {
try {
@@ -153,7 +340,11 @@ public final class ReflectHelper {
* @param <C> The class type.
* @param <V> The field value type.
* @return The field value if any, else null.
*
* @deprecated Use {@link #tryGetField(Class, String)} then map to {@link #tryGetFieldValue(Object, Field, Class)} instead,
* which returns a {@link Try} that can be used to handle the failure case more explicitly.
*/
@Deprecated(forRemoval = true, since = "5.7")
@Nullable
public static <C, V> V getFieldValue(C classInstance, @Nullable String fieldName, @NotNull Class<V> fieldType) {
return getFieldValue(classInstance, getField(classInstance, fieldName), fieldType);
@@ -3,6 +3,7 @@ package org.mvplugins.multiverse.core.utils;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
import jakarta.inject.Inject;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
@@ -14,6 +15,21 @@ import java.util.Map;
@Service
public final class ServerProperties {
private static ServerProperties instance;
/**
* A very bad static way of obtaining the level name from server.properties of older mc versions that
* don't have any way of getting main level name.
* <br />
* For internal use ONLY!
*
* @return The level name if it can be obtained, else none.
*/
@ApiStatus.Internal
public static Option<String> getStaticLevelName() {
return Option.of(instance).flatMap(ServerProperties::getLevelName);
}
private final Map<String, String> properties;
private final FileUtils fileUtils;
@@ -22,6 +38,8 @@ public final class ServerProperties {
this.fileUtils = fileUtils;
properties = new HashMap<>();
parseServerPropertiesFile();
instance = this;
}
private void parseServerPropertiesFile() {
@@ -2,13 +2,17 @@ package org.mvplugins.multiverse.core.utils;
import com.google.common.base.Strings;
import com.google.common.collect.Sets;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
@@ -30,15 +34,15 @@ public final class StringFormatter {
* @param list the list of strings to join. If the list is empty, an empty string is returned.
* @return the concatenated string
*/
public static @NotNull String joinAnd(List<String> list) {
public static @NotNull String joinAnd(@Nullable List<String> list) {
return join(list, ", ", " and ");
}
public static @NotNull String join(Collection list, String separator) {
public static @NotNull String join(@Nullable Collection<?> list, @NotNull String separator) {
if (list == null || list.isEmpty()) {
return "";
}
return list.stream().map(String::valueOf).collect(Collectors.joining(separator)).toString();
return list.stream().map(String::valueOf).collect(Collectors.joining(separator));
}
/**
@@ -50,7 +54,7 @@ public final class StringFormatter {
* @param lastSeparator the separator to use before the last element. For example, " and ".
* @return the concatenated string
*/
public static @NotNull String join(List<String> list, String separator, String lastSeparator) {
public static @NotNull String join(@Nullable List<String> list, @NotNull String separator, @NotNull String lastSeparator) {
if (list == null || list.isEmpty()) {
return "";
}
@@ -71,11 +75,30 @@ public final class StringFormatter {
/**
* Appends a list of suggestions to the end of the input string, separated by commas.
*
* @param input The current input
* @param addons The autocomplete suggestions
* @return A collection of suggestions with the next suggestion appended
*
* @deprecated Method name has a spelling error. Use {@link #addOnToCommaSeparated(String, Collection)} instead.
*/
@Deprecated(forRemoval = true, since = "5.5")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public static Collection<String> addonToCommaSeperated(@Nullable String input, @NotNull Collection<String> addons) {
return addOnToCommaSeparated(input, addons);
}
/**
* Appends a list of suggestions to the end of the input string, separated by commas.
*
* @param input The current input
* @param addons The autocomplete suggestions
* @return A collection of suggestions with the next suggestion appended
*
* @since 5.5
*/
@ApiStatus.AvailableSince("5.5")
public static Collection<String> addOnToCommaSeparated(@Nullable String input, @NotNull Collection<String> addons) {
if (Strings.isNullOrEmpty(input)) {
return addons;
}
@@ -94,7 +117,7 @@ public final class StringFormatter {
* @param args The args to parse
* @return The parsed args
*/
public static Collection<String> parseQuotesInArgs(String[] args) {
public static @NotNull Collection<String> parseQuotesInArgs(@NotNull String[] args) {
List<String> result = new ArrayList<>(args.length);
StringBuilder current = new StringBuilder();
boolean inQuotes = false;
@@ -139,7 +162,28 @@ public final class StringFormatter {
* @param input The string to add quotes to
* @return The quoted string
*/
public static String quoteMultiWordString(String input) {
return input.contains(" ") ? "\"" + input + "\"" : input;
@Contract("null -> null")
public static @Nullable String quoteMultiWordString(@Nullable String input) {
return input != null && input.contains(" ") ? "\"" + input + "\"" : input;
}
/**
* Parses a CSV string of key=value pairs into a map.
* E.g. "key1=value1,key2=value2" -> {key1=value1, key2=value2}
*
* @param input The CSV string to parse
* @return The parsed map
*
* @since 5.5
*/
@ApiStatus.AvailableSince("5.5")
public static @Unmodifiable Map<String, String> parseCSVMap(@Nullable String input) {
if (Strings.isNullOrEmpty(input)) {
return Map.of();
}
return REPatterns.COMMA.splitAsStream(input)
.map(s -> REPatterns.EQUALS.split(s, 2))
.filter(parts -> parts.length == 2)
.collect(Collectors.toUnmodifiableMap(parts -> parts[0], parts -> parts[1]));
}
}
@@ -1,7 +1,7 @@
package org.mvplugins.multiverse.core.utils;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Try;
import io.vavr.control.Option;
import jakarta.inject.Inject;
import org.bukkit.Server;
import org.bukkit.scheduler.BukkitRunnable;
@@ -10,8 +10,6 @@ import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.MultiverseCore;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.Objects;
/**
* Defers action that cannot be done during world tick.
@@ -21,26 +19,22 @@ public final class WorldTickDeferrer {
private final MultiverseCore plugin;
private Object console = null;
private Field isIteratingOverLevelsMethod = null;
private final Option<Object> console;
private final Option<Field> isIteratingOverLevelsMethod;
@Inject
WorldTickDeferrer(@NotNull MultiverseCore plugin, @NotNull Server server) {
this.plugin = plugin;
Method getServerMethod = ReflectHelper.getMethod(server.getClass(), "getServer");
if (getServerMethod == null) {
Logging.fine("Unable to find getServer method.");
return;
}
this.console = ReflectHelper.invokeMethod(server, getServerMethod);
if (console == null) {
Logging.fine("Unable to find console.");
return;
}
this.isIteratingOverLevelsMethod = Try.of(() -> console.getClass().getField("isIteratingOverLevels")).getOrNull();
if (isIteratingOverLevelsMethod == null) {
Logging.fine("Unable to find isIteratingOverLevels field.");
}
this.console = ReflectHelper.tryGetMethod(server.getClass(), "getServer")
.onFailure(throwable -> Logging.fine("Unable to find getServer method."))
.flatMap(getServerMethod -> ReflectHelper.tryInvokeMethod(server, getServerMethod))
.onFailure(throwable -> Logging.fine("Unable to find console."))
.toOption();
this.isIteratingOverLevelsMethod = console.toTry()
.map(Object::getClass)
.flatMap(consoleClazz -> ReflectHelper.tryGetField(consoleClazz, "isIteratingOverLevels"))
.onFailure(throwable -> Logging.fine("Unable to find isIteratingOverLevels field."))
.toOption();
}
/**
@@ -63,16 +57,14 @@ public final class WorldTickDeferrer {
}
/**
* Check if the the server is currently doing a world tick.
* Check if the server is currently doing a world tick.
*
* @return True if the server is currently doing a world tick
*/
private boolean isIteratingOverLevels() {
if (console == null || isIteratingOverLevelsMethod == null) {
return false;
}
return Objects.requireNonNullElse(
ReflectHelper.getFieldValue(console, isIteratingOverLevelsMethod, Boolean.class),
false);
return isIteratingOverLevelsMethod
.flatMap(field -> console
.flatMap(c -> ReflectHelper.tryGetFieldValue(c, field, Boolean.class).toOption()))
.getOrElse(false);
}
}
@@ -0,0 +1,123 @@
package org.mvplugins.multiverse.core.utils.compatibility;
import io.vavr.control.Option;
import io.vavr.control.Try;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.bukkit.Server;
import org.bukkit.World;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import java.lang.reflect.Method;
import java.nio.file.Path;
/**
* Compatibility class used to handle API changes in {@link Bukkit} class.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
public final class BukkitCompatibility {
private static final Try<Method> GET_LEVEL_DIRECTORY_METHOD;
private static final Try<Method> GET_WORLD_NAMESPACED_KEY_METHOD;
static {
GET_LEVEL_DIRECTORY_METHOD = ReflectHelper.tryGetMethod(Server.class, "getLevelDirectory");
GET_WORLD_NAMESPACED_KEY_METHOD = ReflectHelper.tryGetMethod(Bukkit.class, "getWorld", NamespacedKey.class);
}
/**
* Check whether the server is using the new dimension storage system introduced in PaperMC 26.1.
* <br />
* This is based of whether getLevelDirectory method exists in the server class,which is the main API change for
* the new dimension storage system.
*
* @return True if the server is using the new dimension storage system, else false.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
public static boolean isUsingNewDimensionStorage() {
return GET_LEVEL_DIRECTORY_METHOD
.flatMap(method -> ReflectHelper.tryInvokeMethod(Bukkit.getServer(), method))
.isSuccess();
}
/**
* Gets the folder where all the worlds will be store. Before 26.1, all worlds are stored in the root directory
* of the server, which can be obtained by {@link Server#getWorldContainer()}.
* <br />
* After 26.1, PaperMC changed all worlds are stored in the "[level]/dimensions" folder under the world
* level directory, which needs to be manually parsed.
*
* @return The location where all the worlds folders should be, depending on server's mc version.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
@NotNull
public static Path getWorldFoldersDirectory() {
Server server = Bukkit.getServer();
return GET_LEVEL_DIRECTORY_METHOD.flatMap(method -> ReflectHelper.tryInvokeMethod(server, method))
.filter(Path.class::isInstance)
.map(Path.class::cast)
.map(path -> path.resolve("dimensions"))
.getOrElse(() -> server.getWorldContainer().toPath());
}
/**
* Check if the world with the given name or namespaced key (e.g. minecraft:overworld) exists,
* and return it if it does.
* <br />
* Note that some default world names have different namespaced key matched with them.
* E.g.: world -> minecraft:overworld, world_nether -> minecraft:the_nether, world_the_end -> minecraft:the_end.
*
* @param nameOrKey Either a name or namespaced key string representation.
* @return The world if it exists
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
@NotNull
public static Option<World> getWorldByNameOrKey(@NotNull String nameOrKey) {
return Option.of(Bukkit.getWorld(nameOrKey))
.orElse(() -> GET_WORLD_NAMESPACED_KEY_METHOD
.flatMap(method -> ReflectHelper.tryInvokeStaticMethod(method, NamespacedKey.fromString(nameOrKey)))
.filter(World.class::isInstance)
.map(World.class::cast)
.toOption());
}
/**
* Check if the world represented by the given {@link WorldKeyOrName} exists and return it if present.
*
* <p>This first attempts to resolve the world by name using {@link WorldKeyOrName#usableName()} (which
* delegates to the traditional Bukkit world name lookup). If that fails it will attempt to resolve a
* {@link NamespacedKey} using {@link WorldKeyOrName#usableKey()} (which mirrors the newer Bukkit API
* that accepts namespaced keys, e.g. "minecraft:overworld").
*
* @param keyOrName The key-or-name wrapper providing either a world name or a {@link NamespacedKey}.
* @return The world if it exists.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static Option<World> getWorldByNameOrKey(@NotNull WorldKeyOrName keyOrName) {
return Option.of(Bukkit.getWorld(keyOrName.usableName()))
.orElse(() -> GET_WORLD_NAMESPACED_KEY_METHOD
.flatMap(method -> ReflectHelper.tryInvokeStaticMethod(method, keyOrName.usableKey()))
.filter(World.class::isInstance)
.map(World.class::cast)
.toOption());
}
private BukkitCompatibility() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}
@@ -0,0 +1,45 @@
package org.mvplugins.multiverse.core.utils.compatibility;
import io.vavr.control.Option;
import io.vavr.control.Try;
import org.bukkit.Bukkit;
import org.bukkit.UnsafeValues;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import java.lang.reflect.Method;
/**
* Compatibility class used to handle API changes in {@link UnsafeValues} class.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.7")
public final class UnsafeValuesCompatibility {
private static final Try<Method> GET_MAIN_LEVEL_NAME_METHOD;
static {
GET_MAIN_LEVEL_NAME_METHOD = ReflectHelper.tryGetMethod(UnsafeValues.class, "getMainLevelName");
}
/**
* Try to call the getMainLevelName method in UnsafeValues, which is used to get level-name configured in
* server.properties file.
*
* @return The level-name configured in server.properties file, or empty if the method is not available.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static Option<String> getMainLevelName() {
return GET_MAIN_LEVEL_NAME_METHOD
.flatMap(method -> ReflectHelper.tryInvokeMethod(Bukkit.getUnsafe(), method))
.map(String.class::cast)
.toOption();
}
private UnsafeValuesCompatibility() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}
@@ -0,0 +1,170 @@
package org.mvplugins.multiverse.core.utils.compatibility;
import org.bukkit.WorldBorder;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import org.mvplugins.multiverse.core.utils.tick.TickDuration;
import java.util.concurrent.TimeUnit;
/**
* Compatibility class used to handle API changes in {@link WorldBorder} class.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@SuppressWarnings("removal")
public final class WorldBorderCompatibility {
private static final boolean HAS_GET_WARNING_TIME_TICKS_METHOD;
private static final boolean HAS_SET_WARNING_TIME_TICKS_METHOD;
private static final boolean HAS_SET_WARNING_TIME_METHOD;
private static final boolean HAS_GET_WARNING_TIME_METHOD;
private static final boolean HAS_CHANGE_SIZE_METHOD;
private static final boolean HAS_SET_SIZE_METHOD;
static {
HAS_GET_WARNING_TIME_TICKS_METHOD = ReflectHelper.hasMethod(WorldBorder.class, "getWarningTimeTicks");
HAS_SET_WARNING_TIME_TICKS_METHOD = ReflectHelper.hasMethod(WorldBorder.class, "setWarningTimeTicks", int.class);
HAS_GET_WARNING_TIME_METHOD = ReflectHelper.hasMethod(WorldBorder.class, "getWarningTime");
HAS_SET_WARNING_TIME_METHOD = ReflectHelper.hasMethod(WorldBorder.class, "setWarningTime", int.class);
HAS_CHANGE_SIZE_METHOD = ReflectHelper.hasMethod(WorldBorder.class, "changeSize", double.class, long.class);
HAS_SET_SIZE_METHOD = ReflectHelper.hasMethod(WorldBorder.class, "setSize", double.class, long.class);
}
/**
* Gets the world border warning time in ticks, using the most precise API available.
* <br />
* If the server exposes {@code getWarningTimeTicks()}, that value is returned directly. Otherwise, the older
* {@code getWarningTime()} API is used and converted from seconds to ticks.
*
* @param worldBorder The world border to query.
* @return The warning time in ticks.
*
* @since 5.7
* @throws IllegalStateException If neither warning-time getter is available.
*/
@ApiStatus.AvailableSince("5.7")
public static int getWarningTimeTicks(WorldBorder worldBorder) {
if (HAS_GET_WARNING_TIME_TICKS_METHOD) {
return worldBorder.getWarningTimeTicks();
} else if (HAS_GET_WARNING_TIME_METHOD) {
return worldBorder.getWarningTime() * 20;
}
throw new IllegalStateException("Neither getWarningTimeTicks nor getWarningTime method is available in WorldBorder class.");
}
/**
* Gets the world border warning time in seconds, using the most appropriate API available.
* <br />
* If the server exposes {@code getWarningTimeTicks()}, that value is converted from ticks to seconds. Otherwise,
* the older {@code getWarningTime()} API is used directly.
*
* @param worldBorder The world border to query.
* @return The warning time in seconds.
*
* @since 5.7
* @throws IllegalStateException If neither warning-time getter is available.
*/
@ApiStatus.AvailableSince("5.7")
public static double getWarningTime(WorldBorder worldBorder) {
if (HAS_GET_WARNING_TIME_TICKS_METHOD) {
return (double) worldBorder.getWarningTimeTicks() / 20.0;
} else if (HAS_GET_WARNING_TIME_METHOD) {
return worldBorder.getWarningTime();
}
throw new IllegalStateException("Neither getWarningTimeTicks nor getWarningTime method is available in WorldBorder class.");
}
/**
* Checks whether the current server supports setting the world border warning time in ticks.
*
* @return True if {@code setWarningTimeTicks(int)} is available, else false.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean supportsSetWarningTimeInTicks() {
return HAS_SET_WARNING_TIME_TICKS_METHOD;
}
/**
* Sets the world border warning time in ticks.
* <br />
* This is a convenience wrapper around {@link #setWarningTimeDuration(WorldBorder, TickDuration)} that converts
* the given tick value into a {@link TickDuration} first.
*
* @param worldBorder The world border to update.
* @param ticks The warning time in ticks.
*
* @since 5.7
*/
public static void setWarningTimeTicks(WorldBorder worldBorder, int ticks) {
setWarningTimeDuration(worldBorder, TickDuration.ofTicks(ticks));
}
/**
* Sets the world border warning time, preferring tick-based APIs when available.
* <br />
* If the server supports {@code setWarningTimeTicks(int)}, the provided duration is applied in ticks. Otherwise,
* the older {@code setWarningTime(int)} API is used and the duration is converted to seconds.
*
* @param worldBorder The world border to update.
* @param duration The warning duration to apply.
*
* @since 5.7
* @throws IllegalStateException If neither warning-time setter is available.
*/
@ApiStatus.AvailableSince("5.7")
public static void setWarningTimeDuration(WorldBorder worldBorder, TickDuration duration) {
if (HAS_SET_WARNING_TIME_TICKS_METHOD) {
worldBorder.setWarningTimeTicks((int) duration.toTicks());
} else if (HAS_SET_WARNING_TIME_METHOD) {
worldBorder.setWarningTime((int) Math.round(duration.to(TimeUnit.SECONDS)));
} else {
throw new IllegalStateException("Neither setWarningTimeTicks nor setWarningTime method is available in WorldBorder class.");
}
}
/**
* Checks whether the current server supports changing world border size over a duration in ticks.
*
* @return True if {@code changeSize(double, long)} is available, else false.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean supportsChangeSizeInTicks() {
return HAS_CHANGE_SIZE_METHOD;
}
/**
* Changes the world border size, preferring the tick-based API when available.
* <br />
* If the server supports {@code changeSize(double, long)}, the size change is applied in ticks. Otherwise, the
* older {@code setSize(double, long)} API is used and the duration is converted to seconds.
*
* @param worldBorder The world border to update.
* @param newSize The new border size.
* @param duration The transition duration to apply.
*
* @since 5.7
* @throws IllegalStateException If neither size-changing API is available.
*/
@ApiStatus.AvailableSince("5.7")
public static void changeSizeDuration(WorldBorder worldBorder, double newSize, TickDuration duration) {
if (HAS_CHANGE_SIZE_METHOD) {
worldBorder.changeSize(newSize, duration.toTicks());
} else if (HAS_SET_SIZE_METHOD) {
worldBorder.setSize(newSize, Math.round(duration.to(TimeUnit.SECONDS)));
} else {
throw new IllegalStateException("Neither changeSize nor setSize method is available in WorldBorder class.");
}
}
private WorldBorderCompatibility() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}
@@ -0,0 +1,92 @@
package org.mvplugins.multiverse.core.utils.compatibility;
import io.papermc.lib.PaperLib;
import io.vavr.control.Try;
import org.bukkit.World;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import java.lang.reflect.Method;
/**
* Compatibility class used to handle API changes in {@link World} class.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public final class WorldCompatibility {
private static final Try<Method> SAVE_WITH_FLUSH_METHOD;
private static final boolean HAS_GET_COORDINATE_SCALE_METHOD;
private static final boolean HAS_HAS_BONUS_CHEST_METHOD;
static {
SAVE_WITH_FLUSH_METHOD = ReflectHelper.tryGetMethod(World.class, "save", boolean.class);
HAS_GET_COORDINATE_SCALE_METHOD = ReflectHelper.hasMethod(World.class, "getCoordinateScale");
HAS_HAS_BONUS_CHEST_METHOD = ReflectHelper.hasMethod(World.class, "hasBonusChest");
}
/**
* Saves the world, with an option to wait for chunk writers to finish if the method is available. Saving with
* flush is only supported on PaperMC 1.21+.
* <br />
* If the method is not available, it will fall back to the normal save method, which may not wait for chunk writers
* to finish, but is the best we can do for older versions of Minecraft.
*
* @param world The world to save
* @param flush Waits for chunk writers to finish
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static void saveWithFlush(World world, boolean flush) {
SAVE_WITH_FLUSH_METHOD
.flatMap(method -> ReflectHelper.tryInvokeMethod(world, method, flush))
.orElseRun(ignore -> world.save());
}
/**
* Gets the coordinate scale for the world, which is used to convert between overworld and nether coordinates.
* On PaperMC, this can be obtained directly from the API. On older versions, it is manually determined based on the
* world environment (overworld = 1, nether = 8, end = 1).
*
* @param world The world to get the coordinate scale for
* @return The scale
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static double getCoordinateScale(World world) {
if (HAS_GET_COORDINATE_SCALE_METHOD) {
return world.getCoordinateScale();
}
return switch (world.getEnvironment()) {
case NORMAL -> 1;
case NETHER -> 8;
default -> 1;
};
}
/**
* Checks if the world has a bonus chest.
* <br />
* On PaperMC 1.21.5+, this can be obtained directly from the API. On older versions, it always returns false as
* bonus chest is not supported.
*
* @param world The world to check
* @return True if the world has a bonus chest, false otherwise
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean hasBonusChest(World world) {
if (HAS_HAS_BONUS_CHEST_METHOD) {
return world.hasBonusChest();
}
return false;
}
private WorldCompatibility() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}
@@ -0,0 +1,188 @@
package org.mvplugins.multiverse.core.utils.compatibility;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Try;
import org.bukkit.NamespacedKey;
import org.bukkit.WorldCreator;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import org.mvplugins.multiverse.core.utils.position.EntityPosition;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import java.lang.reflect.Method;
import java.util.Locale;
/**
* Compatibility class used to handle {@link WorldCreator} API changes across different server versions.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public final class WorldCreatorCompatibility {
private static final Try<Class<?>> POSITION_CLASS;
private static final Try<Method> FORCED_SPAWN_POSITION_METHOD;
private static final Try<Method> OF_KEY_METHOD;
private static final Try<Method> OF_NAME_AND_KEY_METHOD;
private static final Try<Method> BONUS_CHEST_METHOD;
static {
POSITION_CLASS = ReflectHelper.tryGetClass("io.papermc.paper.math.Position");
FORCED_SPAWN_POSITION_METHOD = POSITION_CLASS.flatMap(positionClass ->
ReflectHelper.tryGetMethod(WorldCreator.class, "forcedSpawnPosition", positionClass, float.class, float.class));
BONUS_CHEST_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "bonusChest", boolean.class);
OF_KEY_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "ofKey", NamespacedKey.class);
OF_NAME_AND_KEY_METHOD = ReflectHelper.tryGetMethod(WorldCreator.class, "ofNameAndKey", String.class, NamespacedKey.class);
}
/**
* Check whether the current server version supports creating worlds with a {@link NamespacedKey}.
* <br />
* This is based on whether the {@code ofKey(NamespacedKey)} method exists in the {@link WorldCreator} class,
* which was introduced in later server versions.
*
* @return True if the server supports world creation with NamespacedKey, else false.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean canCreateWorldWithKey() {
return OF_KEY_METHOD.isSuccess();
}
/**
* Creates a {@link WorldCreator} from a {@link WorldKeyOrName}, using the most appropriate method
* based on the server version and the key/name state.
* <br />
* If the server supports NamespacedKey-based creation and the provided keyOrName is a key,
* this method will use {@code ofKey()}. Otherwise, it falls back to {@code name()}.
*
* @param keyOrName Either a world key or name to create from.
* @return The {@link WorldCreator} initialized for the given key or name.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static WorldCreator ofKeyOrName(@NotNull WorldKeyOrName keyOrName) {
return ofNameAndKey(keyOrName.usableKey(), keyOrName.usableName());
}
/**
* Creates a {@link WorldCreator} from a {@link NamespacedKey} and world name, using the most
* appropriate method based on the server version and dimension storage configuration.
* <br />
* This method attempts to use the most feature-rich creation method available:
* if {@code ofNameAndKey()} is available and the parameters are compatible, use it;
* otherwise, if {@code ofKey()} is available, use it; as a fallback, use {@code name()}.
*
* @param worldKey The namespaced key for the world.
* @param worldName The display name for the world.
* @return The {@link WorldCreator} initialized with the given key and name.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static WorldCreator ofNameAndKey(@NotNull NamespacedKey worldKey, String worldName) {
if (OF_NAME_AND_KEY_METHOD.isSuccess() && canPassIntoNameAndKey(worldKey)) {
return WorldCreator.ofNameAndKey(worldName, worldKey);
}
if (OF_KEY_METHOD.isSuccess()) {
return WorldCreator.ofKey(worldKey);
}
return WorldCreator.name(worldName);
}
/**
* Check if the given world key and name can be passed into the {@code ofNameAndKey()} method.
* <br />
* The parameters are compatible only if the server is not using new dimension storage,
* or if the namespace is the Minecraft namespace and the world name (lowercased) matches the key.
*
* @param worldKey The namespaced key for the world.
* @return True if the parameters can be safely passed to {@code ofNameAndKey()}, else false.
*/
private static boolean canPassIntoNameAndKey(@NotNull NamespacedKey worldKey) {
return !BukkitCompatibility.isUsingNewDimensionStorage()
|| worldKey.getNamespace().equals(NamespacedKey.MINECRAFT);
}
/**
* Checks if the server supports configuring a forced spawn position via the WorldCreator API.
* <br />
* The force spawn position API is generally only available on PaperMC 26.1+
*
* @return Whether force spawn position API is supported on the current server version.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean supportsForcedSpawnPosition() {
return FORCED_SPAWN_POSITION_METHOD.isSuccess();
}
/**
* Tries to set the forced spawn position if the server implements the API. This call will do nothing if server
* software does not implement the required APIs.
*
* @param worldCreator Target creator instance to set on.
* @param position The position to set as the forced spawn point for the world.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static void setForcedSpawnPosition(WorldCreator worldCreator, EntityPosition position) {
if (!supportsForcedSpawnPosition()) {
Logging.fine("Server does not support forced spawn position configuration via WorldCreator API.");
return;
}
ReflectHelper.tryInvokeMethod(
worldCreator,
FORCED_SPAWN_POSITION_METHOD.get(),
io.papermc.paper.math.Position.fine(
position.getVector().getX().getRawValue(),
position.getVector().getY().getRawValue(),
position.getVector().getZ().getRawValue()
),
(float) position.getDirection().getYaw().getRawValue(),
(float) position.getDirection().getPitch().getRawValue()
).onFailure(ex ->
Logging.warning("Failed to set forced spawn position on WorldCreator: %s", ex.getMessage()));
}
/**
* Checks if the server supports configuring bonus chest generation via the WorldCreator API.
* <br />
* The bonus chest API is generally only available on PaperMC 1.21.5+
*
* @return Whether bonus chest API is supported on the current server version.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static boolean supportsBonusChest() {
return BONUS_CHEST_METHOD.isSuccess();
}
/**
* Tries to set bonus chest if the server implements the API. This call will do nothing if server software does not
* implement the required APIs.
*
* @param worldCreator Target creator instance to set on.
* @param generateBonusChest Whether to generate a bonus chest at the world spawn point.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static void setBonusChest(WorldCreator worldCreator, boolean generateBonusChest) {
if (!supportsBonusChest()) {
Logging.fine("Server does not support bonus chest generation via WorldCreator API.");
return;
}
worldCreator.bonusChest(generateBonusChest);
}
private WorldCreatorCompatibility() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}
@@ -0,0 +1,5 @@
/**
* Contains classes to handle compatibility due to differing API methods across different server versions and the
* API gaps between PaperMC and Spigot.
*/
package org.mvplugins.multiverse.core.utils.compatibility;
@@ -2,6 +2,7 @@ package org.mvplugins.multiverse.core.utils.result;
import java.util.function.Consumer;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.function.Supplier;
import io.vavr.control.Either;
@@ -19,6 +20,38 @@ import org.mvplugins.multiverse.core.locale.message.MessageReplacement;
*/
public sealed interface Attempt<T, F extends FailureReason> permits Attempt.Success, Attempt.Failure {
/**
* Creates a new success attempt.
*
* @param value The value.
* @param <T> The type of the value.
* @param <F> The type of failure reason.
* @return The new success attempt.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
static <T, F extends FailureReason> Attempt.Success<T, F> successRef(T value) {
return new Success<>(value);
}
/**
* Creates a new failure attempt.
*
* @param failureReason The reason for failure.
* @param messageReplacements The replacements for the failure message.
* @param <T> The type of the value.
* @param <F> The type of failure reason.
* @return The new failure attempt.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
static <T, F extends FailureReason> Attempt.Failure<T, F> failureRef(
F failureReason, MessageReplacement... messageReplacements) {
return new Failure<>(failureReason, Message.of(failureReason, "Failed!", messageReplacements));
}
/**
* Creates a new success attempt.
*
@@ -28,7 +61,7 @@ public sealed interface Attempt<T, F extends FailureReason> permits Attempt.Succ
* @return The new success attempt.
*/
static <T, F extends FailureReason> Attempt<T, F> success(T value) {
return new Success<>(value);
return successRef(value);
}
/**
@@ -42,7 +75,7 @@ public sealed interface Attempt<T, F extends FailureReason> permits Attempt.Succ
*/
static <T, F extends FailureReason> Attempt<T, F> failure(
F failureReason, MessageReplacement... messageReplacements) {
return new Failure<>(failureReason, Message.of(failureReason, "Failed!", messageReplacements));
return failureRef(failureReason, messageReplacements);
}
/**
@@ -203,6 +236,33 @@ public sealed interface Attempt<T, F extends FailureReason> permits Attempt.Succ
*/
<U> Attempt<U, F> mapAttempt(Supplier<Attempt<U, F>> mapper);
/**
* Fails this attempt with the given failure attempt if the value matches the given predicate.This will do nothing
* if this is already a failure attempt.
*
* @param predicate The predicate to test the value.
* @param failureAttempt The failure attempt to return if the predicate matches.
* @return The new attempt
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
Attempt<T, F> failIf(Predicate<? super T> predicate, Supplier<Attempt.Failure<T, F>> failureAttempt);
/**
* Fails this attempt with the given failure attempt if the value matches the given predicate.This will do nothing
* if this is already a failure attempt.
*
* @param predicate The predicate to test the value.
* @param failureAttempt The failure attempt to return if the predicate matches, which can be generated based
* on the value.
* @return The new attempt
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
Attempt<T, F> failIf(Predicate<? super T> predicate, Function<? super T, Attempt.Failure<T, F>> failureAttempt);
/**
* Maps to another attempt with a different fail reason.
*
@@ -370,6 +430,16 @@ public sealed interface Attempt<T, F extends FailureReason> permits Attempt.Succ
return mapper.get();
}
@Override
public Attempt<T, F> failIf(Predicate<? super T> predicate, Supplier<Attempt.Failure<T, F>> failureAttempt) {
return predicate.test(value) ? failureAttempt.get() : this;
}
@Override
public Attempt<T, F> failIf(Predicate<? super T> predicate, Function<? super T, Failure<T, F>> failureAttempt) {
return predicate.test(value) ? failureAttempt.apply(value) : this;
}
@Override
public <UF extends FailureReason> Attempt<T, UF> transform(UF failureReason) {
return changeFailureType();
@@ -532,6 +602,16 @@ public sealed interface Attempt<T, F extends FailureReason> permits Attempt.Succ
return changeValueType();
}
@Override
public Attempt<T, F> failIf(Predicate<? super T> predicate, Supplier<Attempt.Failure<T, F>> failureAttempt) {
return this;
}
@Override
public Attempt<T, F> failIf(Predicate<? super T> predicate, Function<? super T, Failure<T, F>> failureAttempt) {
return this;
}
@Override
public <UF extends FailureReason> Attempt<T, UF> transform(UF failureReason) {
return new Failure<>(failureReason, getFailureMessage(), this);
@@ -0,0 +1,274 @@
package org.mvplugins.multiverse.core.utils.tick;
import io.vavr.control.Try;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.Contract;
import org.jetbrains.annotations.NotNull;
import java.time.Duration;
import java.time.LocalTime;
import java.time.temporal.TemporalUnit;
import java.util.concurrent.TimeUnit;
/**
* Represents a duration in Minecraft game ticks.
* <p>
* This class wraps a {@link Duration} and provides convenient conversion methods
* between game ticks (50ms per tick), game days (24,000 ticks), and other time units.
* Game time is mapped to real-world seconds via: realSeconds = (ticks * 3.6) + 6*3600 (mod 24h),
* which means 00:00 maps to 06:00 in-game time.
* </p>
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public final class TickDuration {
/**
* Parses a time string into a {@link TickDuration}.
* <br />
* Formats supported:
* <ul>
* <li>{@code "123"} - raw tick count</li>
* <li>{@code "10s"} - seconds</li>
* <li>{@code "5d"} - game days</li>
* </ul>
*
* @param timeString the time string to parse
* @return a {@link Try} containing the parsed {@link TickDuration}, or a failure if parsing fails
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static Try<TickDuration> parseString(@NotNull String timeString) {
return Try.of(() -> {
if (timeString.endsWith("s")) {
String subString = timeString.substring(0, timeString.length() - 1);
return of(Long.parseLong(subString), TimeUnit.SECONDS);
} else if (timeString.endsWith("d")) {
String subString = timeString.substring(0, timeString.length() - 1);
return ofGameDays(Long.parseLong(subString));
}
return ofTicks(Long.parseLong(timeString));
});
}
/**
* Creates a {@link TickDuration} from a {@link Duration}.
*
* @param duration the duration
* @return a new {@link TickDuration}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Contract(value = "_ -> new", pure = true)
public static @NotNull TickDuration ofDuration(@NotNull Duration duration) {
return new TickDuration(duration);
}
/**
* Creates a {@link TickDuration} from seconds (as a {@link Duration}).
*
* @param duration the duration representing seconds
* @return a new {@link TickDuration}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Contract(value = "_ -> new", pure = true)
public static @NotNull TickDuration ofSeconds(@NotNull Duration duration) {
return new TickDuration(duration);
}
/**
* Creates a {@link TickDuration} from a tick count.
*
* @param ticks the number of game ticks
* @return a new {@link TickDuration}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Contract(value = "_ -> new", pure = true)
public static @NotNull TickDuration ofTicks(long ticks) {
return of(ticks, TickUnit.TICK);
}
/**
* Creates a {@link TickDuration} from a game day count.
*
* @param gameDays the number of game days (24,000 ticks each)
* @return a new {@link TickDuration}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Contract(value = "_ -> new", pure = true)
public static @NotNull TickDuration ofGameDays(long gameDays) {
return of(gameDays, TickUnit.GAME_DAY);
}
/**
* Creates a {@link TickDuration} from a time value and {@link TimeUnit}.
*
* @param time the time value
* @param unit the time unit
* @return a new {@link TickDuration}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Contract(value = "_, _ -> new", pure = true)
public static @NotNull TickDuration of(long time, @NotNull TimeUnit unit) {
return of(time, unit.toChronoUnit());
}
/**
* Creates a {@link TickDuration} from a time value and {@link TemporalUnit}.
*
* @param time the time value
* @param unit the temporal unit
* @return a new {@link TickDuration}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Contract(value = "_, _ -> new", pure = true)
public static @NotNull TickDuration of(long time, @NotNull TemporalUnit unit) {
return new TickDuration(Duration.of(time, unit));
}
private static final double TIME_MULTIPLIER = 3.6;
private static final long DAY_SECONDS = 24 * 60 * 60;
private static final long START_OFFSET = 6 * 60 * 60;
private final Duration duration;
/**
* Creates a {@link TickDuration} from a {@link Duration}.
*
* @param duration the underlying duration
*/
private TickDuration(@NotNull Duration duration) {
this.duration = duration;
}
/**
* Converts this duration to Minecraft local time.
* <p>
* Maps the duration to real-world seconds using the formula:
* realSeconds = (ticks * 3.6) + 6*3600 (mod 24h)
* </p>
*
* @return the {@link LocalTime} in Minecraft time (where 00:00 = 06:00 in-game)
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull LocalTime toLocalTime() {
// Minecraft maps ticks -> seconds by: realSeconds = (ticks * 3.6) + 6*3600 (mod 24h)
long realSeconds = (long) ((toTicks() * TIME_MULTIPLIER) + START_OFFSET) % DAY_SECONDS;
return LocalTime.ofSecondOfDay(realSeconds);
}
/**
* Converts this duration to game ticks.
*
* @return the number of game ticks (1 tick = 50ms)
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public long toTicks() {
return duration.toMillis() / TickUnit.TICK.inMillis();
}
/**
* Converts this duration to game days.
*
* @return the number of game days (1 day = 24,000 ticks)
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public double toGameDays() {
return (double) duration.toMillis() / (double) TickUnit.GAME_DAY.inMillis();
}
/**
* Converts this duration to seconds.
*
* @return the duration in seconds
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public double toSeconds() {
return to(TimeUnit.SECONDS);
}
/**
* Converts this duration to a specified {@link TimeUnit}.
*
* @param unit the target time unit
* @return the duration in the specified unit
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public double to(@NotNull TimeUnit unit) {
return to(unit.toChronoUnit());
}
/**
* Converts this duration to a specified {@link TemporalUnit}.
*
* @param unit the target temporal unit
* @return the duration in the specified unit
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public double to(@NotNull TemporalUnit unit) {
return (double) duration.toMillis() / (double) unit.getDuration().toMillis();
}
/**
* Checks whether this duration is an exact multiple of the given {@link TimeUnit}.
*
* @param unit the time unit to test against
* @return {@code true} if this duration divides evenly by the unit, otherwise {@code false}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public boolean isExactTo(@NotNull TimeUnit unit) {
return isExactTo(unit.toChronoUnit());
}
/**
* Checks whether this duration is an exact multiple of the given {@link TemporalUnit}.
*
* @param unit the temporal unit to test against
* @return {@code true} if this duration divides evenly by the unit, otherwise {@code false}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public boolean isExactTo(@NotNull TemporalUnit unit) {
return duration.toMillis() % unit.getDuration().toMillis() == 0;
}
/**
* Returns the underlying {@link Duration}.
*
* @return the duration
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public Duration getDuration() {
return duration;
}
}
@@ -0,0 +1,112 @@
package org.mvplugins.multiverse.core.utils.tick;
import org.jetbrains.annotations.ApiStatus;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
import java.time.temporal.Temporal;
import java.time.temporal.TemporalUnit;
/**
* A {@link TemporalUnit} implementation for Minecraft game ticks.
* <br />
* Provides two standard units:
* <ul>
* <li>{@link #TICK} - A single game tick (50 milliseconds)</li>
* <li>{@link #GAME_DAY} - A full game day (24,000 ticks)</li>
* </ul>
* This unit is not date-based and is precisely defined without estimation.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public final class TickUnit implements TemporalUnit {
private static final long SINGLE_TICK_DURATION_MS = 50L;
private static final long TICKS_PER_GAME_DAY = 24_000L;
/**
* A single game tick (50 milliseconds).
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static final TickUnit TICK = new TickUnit(SINGLE_TICK_DURATION_MS);
/**
* A full game day (24,000 ticks = 20 minutes in real time).
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static final TickUnit GAME_DAY = new TickUnit(SINGLE_TICK_DURATION_MS * TICKS_PER_GAME_DAY);
private final long milliseconds;
private final Duration duration;
private TickUnit(long milliseconds) {
this.milliseconds = milliseconds;
this.duration = Duration.ofMillis(milliseconds);
}
/**
* Gets the duration of this unit in milliseconds.
*
* @return the duration in milliseconds
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public long inMillis() {
return milliseconds;
}
/**
* {@inheritDoc}
*/
@Override
public Duration getDuration() {
return duration;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isDurationEstimated() {
return false;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isDateBased() {
return false;
}
/**
* {@inheritDoc}
*/
@Override
public boolean isTimeBased() {
return true;
}
/**
* {@inheritDoc}
*/
@SuppressWarnings("unchecked") // following ChronoUnit#addTo
@Override
public <R extends Temporal> R addTo(R temporal, long amount) {
return (R) temporal.plus(duration.multipliedBy(amount));
}
/**
* {@inheritDoc}
*/
@Override
public long between(final Temporal start, final Temporal end) {
return start.until(end, ChronoUnit.MILLIS) / milliseconds;
}
}
@@ -16,6 +16,9 @@ import javax.net.ssl.HttpsURLConnection;
* HTTP API-client.
*/
abstract sealed class HttpAPIClient permits PasteService, URLShortener {
static final String DUMPS_VIEWER_URL = "https://mvdumps.c0ding.party/";
/**
* The URL for this API-request, and if necessary, the access token.
* If an access token is not necessary, it should be set to null.
@@ -34,7 +34,9 @@ final class McloGsPasteService extends PasteService {
public String postData(String data) throws PasteFailedException {
try {
String stringJSON = this.exec(encodeData(data), ContentType.URLENCODED); // Execute request
return String.valueOf(((JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(stringJSON)).get("url")); // Interpret result
return DUMPS_VIEWER_URL +
((JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(stringJSON)).get("id") +
"?service=mclogs";
} catch (IOException | ParseException e) {
throw new PasteFailedException(e);
}
@@ -44,7 +46,9 @@ final class McloGsPasteService extends PasteService {
public String postData(Map<String, String> data) throws PasteFailedException {
try {
String stringJSON = this.exec(encodeData(data), ContentType.JSON); // Execute request
return String.valueOf(((JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(stringJSON)).get("url")); // Interpret result
return DUMPS_VIEWER_URL +
((JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(stringJSON)).get("id") +
"?service=mclogs";
} catch (IOException | ParseException e) {
throw new PasteFailedException(e);
}
@@ -26,7 +26,7 @@ final class PastebinPasteService extends PasteService {
"&" + URLEncoder.encode("api_option", StandardCharsets.UTF_8) + "=" + URLEncoder.encode("paste", StandardCharsets.UTF_8) +
"&" + URLEncoder.encode("api_paste_code", StandardCharsets.UTF_8) + "=" + URLEncoder.encode(data, StandardCharsets.UTF_8) +
"&" + URLEncoder.encode("api_paste_private", StandardCharsets.UTF_8) + "=" + URLEncoder.encode(this.isPrivate ? "1" : "0", StandardCharsets.UTF_8) +
"&" + URLEncoder.encode("api_paste_format", StandardCharsets.UTF_8) + "=" + URLEncoder.encode("yaml", StandardCharsets.UTF_8) +
"&" + URLEncoder.encode("api_paste_format", StandardCharsets.UTF_8) + "=" + URLEncoder.encode("json", StandardCharsets.UTF_8) +
"&" + URLEncoder.encode("api_paste_name", StandardCharsets.UTF_8) + "=" + URLEncoder.encode("Multiverse-Core Debug Info", StandardCharsets.UTF_8);
}
@@ -39,8 +39,9 @@ final class PastesDevPasteService extends PasteService {
@Override
public String postData(String data) throws PasteFailedException {
try {
String stringJSON = this.exec(encodeData(data), ContentType.PLAINTEXT_YAML);
return "https://pastes.dev/" + ((JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(stringJSON)).get("key");
String stringJSON = this.exec(encodeData(data), ContentType.JSON);
return DUMPS_VIEWER_URL +
((JSONObject) new JSONParser(JSONParser.DEFAULT_PERMISSIVE_MODE).parse(stringJSON)).get("key");
} catch (IOException | ParseException e) {
throw new PasteFailedException(e);
}
@@ -39,7 +39,7 @@ public final class LoadedMultiverseWorld extends MultiverseWorld {
@NotNull LocationManipulation locationManipulation,
@NotNull EntityPurger entityPurger
) {
super(world.getName(), worldConfig, config);
super(worldConfig, config);
this.worldUid = world.getUID();
this.blockSafety = blockSafety;
this.locationManipulation = locationManipulation;
@@ -77,7 +77,7 @@ public final class LoadedMultiverseWorld extends MultiverseWorld {
Logging.fine("Spawn location from world.dat file was unsafe!!");
Logging.fine("NOT adjusting spawn for '" + this.getAliasOrName() + "' because you told me not to.");
Logging.fine("To turn on spawn adjustment for this world simply type:");
Logging.fine("/mvm set adjustspawn true " + this.getAliasOrName());
Logging.fine("/mv modify %s set adjust-spawn true", getName());
return location;
}
@@ -173,7 +173,7 @@ public final class LoadedMultiverseWorld extends MultiverseWorld {
* {@inheritDoc}
*/
@Override
void setWorldConfig(WorldConfig worldConfig) {
void setWorldConfig(@NotNull WorldConfig worldConfig) {
super.setWorldConfig(worldConfig);
setupWorldConfig(getBukkitWorld().get());
}
@@ -184,7 +184,8 @@ public final class LoadedMultiverseWorld extends MultiverseWorld {
@Override
public String toString() {
return "LoadedMultiverseWorld{"
+ "name='" + worldName + "', "
+ "key='" + getKey() + "', "
+ "name='" + getName() + "', "
+ "env='" + getEnvironment() + "', "
+ "type='" + getWorldType().getOrNull() + "', "
+ "gen='" + getGenerator() + "'"
@@ -1,21 +1,29 @@
package org.mvplugins.multiverse.core.world;
import java.io.File;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import com.google.common.base.Strings;
import io.vavr.control.Option;
import io.vavr.control.Try;
import org.bukkit.Bukkit;
import org.bukkit.Difficulty;
import org.bukkit.GameMode;
import org.bukkit.Location;
import org.bukkit.Material;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import org.mvplugins.multiverse.core.config.CoreConfig;
import org.mvplugins.multiverse.core.config.handle.StringPropertyHandle;
import org.mvplugins.multiverse.core.utils.text.ChatTextFormatter;
import org.mvplugins.multiverse.core.world.helpers.WorldFolderResolver;
import org.mvplugins.multiverse.core.world.location.SpawnLocation;
import org.mvplugins.multiverse.core.world.entity.EntitySpawnConfig;
@@ -23,11 +31,6 @@ import org.mvplugins.multiverse.core.world.entity.EntitySpawnConfig;
* Represents a world handled by Multiverse which has all the custom properties provided by Multiverse.
*/
public sealed class MultiverseWorld permits LoadedMultiverseWorld {
/**
* This world's name.
*/
protected final String worldName;
/**
* This world's configuration.
*/
@@ -36,8 +39,7 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
protected final CoreConfig config;
private String colourlessAlias = "";
MultiverseWorld(String worldName, WorldConfig worldConfig, CoreConfig config) {
this.worldName = worldName;
MultiverseWorld(WorldConfig worldConfig, CoreConfig config) {
this.worldConfig = worldConfig;
this.config = config;
this.worldConfig.setMVWorld(this);
@@ -45,15 +47,46 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
}
/**
* Gets the name of this world. The name cannot be changed.
* The key that represents this world. This should now be used as the unique key for the world instead
* of the world name. Also note that the key cannot be modified.
*
* @return The world key
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public NamespacedKey getKey() {
return worldConfig.getWorldKeyOrName().usableKey();
}
/**
* Gets the name of this world. Prefer to use {@link #getKey()} as unique id instead of this name.
* Also note that the name cannot be modified.
* <br/>
* Note for plugin developers: Usually {@link #getAliasOrName()}
* is what you want to use instead of this method.
* Note for plugin developers: Usually {@link #getAliasOrName()}is what you want to use instead of this method.
*
* @return The name of the world as a String.
*/
@NotNull
public String getName() {
return worldName;
return worldConfig.getLegacyWorldName();
}
/**
* Gets the folder where all the world contents are stored in. Generally, the location of the folder is at the server
* roots directory for pre-26.1 servers, and at "[level]/dimensions" folder under the world level directory for
* 26.1+ PaperMC servers.
* <br />
* Note this folder location is based on Multiverse's understanding of Paper and Spigot's folder structure. If the
* server software does something weird, this folder will not reflect the actual world folder location.
* <br />
* If the world is loaded, you should use {@link World#getWorldFolder()} instead as it is more accurate.
* This method is more of a fallback for when the world is not loaded.
*
* @return The world folder.
*/
@ApiStatus.AvailableSince("5.7")
public File getOfflineWorldFolder() {
return WorldFolderResolver.resolve(this);
}
/**
@@ -66,7 +99,8 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
}
/**
* Gets whether this world is loaded.
* Gets whether this world is loaded. Note that this may return false even if this is a stored instance of
* {@link LoadedMultiverseWorld} as worlds can be unloaded anytime.
*
* @return True if the world is loaded, else false.
*/
@@ -74,6 +108,23 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
return worldConfig.isLoadedWorld();
}
/**
* Attempts to get loaded world instance. Only returns a value if the world is loaded at the time of calling, even
* if this is a stored instance of {@link LoadedMultiverseWorld}.
* <br/>
* You may use {@link #isLoaded()} to check if the world is loaded first.
*
* @return Loaded world instance wrapped in an Option, or None if the world is not loaded.
*
* @since 5.5
*/
@ApiStatus.AvailableSince("5.5")
public Option<LoadedMultiverseWorld> asLoadedWorld() {
return Option.of(worldConfig.getMVWorld())
.filter(world -> world instanceof LoadedMultiverseWorld)
.map(world -> (LoadedMultiverseWorld) world);
}
/**
* Gets the properties handler of this world.
*
@@ -124,7 +175,7 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
* @return The alias of the world as a String.
*/
public String getAliasOrName() {
return Strings.isNullOrEmpty(worldConfig.getAlias()) ? worldName : worldConfig.getAlias();
return Strings.isNullOrEmpty(worldConfig.getAlias()) ? getName() : worldConfig.getAlias();
}
/**
@@ -373,6 +424,19 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
return worldConfig.getGenerator();
}
/**
* Gets the generator settings used to create this world. You cannot change this after world creation.
* Changing this manually in config on existing world will have no effect on the world generation.
*
* @return The generator settings string.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public String getGeneratorSettings() {
return worldConfig.getGeneratorSettings();
}
/**
* Gets whether or not this world will display in chat, mvw and mvl regardless if a user has the
* access permissions to go to this world.
@@ -434,6 +498,59 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
return worldConfig.setKeepSpawnInMemory(keepSpawnInMemory);
}
/**
* Get all meta key-value pairs for this world. This is an unmodifiable view of the internal map, so changes to
* the world meta will be reflected in this map, but attempts to modify this map will throw an exception.
*
* @return An unmodifiable view of all meta key-value pairs for this world.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @UnmodifiableView Map<String, String> getAllMeta() {
return Collections.unmodifiableMap(worldConfig.getMeta());
}
/**
* Gets the meta value for the given key if it exists.
*
* @param key The meta key to look up, may be {@code null} in which case an empty result is returned.
* @return An {@link Option} containing the meta value if present, otherwise an empty {@link Option}.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Option<String> getMeta(@Nullable String key) {
return Option.of(worldConfig.getMeta().get(key));
}
/**
* Sets a meta key-value pair for this world. Existing value for the key will be replaced.
*
* @param key The meta key to set, must not be {@code null}.
* @param value The meta value to set.
* @return Result of setting the property.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Try<Void> setMeta(@NotNull String key, @Nullable String value) {
return worldConfig.setMeta(key, value);
}
/**
* Removes the meta entry associated with the given key.
*
* @param key The meta key to remove.
* @return Result of removing the property.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Try<Void> removeMeta(@NotNull String key) {
return worldConfig.removeMeta(key);
}
/**
* Gets the player limit for this world after which players without an override
* permission node will not be allowed in. A value of -1 or less signifies no limit
@@ -669,7 +786,7 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
*
* @return The world config.
*/
WorldConfig getWorldConfig() {
@NotNull WorldConfig getWorldConfig() {
return worldConfig;
}
@@ -678,7 +795,7 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
*
* @param worldConfig The world config.
*/
void setWorldConfig(WorldConfig worldConfig) {
void setWorldConfig(@NotNull WorldConfig worldConfig) {
this.worldConfig = worldConfig;
}
@@ -688,7 +805,8 @@ public sealed class MultiverseWorld permits LoadedMultiverseWorld {
@Override
public String toString() {
return "MultiverseWorld{"
+ "name='" + worldName + "', "
+ "key='" + getKey() + "', "
+ "name='" + getName() + "', "
+ "env='" + getEnvironment() + "', "
+ "gen='" + getGenerator() + "'"
+ '}';
@@ -1,5 +1,7 @@
package org.mvplugins.multiverse.core.world;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import java.util.List;
/**
@@ -8,5 +10,5 @@ import java.util.List;
* @param newWorlds List of the new WorldConfigs added
* @param removedWorlds List of the worlds removed from the config
*/
record NewAndRemovedWorlds(List<WorldConfig> newWorlds, List<String> removedWorlds) {
record NewAndRemovedWorlds(List<WorldConfig> newWorlds, List<WorldKeyOrName> removedWorlds) {
}
@@ -2,6 +2,7 @@ package org.mvplugins.multiverse.core.world;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import com.dumptruckman.minecraft.util.Logging;
@@ -29,6 +30,7 @@ import org.mvplugins.multiverse.core.config.migration.action.MoveMigratorAction;
import org.mvplugins.multiverse.core.config.migration.action.NullStringMigratorAction;
import org.mvplugins.multiverse.core.config.migration.VersionMigrator;
import org.mvplugins.multiverse.core.economy.MVEconomist;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import org.mvplugins.multiverse.core.world.location.SpawnLocation;
import org.mvplugins.multiverse.core.world.entity.EntitySpawnConfig;
@@ -37,16 +39,16 @@ import org.mvplugins.multiverse.core.world.entity.EntitySpawnConfig;
*/
final class WorldConfig {
private final String worldName;
private final WorldKeyOrName keyOrName;
private final WorldConfigNodes configNodes;
private final MemoryConfigurationHandle configHandle;
private final StringPropertyHandle stringPropertyHandle;
WorldConfig(
@NotNull String worldName,
@NotNull WorldKeyOrName keyOrName,
@NotNull ConfigurationSection configSection,
@NotNull MultiverseCore multiverseCore) {
this.worldName = worldName;
this.keyOrName = keyOrName;
this.configNodes = new WorldConfigNodes(multiverseCore);
this.configHandle = MemoryConfigurationHandle.builder(configSection, configNodes.getNodes())
.logger(Logging.getLogger())
@@ -118,6 +120,11 @@ final class WorldConfig {
.addAction(MoveMigratorAction.of("spawning.animals", "spawning.animal"))
.addAction(MoveMigratorAction.of("spawning.monsters", "spawning.monster"))
.build())
.addVersionMigrator(VersionMigrator.builder(1.3)
.addAction(config -> config.set("read-only.legacy-world-name", keyOrName.usableName()))
.addAction(MoveMigratorAction.of("environment", "read-only.environment"))
.addAction(MoveMigratorAction.of("seed", "read-only.seed"))
.build())
.build();
}
@@ -132,7 +139,7 @@ final class WorldConfig {
Try<Void> save() {
return configHandle.save().onFailure(ex ->
Logging.warning("Failed to save world config for world '%s'. %s",
worldName, ex.getLocalizedMessage()));
keyOrName, ex.getLocalizedMessage()));
}
ConfigurationSection getConfigurationSection() {
@@ -143,8 +150,8 @@ final class WorldConfig {
return stringPropertyHandle;
}
String getWorldName() {
return worldName;
WorldKeyOrName getWorldKeyOrName() {
return keyOrName;
}
boolean getAdjustSpawn() {
@@ -258,14 +265,6 @@ final class WorldConfig {
return configHandle.set(configNodes.entryFeeCurrency, entryFeeCurrency);
}
World.Environment getEnvironment() {
return configHandle.get(configNodes.environment);
}
Try<Void> setEnvironment(World.Environment environment) {
return configHandle.set(configNodes.environment, environment);
}
GameMode getGameMode() {
return configHandle.get(configNodes.gamemode);
}
@@ -305,6 +304,18 @@ final class WorldConfig {
return configHandle.set(configNodes.keepSpawnInMemory, keepSpawnInMemory);
}
Map<String, String> getMeta() {
return configHandle.get(configNodes.meta);
}
Try<Void> setMeta(String key, String value) {
return configHandle.set(configNodes.meta, key, value);
}
Try<Void> removeMeta(String key) {
return configHandle.remove(configNodes.meta, key);
}
int getPlayerLimit() {
return configHandle.get(configNodes.playerLimit);
}
@@ -345,14 +356,6 @@ final class WorldConfig {
return configHandle.set(configNodes.scale, scale);
}
long getSeed() {
return configHandle.get(configNodes.seed);
}
Try<Void> setSeed(long seed) {
return configHandle.set(configNodes.seed, seed);
}
SpawnLocation getSpawnLocation() {
return configHandle.get(configNodes.spawnLocation);
}
@@ -377,10 +380,46 @@ final class WorldConfig {
return configHandle.set(configNodes.worldBlacklist, worldBlacklist);
}
World.Environment getEnvironment() {
return configHandle.get(configNodes.environment);
}
Try<Void> setEnvironment(World.Environment environment) {
return configHandle.set(configNodes.environment, environment);
}
String getGeneratorSettings() {
return configHandle.get(configNodes.generatorSettings);
}
Try<Void> setGeneratorSettings(String generatorSettings) {
return configHandle.set(configNodes.generatorSettings, generatorSettings);
}
String getLegacyWorldName() {
return configHandle.get(configNodes.legacyWorldName);
}
Try<Void> setLegacyWorldName(String legacyWorldName) {
return configHandle.set(configNodes.legacyWorldName, legacyWorldName);
}
long getSeed() {
return configHandle.get(configNodes.seed);
}
Try<Void> setSeed(long seed) {
return configHandle.set(configNodes.seed, seed);
}
void setMVWorld(@NotNull MultiverseWorld world) {
configNodes.setWorld(world);
}
MultiverseWorld getMVWorld() {
return configNodes.getWorld();
}
boolean isLoadedWorld() {
return configNodes.getWorld() instanceof LoadedMultiverseWorld;
}
@@ -392,7 +431,7 @@ final class WorldConfig {
/**
* Migrates the entry fee settings. Assumes entry fee is disabled if currency is not set.
*/
static final class EntryFeeMigrator implements MigratorAction {
private static final class EntryFeeMigrator implements MigratorAction {
@Override
public void migrate(ConfigurationSection config) {
String currency = config.getString("entry-fee.currency", "");
@@ -7,6 +7,7 @@ import java.util.Objects;
import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
import org.bukkit.Bukkit;
import org.bukkit.ChatColor;
import org.bukkit.Difficulty;
import org.bukkit.GameMode;
import org.bukkit.Material;
@@ -17,6 +18,7 @@ import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.MultiverseCore;
import org.mvplugins.multiverse.core.config.CoreConfig;
import org.mvplugins.multiverse.core.config.node.MapConfigNode;
import org.mvplugins.multiverse.core.config.node.serializer.NodeSerializer;
import org.mvplugins.multiverse.core.event.world.MVWorldPropertyChangedEvent;
import org.mvplugins.multiverse.core.config.node.ConfigNode;
@@ -24,6 +26,7 @@ import org.mvplugins.multiverse.core.config.node.ListConfigNode;
import org.mvplugins.multiverse.core.config.node.NodeGroup;
import org.mvplugins.multiverse.core.economy.MVEconomist;
import org.mvplugins.multiverse.core.utils.MaterialConverter;
import org.mvplugins.multiverse.core.utils.text.ChatTextFormatter;
import org.mvplugins.multiverse.core.world.helpers.EnforcementHandler;
import org.mvplugins.multiverse.core.world.location.NullSpawnLocation;
import org.mvplugins.multiverse.core.world.location.SpawnLocation;
@@ -90,6 +93,8 @@ final class WorldConfigNodes {
.onLoadAndChange((oldValue, newValue) -> {
if (world == null) return;
world.updateColourlessAlias();
worldManager.getWorldStore().changeAlias(
ChatTextFormatter.removeColor(oldValue), ChatTextFormatter.removeColor(newValue), world);
}));
final ConfigNode<Boolean> allowAdvancementGrant = node(ConfigNode.builder("allow-advancement-grant", Boolean.class)
@@ -172,11 +177,6 @@ final class WorldConfigNodes {
}
}));
final ConfigNode<World.Environment> environment = node(ConfigNode
.builder("environment", World.Environment.class)
.defaultValue(World.Environment.NORMAL)
.hidden());
final ConfigNode<GameMode> gamemode = node(ConfigNode.builder("gamemode", GameMode.class)
.defaultValue(GameMode.SURVIVAL)
.onLoadAndChange((oldValue, newValue) -> {
@@ -198,11 +198,21 @@ final class WorldConfigNodes {
final ConfigNode<Boolean> keepSpawnInMemory = node(ConfigNode
.builder("keep-spawn-in-memory", Boolean.class)
.defaultValue(true)
.onLoadAndChange((oldValue, newValue) -> {
.onLoadAndChange((sender, oldValue, newValue) -> {
if (!(world instanceof LoadedMultiverseWorld loadedWorld)) return;
loadedWorld.getBukkitWorld().peek(bukkitWorld -> bukkitWorld.setKeepSpawnInMemory(newValue));
loadedWorld.getBukkitWorld().peek(bukkitWorld -> {
bukkitWorld.setKeepSpawnInMemory(newValue);
if (bukkitWorld.getKeepSpawnInMemory() != newValue) {
sender.sendMessage(ChatColor.RED + "Keep spawn in memory feature has been removed by " +
"Minecraft in 1.21.9+ and will no longer have any effect when set to true.");
}
});
}));
final MapConfigNode<String, String> meta = (MapConfigNode<String, String>) node(MapConfigNode
.mapBuilder("meta", String.class, String.class)
.hidden());
final ConfigNode<Integer> playerLimit = node(ConfigNode.builder("player-limit", Integer.class)
.defaultValue(-1));
@@ -234,10 +244,6 @@ final class WorldConfigNodes {
};
}));
final ConfigNode<Long> seed = node(ConfigNode.builder("seed", Long.class)
.defaultValue(Long.MIN_VALUE)
.hidden());
final ConfigNode<SpawnLocation> spawnLocation = node(ConfigNode.builder("spawn-location", SpawnLocation.class)
.defaultValue(NullSpawnLocation.get())
.hidden()
@@ -274,6 +280,22 @@ final class WorldConfigNodes {
final ConfigNode<List<String>> worldBlacklist = node(ListConfigNode.listBuilder("world-blacklist", String.class));
final ConfigNode<World.Environment> environment = node(ConfigNode
.builder("read-only.environment", World.Environment.class)
.defaultValue(World.Environment.NORMAL)
.hidden());
final ConfigNode<String> generatorSettings = node(ConfigNode.builder("read-only.generator-settings", String.class)
.defaultValue("")
.hidden());
final ConfigNode<String> legacyWorldName = node(ConfigNode.builder("read-only.legacy-world-name", String.class)
.hidden());
final ConfigNode<Long> seed = node(ConfigNode.builder("read-only.seed", Long.class)
.defaultValue(Long.MIN_VALUE)
.hidden());
final ConfigNode<Double> version = node(ConfigNode.builder("version", Double.class)
.defaultValue(CONFIG_VERSION)
.hidden());
@@ -1,14 +1,10 @@
package org.mvplugins.multiverse.core.world;
import java.io.File;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
@@ -17,19 +13,22 @@ import java.util.stream.Collectors;
import com.dumptruckman.minecraft.util.Logging;
import com.google.common.base.Strings;
import io.papermc.lib.PaperLib;
import io.vavr.control.Option;
import io.vavr.control.Try;
import jakarta.inject.Inject;
import jakarta.inject.Provider;
import org.bukkit.Bukkit;
import org.bukkit.GameRule;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.WorldBorder;
import org.bukkit.WorldCreator;
import org.bukkit.WorldType;
import org.bukkit.plugin.PluginManager;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.config.CoreConfig;
@@ -48,19 +47,23 @@ import org.mvplugins.multiverse.core.locale.message.MessageReplacement.Replace;
import org.mvplugins.multiverse.core.permissions.CorePermissions;
import org.mvplugins.multiverse.core.teleportation.BlockSafety;
import org.mvplugins.multiverse.core.teleportation.LocationManipulation;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import org.mvplugins.multiverse.core.utils.ServerProperties;
import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility;
import org.mvplugins.multiverse.core.utils.compatibility.WorldCompatibility;
import org.mvplugins.multiverse.core.utils.compatibility.WorldCreatorCompatibility;
import org.mvplugins.multiverse.core.utils.result.Attempt;
import org.mvplugins.multiverse.core.utils.result.FailureReason;
import org.mvplugins.multiverse.core.utils.FileUtils;
import org.mvplugins.multiverse.core.utils.text.ChatTextFormatter;
import org.mvplugins.multiverse.core.world.biomeprovider.BiomeProviderFactory;
import org.mvplugins.multiverse.core.world.entity.EntityPurger;
import org.mvplugins.multiverse.core.world.generators.GeneratorProvider;
import org.mvplugins.multiverse.core.world.helpers.DataStore.GameRulesStore;
import org.mvplugins.multiverse.core.world.helpers.DataTransfer;
import org.mvplugins.multiverse.core.world.helpers.DimensionFinder.DimensionFormat;
import org.mvplugins.multiverse.core.world.helpers.DimensionFinder;
import org.mvplugins.multiverse.core.world.helpers.PotentialWorldFinder;
import org.mvplugins.multiverse.core.world.helpers.WorldFolderResolver;
import org.mvplugins.multiverse.core.world.helpers.WorldNameChecker;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import org.mvplugins.multiverse.core.world.options.CloneWorldOptions;
import org.mvplugins.multiverse.core.world.options.CreateWorldOptions;
import org.mvplugins.multiverse.core.world.options.DeleteWorldOptions;
@@ -87,12 +90,12 @@ import static org.mvplugins.multiverse.core.world.helpers.DataStore.WorldConfigS
@Service // SUPPRESS CHECKSTYLE: ClassFanOutComplexity This is the world manager, it's going to be complex.
public final class WorldManager {
private static final List<String> CLONE_IGNORE_FILES = Arrays.asList("uid.dat", "session.lock");
private static final DimensionFormat DEFAULT_NETHER_FORMAT = new DimensionFormat("%overworld%_nether");
private static final DimensionFormat DEFAULT_END_FORMAT = new DimensionFormat("%overworld%_the_end");
private static final List<String> CLONE_IGNORE_FILES = List.of(
"uid.dat", "session.lock", // All pre 26.1 format
"data/paper/metadata.dat" // New papermc format for 26.1+
);
private final Map<String, MultiverseWorld> worldsMap;
private final Map<String, LoadedMultiverseWorld> loadedWorldsMap;
private final WorldStore worldStore;
private final List<String> unloadTracker;
private final List<String> loadTracker;
private final WorldsConfigManager worldsConfigManager;
@@ -107,9 +110,11 @@ public final class WorldManager {
private final ServerProperties serverProperties;
private final CoreConfig config;
private final EntityPurger entityPurger;
private final Provider<PotentialWorldFinder> potentialWorldFinder;
@Inject
WorldManager(
@NotNull WorldStore worldStore,
@NotNull WorldsConfigManager worldsConfigManager,
@NotNull WorldNameChecker worldNameChecker,
@NotNull BiomeProviderFactory biomeProviderFactory,
@@ -121,7 +126,9 @@ public final class WorldManager {
@NotNull CorePermissions corePermissions,
@NotNull ServerProperties serverProperties,
@NotNull CoreConfig config,
@NotNull EntityPurger entityPurger) {
@NotNull EntityPurger entityPurger,
@NotNull Provider<PotentialWorldFinder> potentialWorldFinder) {
this.worldStore = worldStore;
this.worldsConfigManager = worldsConfigManager;
this.worldNameChecker = worldNameChecker;
this.biomeProviderFactory = biomeProviderFactory;
@@ -134,9 +141,8 @@ public final class WorldManager {
this.serverProperties = serverProperties;
this.config = config;
this.entityPurger = entityPurger;
this.potentialWorldFinder = potentialWorldFinder;
this.worldsMap = new HashMap<>();
this.loadedWorldsMap = new HashMap<>();
this.unloadTracker = new ArrayList<>();
this.loadTracker = new ArrayList<>();
}
@@ -168,19 +174,23 @@ public final class WorldManager {
}
private void loadNewWorldConfigs(Collection<WorldConfig> newWorldConfigs) {
newWorldConfigs.forEach(worldConfig -> Option.of(worldsMap.get(worldConfig.getWorldName()))
.peek(unloadedWorld -> unloadedWorld.setWorldConfig(worldConfig))
.onEmpty(() -> newMultiverseWorld(worldConfig.getWorldName(), worldConfig)));
newWorldConfigs.forEach(worldConfig -> {
worldStore.getUnloadedWorldRef(worldConfig.getWorldKeyOrName().usableName())
.peek(unloadedWorld -> unloadedWorld.setWorldConfig(worldConfig))
.onEmpty(() -> newMultiverseWorld(worldConfig));
worldStore.getLoadedWorld(worldConfig.getWorldKeyOrName().usableName())
.peek(loadedWorld -> loadedWorld.setWorldConfig(worldConfig));
});
}
private void removeWorldsNotInConfigs(Collection<String> removedWorlds) {
removedWorlds.forEach(worldName -> getWorld(worldName)
private void removeWorldsNotInConfigs(Collection<WorldKeyOrName> removedWorlds) {
removedWorlds.forEach(keyOrName -> getWorld(keyOrName.usableName())
.map(world -> removeWorld(RemoveWorldOptions.world(world)))
.getOrElse(() -> worldActionResult(RemoveFailureReason.WORLD_NON_EXISTENT, worldName))
.getOrElse(() -> worldActionResult(RemoveFailureReason.WORLD_NON_EXISTENT, keyOrName.toString()))
.onFailure(failure ->
Logging.severe("Failed to unload world %s: %s", worldName, failure))
Logging.severe("Failed to unload world %s: %s", keyOrName, failure))
.onSuccess(success ->
Logging.fine("Unloaded world %s as it was removed from config", worldName)));
Logging.fine("Unloaded world %s as it was removed from config", keyOrName)));
}
/**
@@ -192,9 +202,10 @@ public final class WorldManager {
.collect(Collectors.toMap(World::getName, Function.identity()));
serverProperties.getLevelName().peek(overworldName -> {
//TODO: check by namespaced key instead
World overworld = bukkitWorlds.remove(overworldName);
World nether = bukkitWorlds.remove(DEFAULT_NETHER_FORMAT.replaceOverworld(overworldName));
World end = bukkitWorlds.remove(DEFAULT_END_FORMAT.replaceOverworld(overworldName));
World nether = bukkitWorlds.remove(DimensionFinder.DEFAULT_NETHER_FORMAT.replaceOverworld(overworldName));
World end = bukkitWorlds.remove(DimensionFinder.DEFAULT_END_FORMAT.replaceOverworld(overworldName));
if (config.getAutoImportDefaultWorlds()) {
importExistingBukkitWorld(overworld);
@@ -241,43 +252,60 @@ public final class WorldManager {
* @return The result of the creation.
*/
public Attempt<LoadedMultiverseWorld, CreateFailureReason> createWorld(CreateWorldOptions options) {
return validateCreateWorldOptions(options).mapAttempt(this::doCreateWorld);
return parseCreateWorldOptionsKeyOrName(options).mapAttempt(this::doCreateWorld);
}
private Attempt<CreateWorldOptions, CreateFailureReason> validateCreateWorldOptions(
private Attempt<KeyOrNameWithOptions<CreateWorldOptions>, CreateFailureReason> parseCreateWorldOptionsKeyOrName(
CreateWorldOptions options) {
if (!worldNameChecker.isValidWorldName(options.worldName())) {
return worldActionResult(CreateFailureReason.INVALID_WORLDNAME, options.worldName());
} else if (getLoadedWorld(options.worldName()).isDefined()) {
return worldActionResult(CreateFailureReason.WORLD_EXIST_LOADED, options.worldName());
} else if (getWorld(options.worldName()).isDefined()) {
return worldActionResult(CreateFailureReason.WORLD_EXIST_UNLOADED, options.worldName());
} else if (options.doFolderCheck() && worldNameChecker.hasWorldFolder(options.worldName())) {
return worldActionResult(CreateFailureReason.WORLD_EXIST_FOLDER, options.worldName());
}
return worldActionResult(options);
return options.keyOrName()
.fold(WorldKeyOrName::parse, Attempt::success)
.transform(CreateFailureReason.INVALID_WORLDNAME)
.failIf(keyOrName -> !worldNameChecker.isValidWorldName(keyOrName.usableName()),
keyOrName -> worldActionResult(CreateFailureReason.INVALID_WORLDNAME, keyOrName))
.failIf(keyOrName -> keyOrName.isKey() && !WorldCreatorCompatibility.canCreateWorldWithKey(),
keyOrName -> worldActionResult(CreateFailureReason.NAMESPACEDKEY_UNSUPPORTED, keyOrName))
.failIf(worldStore::isLoadedWorld,
keyOrName -> worldActionResult(CreateFailureReason.WORLD_EXIST_LOADED, keyOrName))
.failIf(worldStore::isUnloadedWorld,
keyOrName -> worldActionResult(CreateFailureReason.WORLD_EXIST_UNLOADED, keyOrName))
.failIf(keyOrName -> options.doFolderCheck() && worldNameChecker.hasWorldFolder(keyOrName),
keyOrName -> worldActionResult(CreateFailureReason.WORLD_EXIST_FOLDER, keyOrName))
.map(keyOrName -> new KeyOrNameWithOptions<>(keyOrName, options));
}
private Attempt<LoadedMultiverseWorld, CreateFailureReason> doCreateWorld(
CreateWorldOptions options) {
String generatorString = generatorProvider.parseGeneratorString(options.worldName(), options.generator());
WorldCreator worldCreator = WorldCreator.name(options.worldName())
KeyOrNameWithOptions<CreateWorldOptions> keyOrNameWithOptions) {
WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName();
CreateWorldOptions options = keyOrNameWithOptions.options();
String generatorString = generatorProvider.parseGeneratorString(keyOrName.usableName(), options.generator());
WorldCreator worldCreator = WorldCreatorCompatibility.ofKeyOrName(keyOrName)
.environment(options.environment())
.generateStructures(options.generateStructures())
.generatorSettings(options.generatorSettings())
.seed(options.seed())
.type(options.worldType());
return addBiomeProviderToCreator(worldCreator, options.worldName(), options.biome())
WorldCreatorCompatibility.setBonusChest(worldCreator, options.bonusChest());
options.forcedSpawnPosition().peek(position -> WorldCreatorCompatibility.setForcedSpawnPosition(worldCreator, position));
return addBiomeProviderToCreator(worldCreator, keyOrName.usableName(), options.biome())
.mapAttempt(creator -> addGeneratorToCreator(creator, generatorString))
.mapAttempt(this::createBukkitWorld)
.transform(CreateFailureReason.WORLD_CREATOR_FAILED)
.map(bukkitWorld -> newLoadedMultiverseWorld(
bukkitWorld,
generatorString,
options.biome(),
options.useSpawnAdjust()))
.peek(loadedWorld -> pluginManager.callEvent(new MVWorldCreatedEvent(loadedWorld)));
bukkitWorld,
generatorString,
options.biome(),
options.generatorSettings(),
options.useSpawnAdjust()))
.peek(loadedWorld -> postCreateWorld(loadedWorld, options));
}
private void postCreateWorld(LoadedMultiverseWorld loadedWorld, CreateWorldOptions options) {
options.worldPropertyStrings().forEach((key, value) -> loadedWorld.getStringPropertyHandle()
.setPropertyString(key, value)
.onFailure(failure -> Logging.warning("Failed to set property '%s' to '%s' for world %s: %s",
key, value, loadedWorld.getName(), failure.getMessage())));
pluginManager.callEvent(new MVWorldCreatedEvent(loadedWorld));
saveWorldsConfig();
}
/**
@@ -287,34 +315,51 @@ public final class WorldManager {
* @return The result of the import.
*/
public Attempt<LoadedMultiverseWorld, ImportFailureReason> importWorld(ImportWorldOptions options) {
String worldName = options.worldName();
if (isLoadedWorld(worldName)) {
return worldActionResult(ImportFailureReason.WORLD_EXIST_LOADED, worldName);
} else if (isWorld(worldName)) {
return worldActionResult(ImportFailureReason.WORLD_EXIST_UNLOADED, worldName);
}
return Option.of(Bukkit.getWorld(worldName))
.map(bukkitWorld -> doImportBukkitWorld(options, bukkitWorld))
.getOrElse(() -> validateImportWorldOptions(options).mapAttempt(this::doImportWorld));
return options.keyOrName()
.fold(WorldKeyOrName::parse, Attempt::success)
.transform(ImportFailureReason.INVALID_WORLDNAME)
.failIf(keyOrName -> !worldNameChecker.isValidWorldName(keyOrName.usableName()),
keyOrName -> worldActionResult(ImportFailureReason.INVALID_WORLDNAME, keyOrName))
.failIf(keyOrName -> keyOrName.isKey() && !WorldCreatorCompatibility.canCreateWorldWithKey(),
keyOrName -> worldActionResult(ImportFailureReason.NAMESPACEDKEY_UNSUPPORTED, keyOrName))
.failIf(worldStore::isLoadedWorld,
keyOrName -> worldActionResult(ImportFailureReason.WORLD_EXIST_LOADED, keyOrName))
.failIf(worldStore::isUnloadedWorld,
keyOrName -> worldActionResult(ImportFailureReason.WORLD_EXIST_UNLOADED, keyOrName))
.map(keyOrName -> new KeyOrNameWithOptions<>(keyOrName, options))
.mapAttempt(pair -> BukkitCompatibility.getWorldByNameOrKey(pair.keyOrName())
.map(bukkitWorld -> doImportBukkitWorld(pair, bukkitWorld))
.getOrElse(() -> validateImportWorldOptions(pair).mapAttempt(this::doImportWorld)));
}
private Attempt<ImportWorldOptions, ImportFailureReason> validateImportWorldOptions(ImportWorldOptions options) {
String worldName = options.worldName();
if (!worldNameChecker.isValidWorldName(worldName)) {
return worldActionResult(ImportFailureReason.INVALID_WORLDNAME, worldName);
} else if (options.doFolderCheck() && !worldNameChecker.isValidWorldFolder(worldName)) {
return worldActionResult(ImportFailureReason.WORLD_FOLDER_INVALID, worldName);
private Attempt<KeyOrNameWithOptions<ImportWorldOptions>, ImportFailureReason> validateImportWorldOptions(
KeyOrNameWithOptions<ImportWorldOptions> keyOrNameWithOptions) {
WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName();
if (keyOrNameWithOptions.options().doFolderCheck()) {
//todo This is a duplicate of folder check in load world
WorldNameChecker.FolderStatus folderStatus = worldNameChecker.checkFolder(keyOrName);
if (!folderStatus.isLoadable()) {
return worldActionResult(ImportFailureReason.WORLD_FOLDER_INVALID, keyOrName);
}
if (folderStatus == WorldNameChecker.FolderStatus.REQUIRES_MIGRATION) {
Logging.info("World '%s' will be automatically migrated by PaperMC to the new dimension " +
"location. If you face any issue with migration, please contact PaperMC support!",
keyOrName);
}
}
return worldActionResult(options);
return worldActionResult(keyOrNameWithOptions);
}
private Attempt<LoadedMultiverseWorld, ImportFailureReason> doImportWorld(
ImportWorldOptions options) {
String generatorString = generatorProvider.parseGeneratorString(options.worldName(), options.generator());
WorldCreator worldCreator = WorldCreator.name(options.worldName())
.environment(options.environment());
KeyOrNameWithOptions<ImportWorldOptions> keyOrNameWithOptions) {
WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName();
ImportWorldOptions options = keyOrNameWithOptions.options();
String generatorString = generatorProvider.parseGeneratorString(keyOrName.usableName(), options.generator());
WorldCreator worldCreator = WorldCreatorCompatibility.ofKeyOrName(keyOrName)
.environment(options.environment())
.generatorSettings(options.generatorSettings());
return addBiomeProviderToCreator(worldCreator, options.worldName(), options.biome())
return addBiomeProviderToCreator(worldCreator, keyOrName.usableName(), options.biome())
.mapAttempt(creator -> addGeneratorToCreator(creator, generatorString))
.mapAttempt(this::createBukkitWorld)
.transform(ImportFailureReason.WORLD_CREATOR_FAILED)
@@ -322,11 +367,15 @@ public final class WorldManager {
bukkitWorld,
generatorString,
options.biome(),
options.generatorSettings(),
options.useSpawnAdjust()))
.peek(loadedWorld -> pluginManager.callEvent(new MVWorldImportedEvent(loadedWorld)));
}
private Attempt<LoadedMultiverseWorld, ImportFailureReason> doImportBukkitWorld(ImportWorldOptions options, World bukkitWorld) {
private Attempt<LoadedMultiverseWorld, ImportFailureReason> doImportBukkitWorld(
KeyOrNameWithOptions<ImportWorldOptions> keyOrNameWithOptions, World bukkitWorld) {
WorldKeyOrName keyOrName = keyOrNameWithOptions.keyOrName();
ImportWorldOptions options = keyOrNameWithOptions.options();
if (options.environment() != bukkitWorld.getEnvironment()) {
return Attempt.failure(ImportFailureReason.BUKKIT_ENVIRONMENT_MISMATCH,
Replace.WORLD.with(bukkitWorld.getName()),
@@ -334,18 +383,21 @@ public final class WorldManager {
replace("{mvEnvironment}").with(options.environment().name()));
}
//todo: check for key mismatch?
LoadedMultiverseWorld loadedWorld = newLoadedMultiverseWorld(
bukkitWorld,
generatorProvider.parseGeneratorString(options.worldName(), options.generator()),
generatorProvider.parseGeneratorString(keyOrName.usableName(), options.generator()),
options.biome(),
options.generatorSettings(),
options.useSpawnAdjust());
pluginManager.callEvent(new MVWorldImportedEvent(loadedWorld));
return Attempt.success(loadedWorld);
}
private MultiverseWorld newMultiverseWorld(String worldName, WorldConfig worldConfig) {
MultiverseWorld mvWorld = new MultiverseWorld(worldName, worldConfig, config);
worldsMap.put(mvWorld.getName().toLowerCase(Locale.ENGLISH), mvWorld);
private MultiverseWorld newMultiverseWorld(WorldConfig worldConfig) {
MultiverseWorld mvWorld = new MultiverseWorld(worldConfig, config);
worldStore.putUnloadedWorld(mvWorld);
corePermissions.addWorldPermissions(mvWorld);
return mvWorld;
}
@@ -358,22 +410,28 @@ public final class WorldManager {
* @param adjustSpawn Whether to adjust spawn.
*/
private LoadedMultiverseWorld newLoadedMultiverseWorld(
@NotNull World world, @Nullable String generator, @Nullable String biome, boolean adjustSpawn) {
WorldConfig worldConfig = worldsConfigManager.addWorldConfig(world.getName());
@NotNull World world,
@Nullable String generator,
@Nullable String biome,
@Nullable String generatorSettings,
boolean adjustSpawn) {
WorldConfig worldConfig = worldsConfigManager.addWorldConfig(world.getKey());
// Properties from multiverse input
worldConfig.setAdjustSpawn(adjustSpawn);
worldConfig.setGenerator(generator == null ? "" : generator);
worldConfig.setBiome(biome == null ? "" : biome);
worldConfig.setGeneratorSettings(generatorSettings == null ? "" : generatorSettings);
// Properties from the bukkit world
worldConfig.setLegacyWorldName(world.getName());
worldConfig.setDifficulty(world.getDifficulty());
worldConfig.setKeepSpawnInMemory(world.getKeepSpawnInMemory());
worldConfig.setScale(getCoordinateScale(world));
worldConfig.setScale(WorldCompatibility.getCoordinateScale(world));
worldConfig.save();
newMultiverseWorld(world.getName(), worldConfig);
newMultiverseWorld(worldConfig);
LoadedMultiverseWorld loadedWorld = new LoadedMultiverseWorld(
world,
worldConfig,
@@ -382,23 +440,12 @@ public final class WorldManager {
locationManipulation,
entityPurger
);
loadedWorldsMap.put(loadedWorld.getName().toLowerCase(Locale.ENGLISH), loadedWorld);
worldStore.putLoadedWorld(loadedWorld);
saveWorldsConfig();
pluginManager.callEvent(new MVWorldLoadedEvent(loadedWorld));
return loadedWorld;
}
private double getCoordinateScale(World world) {
if (PaperLib.isPaper()) {
return world.getCoordinateScale();
}
return switch (world.getEnvironment()) {
case NORMAL -> 1;
case NETHER -> 8;
default -> 1;
};
}
/**
* Loads an existing world in config.
*
@@ -465,11 +512,19 @@ public final class WorldManager {
return doLoadBukkitWorld(bukkitWorld, mvWorld);
}
if (options.doFolderCheck() && !worldNameChecker.isValidWorldFolder(mvWorld.getName())) {
return worldActionResult(LoadFailureReason.WORLD_FOLDER_INVALID, mvWorld.getName());
if (options.doFolderCheck()) {
WorldNameChecker.FolderStatus folderStatus = worldNameChecker.checkFolder(mvWorld.getOfflineWorldFolder());
if (!folderStatus.isLoadable()) {
return worldActionResult(LoadFailureReason.WORLD_FOLDER_INVALID, mvWorld.getName());
}
if (folderStatus == WorldNameChecker.FolderStatus.REQUIRES_MIGRATION) {
Logging.info("World '%s' will be automatically migrated by PaperMC to the new dimension " +
"location. If you face any issue with migration, please contact PaperMC support!",
mvWorld.getName());
}
}
WorldCreator worldCreator = WorldCreator.name(mvWorld.getName())
WorldCreator worldCreator = WorldCreatorCompatibility.ofNameAndKey(mvWorld.getKey(), mvWorld.getName())
.environment(mvWorld.getEnvironment())
.seed(mvWorld.getSeed());
return addBiomeProviderToCreator(worldCreator, mvWorld.getName(), mvWorld.getBiome())
@@ -492,7 +547,23 @@ public final class WorldManager {
}
private Attempt<LoadedMultiverseWorld, LoadFailureReason> newLoadedMultiverseWorld(MultiverseWorld mvWorld, World bukkitWorld) {
WorldConfig worldConfig = worldsConfigManager.getWorldConfig(mvWorld.getName()).get(); //TODO: null check here, but logically it should never be null.
WorldConfig worldConfig = mvWorld.getWorldConfig();
if (worldConfig.getWorldKeyOrName().isName() && mvWorld.getName().equalsIgnoreCase(bukkitWorld.getName())) {
// do migration of namespaced key
Logging.info("Migrating world config for '%s' to use namespaced key '%s'...",
mvWorld.getName(), bukkitWorld.getKey());
worldConfig = worldsConfigManager.migrateWorldConfigKey(worldConfig, bukkitWorld.getKey());
mvWorld.setWorldConfig(worldConfig);
}
if (!bukkitWorld.getKey().equals(mvWorld.getKey())) {
return Attempt.failure(LoadFailureReason.BUKKIT_NAMESPACED_KEY_MISMATCH,
Replace.WORLD.with(mvWorld.getName()),
replace("{bukkitNamespace}").with(bukkitWorld.getKey()),
replace("{mvNamespace}").with(mvWorld.getKey()));
}
LoadedMultiverseWorld loadedWorld = new LoadedMultiverseWorld(
bukkitWorld,
worldConfig,
@@ -501,7 +572,7 @@ public final class WorldManager {
locationManipulation,
entityPurger
);
loadedWorldsMap.put(loadedWorld.getName().toLowerCase(Locale.ENGLISH), loadedWorld);
worldStore.putLoadedWorld(loadedWorld);
saveWorldsConfig();
pluginManager.callEvent(new MVWorldLoadedEvent(loadedWorld));
return Attempt.success(loadedWorld);
@@ -535,12 +606,10 @@ public final class WorldManager {
success -> removeLoadedMultiverseWorld(world));
}
private Attempt<MultiverseWorld, UnloadFailureReason> removeLoadedMultiverseWorld(@NotNull LoadedMultiverseWorld world) {
MultiverseWorld mvWorld = Objects.requireNonNull(loadedWorldsMap.remove(world.getName()),
"For some reason, the loaded world isn't in the map... BUGGG");
Logging.fine("Removed MultiverseWorld from map: " + world.getName());
var unloadedWorld = Objects.requireNonNull(worldsMap.get(world.getName()),
"For some reason, the unloaded world isn't in the map... BUGGG");
private Attempt<MultiverseWorld, UnloadFailureReason> removeLoadedMultiverseWorld(@NotNull LoadedMultiverseWorld mvWorld) {
MultiverseWorld unloadedWorld = worldStore.getUnloadedWorldRef(mvWorld.getKey().toString()).getOrElseThrow(
() -> new IllegalStateException("Unloaded ref of world not found: " + mvWorld));
worldStore.removeLoadedWorld(mvWorld);
mvWorld.getWorldConfig().setMVWorld(unloadedWorld);
pluginManager.callEvent(new MVWorldUnloadedEvent(mvWorld));
return worldActionResult(unloadedWorld);
@@ -630,11 +699,15 @@ public final class WorldManager {
*/
private Attempt<String, RemoveFailureReason> removeWorldFromConfig(@NotNull MultiverseWorld world) {
// Remove world from config
worldsMap.remove(world.getName());
world.getWorldConfig().deferenceMVWorld();
worldsConfigManager.deleteWorldConfig(world.getName());
worldStore.removeWorld(world);
WorldConfig worldConfig = world.getWorldConfig();
worldConfig.deferenceMVWorld();
worldsConfigManager.deleteWorldConfig(worldConfig);
saveWorldsConfig();
corePermissions.removeWorldPermissions(world);
pluginManager.callEvent(new MVWorldRemovedEvent(world));
return worldActionResult(world.getName());
}
@@ -691,6 +764,7 @@ public final class WorldManager {
private Attempt<File, DeleteFailureReason> validateWorldToDelete(
@NotNull LoadedMultiverseWorld world) {
return world.getBukkitWorld().map(World::getWorldFolder)
.peek(folder -> Logging.finer("World folder for world %s is at: %s", world.getName(), folder.getPath()))
.filter(worldNameChecker::isValidWorldFolder)
.map(this::<File, DeleteFailureReason>worldActionResult)
.getOrElse(() -> {
@@ -706,91 +780,91 @@ public final class WorldManager {
* @return The result of the clone.
*/
public Attempt<LoadedMultiverseWorld, CloneFailureReason> cloneWorld(@NotNull CloneWorldOptions options) {
return cloneWorldValidateWorld(options)
return parseCloneWorldOptionsNewWorld(options)
.mapAttempt(this::cloneWorldCopyFolder)
.mapAttempt(validatedOptions -> {
.mapAttempt(keyOrNameWithOptions -> {
ImportWorldOptions importWorldOptions = ImportWorldOptions
.worldName(validatedOptions.newWorldName())
.biome(validatedOptions.world().getBiome())
.environment(validatedOptions.world().getEnvironment())
.generator(validatedOptions.world().getGenerator());
.worldKeyOrName(keyOrNameWithOptions.keyOrName())
.biome(options.fromWorld().getBiome())
.environment(options.fromWorld().getEnvironment())
.generator(options.fromWorld().getGenerator());
return importWorld(importWorldOptions).transform(CloneFailureReason.IMPORT_FAILED);
})
.onSuccess(newWorld -> {
cloneWorldTransferData(options, newWorld);
if (options.keepWorldConfig()) {
newWorld.setSpawnLocation(options.world().getSpawnLocation());
// Special case for spawn location as it's considered a hidden config
newWorld.setSpawnLocation(options.fromWorld().getSpawnLocation());
}
saveWorldsConfig();
pluginManager.callEvent(new MVWorldClonedEvent(newWorld, options.world()));
pluginManager.callEvent(new MVWorldClonedEvent(newWorld, options.fromWorld()));
});
}
private Attempt<CloneWorldOptions, CloneFailureReason> cloneWorldValidateWorld(
private Attempt<KeyOrNameWithOptions<CloneWorldOptions>, CloneFailureReason> parseCloneWorldOptionsNewWorld(
@NotNull CloneWorldOptions options) {
String newWorldName = options.newWorldName();
if (!worldNameChecker.isValidWorldName(newWorldName)) {
Logging.severe("Invalid world name: " + newWorldName);
return worldActionResult(CloneFailureReason.INVALID_WORLDNAME, newWorldName);
}
if (isLoadedWorld(newWorldName)) {
Logging.severe("World already loaded when attempting to clone: " + newWorldName);
return worldActionResult(CloneFailureReason.WORLD_EXIST_LOADED, newWorldName);
}
if (isWorld(newWorldName)) {
Logging.severe("World already exist unloaded: " + newWorldName);
return worldActionResult(CloneFailureReason.WORLD_EXIST_UNLOADED, newWorldName);
}
if (worldNameChecker.hasWorldFolder(newWorldName)) {
return worldActionResult(CloneFailureReason.WORLD_EXIST_FOLDER, newWorldName);
}
return worldActionResult(options);
return options.newWorldKeyOrName()
.fold(WorldKeyOrName::parse, Attempt::success)
.transform(CloneFailureReason.INVALID_WORLDNAME)
.failIf(keyOrName -> !worldNameChecker.isValidWorldName(keyOrName.usableName()),
keyOrName -> worldActionResult(CloneFailureReason.INVALID_WORLDNAME, keyOrName))
.failIf(keyOrName -> keyOrName.isKey() && !WorldCreatorCompatibility.canCreateWorldWithKey(),
keyOrName -> worldActionResult(CloneFailureReason.NAMESPACEDKEY_UNSUPPORTED, keyOrName))
.failIf(worldStore::isLoadedWorld,
keyOrName -> worldActionResult(CloneFailureReason.WORLD_EXIST_LOADED, keyOrName))
.failIf(worldStore::isWorld,
keyOrName -> worldActionResult(CloneFailureReason.WORLD_EXIST_UNLOADED, keyOrName))
.failIf(worldNameChecker::hasWorldFolder,
keyOrName -> worldActionResult(CloneFailureReason.WORLD_EXIST_FOLDER, keyOrName))
.failIf(keyOrName -> !worldNameChecker.isValidWorldFolder(options.fromWorld().getOfflineWorldFolder()),
keyOrName -> worldActionResult(CloneFailureReason.FROM_WORLD_FOLDER_INVALID,
options.fromWorld().getName()))
.map(keyOrName -> new KeyOrNameWithOptions<>(keyOrName, options));
}
private Attempt<CloneWorldOptions, CloneFailureReason> cloneWorldCopyFolder(@NotNull CloneWorldOptions options) {
private Attempt<KeyOrNameWithOptions<CloneWorldOptions>, CloneFailureReason> cloneWorldCopyFolder(
@NotNull KeyOrNameWithOptions<CloneWorldOptions> keyOrNameWithOptions) {
WorldKeyOrName newWorldkeyOrName = keyOrNameWithOptions.keyOrName();
CloneWorldOptions options = keyOrNameWithOptions.options();
if (options.saveBukkitWorld()) {
Logging.finer("Saving bukkit world before cloning: " + options.world().getName());
options.world().getBukkitWorld().peek(this::saveWorldWithFlush);
options.fromWorld().asLoadedWorld().peek(loadedWorld -> {
Logging.finer("Saving world before cloning: " + loadedWorld.getName());
loadedWorld.getBukkitWorld().peek(bukkitWorld -> WorldCompatibility.saveWithFlush(bukkitWorld, true));
});
}
File worldFolder = options.world().getBukkitWorld().map(World::getWorldFolder).get();
File newWorldFolder = new File(Bukkit.getWorldContainer(), options.newWorldName());
File worldFolder = WorldFolderResolver.resolve(options.fromWorld());
File newWorldFolder = WorldFolderResolver.resolve(newWorldkeyOrName);
return fileUtils.copyFolder(worldFolder, newWorldFolder, CLONE_IGNORE_FILES).fold(
exception -> worldActionResult(CloneFailureReason.COPY_FAILED,
options.world().getName(), exception),
success -> worldActionResult(options));
}
// This method is only available since 1.21
private final Method saveWithFlush = ReflectHelper.getMethod(World.class, "save", boolean.class);
private void saveWorldWithFlush(World world) {
if (saveWithFlush != null) {
Logging.fine("Using world save method with flush...");
ReflectHelper.invokeMethod(world, saveWithFlush, true);
} else {
world.save();
}
options.fromWorld().getName(), exception),
success -> worldActionResult(keyOrNameWithOptions));
}
private void cloneWorldTransferData(@NotNull CloneWorldOptions options, @NotNull LoadedMultiverseWorld newWorld) {
DataTransfer<LoadedMultiverseWorld> dataTransfer = transferData(options, options.world());
dataTransfer.pasteAllTo(newWorld);
}
private DataTransfer<LoadedMultiverseWorld> transferData(
@NotNull KeepWorldSettingsOptions options, @NotNull LoadedMultiverseWorld world) {
DataTransfer<LoadedMultiverseWorld> dataTransfer = new DataTransfer<>();
if (options.keepWorldConfig()) {
dataTransfer.addDataStore(new WorldConfigStore(), world);
}
if (options.keepGameRule()) {
dataTransfer.addDataStore(new GameRulesStore(), world);
}
if (options.keepWorldBorder()) {
dataTransfer.addDataStore(new WorldBorderStore(), world);
new DataTransfer<MultiverseWorld>()
.addDataStore(new WorldConfigStore<>(), options.fromWorld())
.pasteAllTo(newWorld);
}
return dataTransfer;
newWorld.getBukkitWorld().peek(bukkitWorld -> {
if (!options.keepWorldBorder()) {
WorldBorder worldBorder = bukkitWorld.getWorldBorder();
worldBorder.reset();
}
if (!options.keepGameRule()) {
Arrays.stream(bukkitWorld.getGameRules())
.map(gameRuleName -> GameRule.getByName(gameRuleName))
.filter(Objects::nonNull)
.forEach(gameRule -> {
Object gameRuleDefault = bukkitWorld.getGameRuleDefault(gameRule);
if (gameRuleDefault != null) {
bukkitWorld.setGameRule(gameRule, gameRuleDefault);
}
});
}
});
}
/**
@@ -801,14 +875,16 @@ public final class WorldManager {
*/
public Attempt<LoadedMultiverseWorld, RegenFailureReason> regenWorld(@NotNull RegenWorldOptions options) {
LoadedMultiverseWorld world = options.world();
DataTransfer<LoadedMultiverseWorld> dataTransfer = transferData(options, world);
DataTransfer<LoadedMultiverseWorld> dataTransfer = regenWorldTransferData(options, world);
boolean shouldKeepSpawnLocation = options.keepWorldConfig() && options.seed() == world.getSeed();
Location spawnLocation = world.getSpawnLocation();
CreateWorldOptions createWorldOptions = CreateWorldOptions.worldName(world.getName())
CreateWorldOptions createWorldOptions = CreateWorldOptions.worldKeyOrName(world.worldConfig.getWorldKeyOrName())
.biome(world.getBiome())
.bonusChest(world.getBukkitWorld().map(WorldCompatibility::hasBonusChest).getOrElse(false))
.environment(world.getEnvironment())
.generateStructures(world.canGenerateStructures().getOrElse(true))
.generator(world.getGenerator())
.generatorSettings(world.getGeneratorSettings())
.seed(options.seed())
.useSpawnAdjust(!shouldKeepSpawnLocation && world.getAdjustSpawn())
.worldType(world.getWorldType().getOrElse(WorldType.NORMAL))
@@ -829,18 +905,35 @@ public final class WorldManager {
});
}
private DataTransfer<LoadedMultiverseWorld> regenWorldTransferData(
@NotNull KeepWorldSettingsOptions options, @NotNull LoadedMultiverseWorld world) {
DataTransfer<LoadedMultiverseWorld> dataTransfer = new DataTransfer<>();
if (options.keepWorldConfig()) {
dataTransfer.addDataStore(new WorldConfigStore<>(), world);
}
if (options.keepGameRule()) {
dataTransfer.addDataStore(new GameRulesStore(), world);
}
if (options.keepWorldBorder()) {
dataTransfer.addDataStore(new WorldBorderStore(), world);
}
return dataTransfer;
}
private <T, F extends FailureReason> Attempt<T, F> worldActionResult(@NotNull T value) {
return Attempt.success(value);
}
private <T, F extends FailureReason> Attempt<T, F> worldActionResult(
@NotNull F failureReason, @NotNull String worldName) {
return Attempt.failure(failureReason, Replace.WORLD.with(worldName));
private <T, F extends FailureReason> Attempt.Failure<T, F> worldActionResult(
@NotNull F failureReason, @NotNull Object worldName) {
return Attempt.failureRef(failureReason, Replace.WORLD.with(worldName));
}
private <T, F extends FailureReason> Attempt<T, F> worldActionResult(
private <T, F extends FailureReason> Attempt.Failure<T, F> worldActionResult(
@NotNull F failureReason, @NotNull String worldName, @NotNull Throwable error) {
return Attempt.failure(failureReason, Replace.WORLD.with(worldName), Replace.ERROR.with(error));
return Attempt.failureRef(failureReason, Replace.WORLD.with(worldName), Replace.ERROR.with(error));
}
private Attempt<WorldCreator, WorldCreatorFailureReason> addBiomeProviderToCreator(
@@ -936,17 +1029,13 @@ public final class WorldManager {
* Checks based on folder contents and name.
*
* @return A list of all potential worlds.
*
* @deprecated Use {@link PotentialWorldFinder#findPotentialWorlds()} instead.
*/
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public List<String> getPotentialWorlds() {
File[] files = Bukkit.getWorldContainer().listFiles();
if (files == null) {
return Collections.emptyList();
}
return Arrays.stream(files)
.filter(file -> !isWorld(file.getName()))
.filter(worldNameChecker::isValidWorldFolder)
.map(File::getName)
.toList();
return potentialWorldFinder.get().findPotentialWorlds();
}
/**
@@ -968,9 +1057,7 @@ public final class WorldManager {
* @return The world if it exists.
*/
public Option<MultiverseWorld> getWorld(@Nullable String worldName) {
return getLoadedWorld(worldName)
.map(world -> (MultiverseWorld) world)
.orElse(() -> getUnloadedWorld(worldName));
return worldStore.getWorld(worldName);
}
/**
@@ -981,9 +1068,7 @@ public final class WorldManager {
* @return The world if it exists.
*/
public Option<MultiverseWorld> getWorldByNameOrAlias(@Nullable String worldNameOrAlias) {
return getLoadedWorldByNameOrAlias(worldNameOrAlias)
.map(world -> (MultiverseWorld) world)
.orElse(() -> getUnloadedWorldByNameOrAlias(worldNameOrAlias));
return getWorld(worldStore.translateAlias(worldNameOrAlias));
}
/**
@@ -993,14 +1078,15 @@ public final class WorldManager {
* <p>If you want only unloaded worlds, use {@link #getUnloadedWorlds()}. If you want only loaded worlds, use
* {@link #getLoadedWorlds()}.</p>
*
* <p>Note that this is an unmodifiable copy of the current worlds. It will not update as worlds are added/removed.
* Call it everytime you need the most updated list of worlds.</p>
*
* @return A list of all worlds that may or may not be loaded.
*/
@Unmodifiable
@NotNull
public Collection<MultiverseWorld> getWorlds() {
return worldsMap.values().stream()
.map(world -> getLoadedWorld(world)
.map(loadedWorld -> (MultiverseWorld) loadedWorld)
.getOrElse(world))
.toList();
return worldStore.getWorlds();
}
/**
@@ -1010,7 +1096,7 @@ public final class WorldManager {
* @return True if the world is a world is known to multiverse, but may or may not be loaded.
*/
public boolean isWorld(@Nullable String worldName) {
return worldName != null && worldsMap.containsKey(worldName.toLowerCase(Locale.ENGLISH));
return worldName != null && worldStore.getWorld(worldName).isDefined();
}
/**
@@ -1020,9 +1106,7 @@ public final class WorldManager {
* @return The world if it exists.
*/
public Option<MultiverseWorld> getUnloadedWorld(@Nullable String worldName) {
return isLoadedWorld(worldName)
? Option.none()
: Option.of(worldName).flatMap(name -> Option.of(worldsMap.get(name.toLowerCase(Locale.ENGLISH))));
return worldStore.getUnloadedWorld(worldName);
}
/**
@@ -1032,18 +1116,7 @@ public final class WorldManager {
* @return The world if it exists.
*/
public Option<MultiverseWorld> getUnloadedWorldByNameOrAlias(@Nullable String worldNameOrAlias) {
return getUnloadedWorld(worldNameOrAlias).orElse(() -> getUnloadedWorldByAlias(worldNameOrAlias));
}
private Option<MultiverseWorld> getUnloadedWorldByAlias(@Nullable String alias) {
if (alias == null || alias.isEmpty()) {
return Option.none();
}
String colourlessAlias = ChatTextFormatter.removeColor(alias);
return Option.ofOptional(worldsMap.values().stream()
.filter(world -> !world.isLoaded())
.filter(world -> world.getColourlessAlias().equalsIgnoreCase(colourlessAlias))
.findFirst());
return getUnloadedWorld(worldStore.translateAlias(worldNameOrAlias));
}
/**
@@ -1051,10 +1124,10 @@ public final class WorldManager {
*
* @return A list of all worlds that are not loaded.
*/
@Unmodifiable
@NotNull
public Collection<MultiverseWorld> getUnloadedWorlds() {
return worldsMap.values().stream()
.filter(world -> !world.isLoaded())
.toList();
return worldStore.getUnloadedWorlds();
}
/**
@@ -1094,8 +1167,7 @@ public final class WorldManager {
* @return The multiverse world if it exists.
*/
public Option<LoadedMultiverseWorld> getLoadedWorld(@Nullable String worldName) {
return Option.of(worldName)
.flatMap(name -> Option.of(loadedWorldsMap.get(name.toLowerCase(Locale.ENGLISH))));
return Option.of(worldName).flatMap(worldStore::getLoadedWorld);
}
/**
@@ -1105,28 +1177,21 @@ public final class WorldManager {
* @return The multiverse world if it exists.
*/
public Option<LoadedMultiverseWorld> getLoadedWorldByNameOrAlias(@Nullable String worldNameOrAlias) {
return getLoadedWorld(worldNameOrAlias)
.orElse(() -> getLoadedWorldByAlias(worldNameOrAlias));
}
private Option<LoadedMultiverseWorld> getLoadedWorldByAlias(@Nullable String alias) {
if (alias == null || alias.isEmpty()) {
return Option.none();
}
return Option.ofOptional(loadedWorldsMap.values().stream()
.filter(world -> world.getColourlessAlias()
.equalsIgnoreCase(ChatTextFormatter.removeColor(alias)))
.findFirst());
return getLoadedWorld(worldStore.translateAlias(worldNameOrAlias));
}
/**
* Get a read-only list of all multiverse worlds that are loaded.
*
* <p>Note that this is an unmodifiable copy of the current worlds. It will not update as worlds are added/removed.
* Call it everytime you need the most updated list of worlds.</p>
*
* @return A list of all multiverse worlds that are loaded.
*/
@Unmodifiable
@NotNull
public Collection<LoadedMultiverseWorld> getLoadedWorlds() {
return loadedWorldsMap.values().stream()
.toList();
return worldStore.getLoadedWorlds();
}
/**
@@ -1156,7 +1221,7 @@ public final class WorldManager {
* @return True if the world is a multiverse world that is loaded.
*/
public boolean isLoadedWorld(@Nullable String worldName) {
return worldName != null && loadedWorldsMap.containsKey(worldName.toLowerCase(Locale.ENGLISH));
return worldName != null && worldStore.getLoadedWorld(worldName).isDefined();
}
/**
@@ -1184,4 +1249,18 @@ public final class WorldManager {
failure.printStackTrace();
});
}
WorldStore getWorldStore() {
return worldStore;
}
/**
* A simple pair wrapper for convenience to pass both the world key or name and the options together between methods
* when parsing world options.
*
* @param keyOrName The world key or name.
* @param options The world options.
* @param <T> The world options type.
*/
private record KeyOrNameWithOptions<T>(WorldKeyOrName keyOrName, T options) { }
}
@@ -0,0 +1,270 @@
package org.mvplugins.multiverse.core.world;
import com.google.common.base.Strings;
import com.google.common.collect.HashMultimap;
import com.google.common.collect.Multimap;
import io.vavr.control.Option;
import jakarta.inject.Inject;
import org.bukkit.NamespacedKey;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.Unmodifiable;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.utils.CaseInsensitiveStringMap;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
@Service
final class WorldStore {
/**
* Only loaded worlds, i.e. will be empty if all worlds are unloaded.
*/
private final List<LoadedMultiverseWorld> loadedList;
/**
* Only unloaded worlds, i.e. will be empty if all worlds are loaded.
*/
private final List<MultiverseWorld> unloadedList;
/**
* Contains single reference to all worlds, either loaded or unloaded ref depending on the world's current status.
*/
private final List<MultiverseWorld> worldList;
/**
* Maps all key and key to loaded ref.
*/
private final Map<String, LoadedMultiverseWorld> loadedMap;
/**
* Maps all key and key to unloaded ref.
*/
private final Map<String, MultiverseWorld> unloadedMap;
/**
* Maps colorless alias to world key. As alias may not be unique, this is a multimap.
*/
private final Multimap<String, String> aliasMap;
@Inject
private WorldStore() {
this.loadedList = new ArrayList<>();
this.unloadedList = new ArrayList<>();
this.worldList = new ArrayList<>();
this.loadedMap = new CaseInsensitiveStringMap<>();
this.unloadedMap = new CaseInsensitiveStringMap<>();
this.aliasMap = HashMultimap.create();
}
void putUnloadedWorld(MultiverseWorld world) {
if (world instanceof LoadedMultiverseWorld) {
throw new IllegalArgumentException("Loaded world cannot be put in unloaded map");
}
unloadedMap.put(world.getKey().toString(), world);
unloadedMap.put(world.getName(), world);
unloadedList.add(world);
worldList.add(world);
}
void putLoadedWorld(LoadedMultiverseWorld world) {
loadedMap.put(world.getKey().toString(), world);
loadedMap.put(world.getName(), world);
unloadedList.remove(unloadedMap.get(world.getKey().toString()));
worldList.remove(unloadedMap.get(world.getKey().toString()));
loadedList.add(world);
worldList.add(world);
}
void removeWorld(MultiverseWorld world) {
LoadedMultiverseWorld loadedRef = loadedMap.get(world.getKey().toString());
MultiverseWorld unloadedRef = unloadedMap.get(world.getKey().toString());
// remove from list
unloadedList.remove(unloadedRef);
loadedList.remove(loadedRef);
worldList.remove(unloadedRef);
worldList.remove(loadedRef);
// remove from maps
loadedMap.remove(world.getKey().toString());
unloadedMap.remove(world.getName());
unloadedMap.remove(world.getKey().toString());
unloadedMap.remove(world.getName());
// remove alias
aliasMap.remove(world.getColourlessAlias(), world.getKey().toString());
}
void removeLoadedWorld(LoadedMultiverseWorld world) {
// remove from list
loadedList.remove(world);
worldList.remove(world);
// remove from maps
loadedMap.remove(world.getKey().toString());
loadedMap.remove(world.getName());
// Add back unloaded
unloadedList.add(unloadedMap.get(world.getKey().toString()));
}
void changeAlias(@Nullable String oldAlias, @Nullable String newAlias, @NotNull MultiverseWorld world) {
if (Objects.equals(oldAlias, newAlias)) {
// nothing changed, ignore
return;
}
if (!Strings.isNullOrEmpty(oldAlias)) {
aliasMap.remove(oldAlias, world.getKey().toString());
}
if (!Strings.isNullOrEmpty(newAlias)) {
aliasMap.put(newAlias, world.getKey().toString());
}
}
@Unmodifiable
@NotNull
List<MultiverseWorld> getWorlds() {
return List.copyOf(worldList);
}
@Unmodifiable
@NotNull
List<LoadedMultiverseWorld> getLoadedWorlds() {
return List.copyOf(loadedList);
}
@Unmodifiable
@NotNull
List<MultiverseWorld> getUnloadedWorlds() {
return List.copyOf(unloadedList);
}
@NotNull
Option<MultiverseWorld> getWorld(@Nullable WorldKeyOrName keyOrName) {
return keyOrName == null
? Option.none()
: getWorld(keyOrName.usableKey()).orElse(() -> getWorld(keyOrName.usableName()));
}
@NotNull
Option<MultiverseWorld> getWorld(@Nullable NamespacedKey worldKey) {
return Option.of(worldKey).map(NamespacedKey::toString).flatMap(this::getWorld);
}
@NotNull
Option<MultiverseWorld> getWorld(@Nullable String worldKeyString) {
return Option.of((MultiverseWorld) loadedMap.get(worldKeyString))
.orElse(() -> Option.of(unloadedMap.get(worldKeyString)));
}
boolean isWorld(@Nullable WorldKeyOrName keyOrName) {
return keyOrName != null && (isWorld(keyOrName.usableKey()) || isWorld(keyOrName.usableName()));
}
boolean isWorld(@Nullable NamespacedKey worldKey) {
return getWorld(worldKey).isDefined();
}
boolean isWorld(@Nullable String worldKeyString) {
return getWorld(worldKeyString).isDefined();
}
@NotNull
Option<LoadedMultiverseWorld> getLoadedWorld(@Nullable WorldKeyOrName keyOrName) {
return keyOrName == null
? Option.none()
: getLoadedWorld(keyOrName.usableKey()).orElse(() -> getLoadedWorld(keyOrName.usableName()));
}
@NotNull
Option<LoadedMultiverseWorld> getLoadedWorld(@Nullable NamespacedKey worldKey) {
return Option.of(worldKey).map(NamespacedKey::toString).flatMap(this::getLoadedWorld);
}
@NotNull
Option<LoadedMultiverseWorld> getLoadedWorld(@Nullable String worldKeyString) {
return Option.of(loadedMap.get(worldKeyString))
.filter(MultiverseWorld::isLoaded);
}
boolean isLoadedWorld(@Nullable WorldKeyOrName keyOrName) {
return keyOrName != null && (isLoadedWorld(keyOrName.usableKey()) || isLoadedWorld(keyOrName.usableName()));
}
boolean isLoadedWorld(@Nullable NamespacedKey worldKey) {
return getLoadedWorld(worldKey).isDefined();
}
boolean isLoadedWorld(@Nullable String worldKeyString) {
return getLoadedWorld(worldKeyString).isDefined();
}
@NotNull
Option<MultiverseWorld> getUnloadedWorld(@Nullable WorldKeyOrName keyOrName) {
return keyOrName == null
? Option.none()
: getUnloadedWorld(keyOrName.usableKey()).orElse(() -> getUnloadedWorld(keyOrName.usableName()));
}
@NotNull
Option<MultiverseWorld> getUnloadedWorld(@Nullable NamespacedKey worldKey) {
return Option.of(worldKey).map(NamespacedKey::toString).flatMap(this::getUnloadedWorld);
}
@NotNull
Option<MultiverseWorld> getUnloadedWorld(@Nullable String worldKeyString) {
return Option.of(unloadedMap.get(worldKeyString))
.filter(world -> !world.isLoaded());
}
boolean isUnloadedWorld(@Nullable WorldKeyOrName keyOrName) {
return keyOrName != null && (isUnloadedWorld(keyOrName.usableKey()) || isUnloadedWorld(keyOrName.usableName()));
}
boolean isUnloadedWorld(@Nullable NamespacedKey worldKey) {
return getUnloadedWorld(worldKey).isDefined();
}
boolean isUnloadedWorld(@Nullable String worldKeyString) {
return getUnloadedWorld(worldKeyString).isDefined();
}
@NotNull
Option<MultiverseWorld> getUnloadedWorldRef(@Nullable WorldKeyOrName keyOrName) {
return keyOrName == null
? Option.none()
: getUnloadedWorldRef(keyOrName.usableKey()).orElse(() -> getUnloadedWorldRef(keyOrName.usableName()));
}
@NotNull
Option<MultiverseWorld> getUnloadedWorldRef(@Nullable NamespacedKey worldKey) {
return Option.of(worldKey).map(NamespacedKey::toString).flatMap(this::getUnloadedWorldRef);
}
@NotNull
Option<MultiverseWorld> getUnloadedWorldRef(@Nullable String worldKeyString) {
return Option.of(unloadedMap.get(worldKeyString));
}
@Nullable
String translateAlias(@Nullable String worldNameOrAlias) {
if (Strings.isNullOrEmpty(worldNameOrAlias)) {
return null;
}
//TODO: Not sure if we should fail if there is multiple alias of the same name, but for now just return the first one
return aliasMap.get(worldNameOrAlias).stream()
.findFirst()
.orElse(worldNameOrAlias);
}
}
@@ -10,6 +10,7 @@ import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
import io.vavr.control.Try;
import jakarta.inject.Inject;
import org.bukkit.NamespacedKey;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.InvalidConfigurationException;
import org.bukkit.configuration.file.YamlConfiguration;
@@ -17,6 +18,9 @@ import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.MultiverseCore;
import org.mvplugins.multiverse.core.utils.result.Attempt;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import org.mvplugins.multiverse.core.world.key.WorldKeyParseFailReason;
import static java.nio.file.StandardCopyOption.COPY_ATTRIBUTES;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
@@ -28,7 +32,7 @@ import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
final class WorldsConfigManager {
private static final String CONFIG_FILENAME = "worlds.yml";
private final SortedMap<String, WorldConfig> worldConfigMap;
private final SortedMap<WorldKeyOrName, WorldConfig> worldConfigMap;
private final File worldConfigFile;
private YamlConfiguration worldsConfig;
@@ -47,7 +51,7 @@ final class WorldsConfigManager {
*
* @return A {@link NewAndRemovedWorlds} record.
*/
public Try<NewAndRemovedWorlds> load() {
Try<NewAndRemovedWorlds> load() {
return Try.of(() -> {
loadWorldYmlFile();
return parseNewAndRemovedWorlds();
@@ -113,7 +117,7 @@ final class WorldsConfigManager {
config.set("worlds", null);
for (Map.Entry<String, ConfigurationSection> entry : worldConfigMap.entrySet()) {
config.set(encodeWorldName(entry.getKey()), entry.getValue());
config.set(encodeConfigKey(entry.getKey()), entry.getValue());
}
config.save(worldConfigFile);
})
@@ -162,33 +166,39 @@ final class WorldsConfigManager {
* @return A tuple containing a list of the new WorldConfigs added and a list of the worlds removed from the config.
*/
private NewAndRemovedWorlds parseNewAndRemovedWorlds() {
List<String> allWorldsInConfig = worldsConfig.getKeys(false)
List<WorldKeyOrName> allWorldsInConfig = worldsConfig.getKeys(false)
.stream()
.map(this::decodeWorldName)
.map(keyStr -> decodeConfigKey(keyStr)
.onFailure(reason ->
Logging.warning("Failed to parse world key '%s' in config: %s", keyStr, reason))
.getOrNull())
.filter(Objects::nonNull)
.toList();
List<WorldConfig> newWorldsAdded = new ArrayList<>();
for (String worldName : allWorldsInConfig) {
getWorldConfig(worldName)
.peek(config -> config.load(getWorldConfigSection(worldName)))
for (WorldKeyOrName worldKeyStr : allWorldsInConfig) {
getWorldConfig(worldKeyStr)
.peek(config -> config.load(getWorldConfigSection(worldKeyStr))
.onFailure(e -> Logging.warning("Failed to load world config for world '%s': %s",
worldKeyStr, e.getMessage())))
.orElse(() -> {
WorldConfig newWorldConfig = new WorldConfig(
worldName,
getWorldConfigSection(worldName),
worldKeyStr,
getWorldConfigSection(worldKeyStr),
multiverseCore);
worldConfigMap.put(worldName, newWorldConfig);
worldConfigMap.put(worldKeyStr, newWorldConfig);
newWorldsAdded.add(newWorldConfig);
return Option.of(newWorldConfig);
})
.peek(WorldConfig::save);
}
List<String> worldsRemoved = worldConfigMap.keySet().stream()
List<WorldKeyOrName> worldsRemoved = worldConfigMap.keySet().stream()
.filter(worldName -> !allWorldsInConfig.contains(worldName))
.toList();
for (String s : worldsRemoved) {
for (WorldKeyOrName s : worldsRemoved) {
worldConfigMap.remove(s);
}
@@ -200,7 +210,7 @@ final class WorldsConfigManager {
*
* @return Whether the worlds.yml file has been loaded.
*/
public boolean isLoaded() {
boolean isLoaded() {
return worldsConfig != null;
}
@@ -209,17 +219,17 @@ final class WorldsConfigManager {
*
* @return Whether the save was successful or the error that occurred.
*/
public Try<Void> save() {
Try<Void> save() {
return Try.run(() -> {
if (!isLoaded()) {
throw new IllegalStateException("WorldsConfigManager is not loaded!");
}
worldsConfig = new YamlConfiguration();
worldConfigMap.forEach((worldName, worldConfig) -> {
worldConfigMap.forEach((keyOrName, worldConfig) -> {
worldConfig.save().onFailure(e -> {
throw new RuntimeException("Failed to save world config: " + worldName, e);
throw new RuntimeException("Failed to save world %s in config: " + keyOrName, e);
});
worldsConfig.set(encodeWorldName(worldName), worldConfig.getConfigurationSection());
worldsConfig.set(encodeConfigKey(keyOrName), worldConfig.getConfigurationSection());
});
worldsConfig.save(worldConfigFile);
}).onFailure(e -> {
@@ -228,63 +238,81 @@ final class WorldsConfigManager {
}
/**
* Gets the {@link WorldConfig} instance of all worlds in the worlds.yml file.
* Gets the {@link WorldConfig} instance of a world in the worlds.yml file.
*
* @param worldName The name of the world to check.
* @param keyOrName The target key to get
* @return The {@link WorldConfig} instance of the world, or empty option if it doesn't exist.
*/
public @NotNull Option<WorldConfig> getWorldConfig(@NotNull String worldName) {
return Option.of(worldConfigMap.get(worldName));
@NotNull Option<WorldConfig> getWorldConfig(@NotNull WorldKeyOrName keyOrName) {
return Option.of(worldConfigMap.get(keyOrName));
}
@NotNull WorldConfig migrateWorldConfigKey(@NotNull WorldConfig worldConfig, @NotNull NamespacedKey toKey) {
worldConfig.save();
WorldKeyOrName newKeyOrName = WorldKeyOrName.parseKey(toKey);
WorldConfig migratedWorldConfig = new WorldConfig(
newKeyOrName,
worldConfig.getConfigurationSection(),
multiverseCore
);
deleteWorldConfig(worldConfig);
worldConfigMap.put(newKeyOrName, migratedWorldConfig);
return migratedWorldConfig;
}
/**
* Add a new world to the worlds.yml file. If a world with the given name already exists, an exception is thrown.
*
* @param worldName The name of the world to add.
* @param namespacedKey The target key to add
* @return The newly created {@link WorldConfig} instance.
*/
public @NotNull WorldConfig addWorldConfig(@NotNull String worldName) {
if (worldConfigMap.containsKey(worldName)) {
throw new IllegalArgumentException("WorldConfig for world " + worldName + " already exists.");
@NotNull WorldConfig addWorldConfig(@NotNull NamespacedKey namespacedKey) {
WorldKeyOrName keyOrName = WorldKeyOrName.parseKey(namespacedKey);
if (worldConfigMap.containsKey(keyOrName)) {
throw new IllegalStateException("WorldConfig for world " + namespacedKey + " already exists.");
}
WorldConfig worldConfig = new WorldConfig(worldName, getWorldConfigSection(worldName), multiverseCore);
worldConfigMap.put(worldName, worldConfig);
WorldConfig worldConfig = new WorldConfig(keyOrName, getWorldConfigSection(keyOrName), multiverseCore);
worldConfigMap.put(keyOrName, worldConfig);
return worldConfig;
}
/**
* Deletes the world config for the given world.
*
* @param worldName The name of the world to delete.
* @param worldConfig The target world config to delete
*/
public void deleteWorldConfig(@NotNull String worldName) {
worldConfigMap.remove(worldName);
worldsConfig.set(encodeWorldName(worldName), null);
void deleteWorldConfig(@NotNull WorldConfig worldConfig) {
worldConfigMap.remove(worldConfig.getWorldKeyOrName());
worldsConfig.set(encodeConfigKey(worldConfig.getWorldKeyOrName()), null);
}
/**
* Gets the {@link ConfigurationSection} for the given world in the worlds.yml file. If the section doesn't exist,
* it is created.
*
* @param worldName The name of the world.
* @param keyOrName the config key of the world to get the configuration section for.
* @return The {@link ConfigurationSection} for the given world.
*/
private ConfigurationSection getWorldConfigSection(String worldName) {
worldName = encodeWorldName(worldName);
return worldsConfig.isConfigurationSection(worldName)
? worldsConfig.getConfigurationSection(worldName)
: worldsConfig.createSection(worldName);
@NotNull
private ConfigurationSection getWorldConfigSection(@NotNull WorldKeyOrName keyOrName) {
String encodeWorldKey = encodeConfigKey(keyOrName);
ConfigurationSection section = worldsConfig.getConfigurationSection(encodeWorldKey);
return section == null ? worldsConfig.createSection(encodeWorldKey) : section;
}
private String encodeConfigKey(@NotNull WorldKeyOrName worldKeyOrName) {
return encodeConfigKey(worldKeyOrName.serialise());
}
/**
* Dot is a special character in YAML that causes sub-path issues.
* Remove dot . with [dot] as it is a special character in YAML that causes sub-path issues.
*/
private String encodeWorldName(String worldName) {
private String encodeConfigKey(@NotNull String worldName) {
return worldName.replace(".", "[dot]");
}
private String decodeWorldName(String worldName) {
return worldName.replace("[dot]", ".");
private Attempt<WorldKeyOrName, WorldKeyParseFailReason> decodeConfigKey(@NotNull String worldKeyStr) {
return WorldKeyOrName.parse(worldKeyStr.replace("[dot]", "."));
}
private static final class ConfigMigratedException extends RuntimeException {
@@ -1,6 +1,7 @@
package org.mvplugins.multiverse.core.world.entity;
import org.bukkit.entity.Entity;
import org.bukkit.entity.Player;
import org.bukkit.entity.SpawnCategory;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
@@ -40,25 +41,24 @@ public final class EntityPurger {
public int purgeEntities(LoadedMultiverseWorld world, SpawnCategory... spawnCategories) {
Set<SpawnCategory> spawnCategoriesSet = Set.of(spawnCategories);
AtomicInteger purgeCount = new AtomicInteger(0);
world.getBukkitWorld().peek(bukkitWorld -> {
for (Entity entity : bukkitWorld.getEntities()) {
if (spawnCategoriesSet.contains(entity.getSpawnCategory())) {
world.getBukkitWorld().peek(bukkitWorld -> bukkitWorld.getEntities().stream()
.filter(entity -> !(entity instanceof Player))
.filter(entity -> spawnCategoriesSet.contains(entity.getSpawnCategory()))
.forEach(entity -> {
entity.remove();
purgeCount.incrementAndGet();
}
}
});
}));
return purgeCount.get();
}
public int purgeAllEntities(LoadedMultiverseWorld world) {
AtomicInteger purgeCount = new AtomicInteger(0);
world.getBukkitWorld().peek(bukkitWorld -> {
for (Entity entity : bukkitWorld.getEntities()) {
entity.remove();
purgeCount.incrementAndGet();
}
});
world.getBukkitWorld().peek(bukkitWorld -> bukkitWorld.getEntities().stream()
.filter(entity -> !(entity instanceof Player))
.forEach(entity -> {
entity.remove();
purgeCount.incrementAndGet();
}));
return purgeCount.get();
}
}
@@ -4,6 +4,7 @@ import com.dumptruckman.minecraft.util.Logging;
import org.bukkit.configuration.ConfigurationSection;
import org.bukkit.configuration.MemoryConfiguration;
import org.bukkit.entity.Entity;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.SpawnCategory;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.config.CoreConfig;
@@ -33,6 +34,11 @@ public final class EntitySpawnConfig {
));
}
@ApiStatus.AvailableSince("5.7")
public boolean shouldAllowSpawn(EntityType entityType) {
return getSpawnCategoryConfig(SpawnCategoryMapper.getSpawnCategory(entityType)).shouldAllowSpawn(entityType);
}
public boolean shouldAllowSpawn(Entity entity) {
return getSpawnCategoryConfig(entity.getSpawnCategory()).shouldAllowSpawn(entity);
}
@@ -4,10 +4,8 @@ import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Try;
import org.bukkit.entity.EntityType;
import org.bukkit.entity.SpawnCategory;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.utils.ReflectHelper;
import java.lang.reflect.Field;
import java.lang.reflect.Method;
import java.util.ArrayList;
import java.util.Arrays;
@@ -21,26 +19,27 @@ import java.util.Map;
*/
final class SpawnCategoryMapper {
private static final Map<EntityType, SpawnCategory> entityTypeToSpawnCategoryMap;
private static final Map<SpawnCategory, List<EntityType>> spawnCategoryMap;
static {
entityTypeToSpawnCategoryMap = new HashMap<>();
spawnCategoryMap = new HashMap<>();
buildSpawnCategoryMap();
}
private static Map<SpawnCategory, List<EntityType>> spawnCategoryMap;
private static void buildSpawnCategoryMap() {
spawnCategoryMap = new HashMap<>();
Class<?> entityTypeClass = ReflectHelper.getClass("net.minecraft.world.entity.EntityType");
Class<?> entityTypeClass = ReflectHelper.tryGetClass("net.minecraft.world.entity.EntityType").getOrNull();
if (entityTypeClass == null) {
Logging.warning("Failed to find EntityType class. SpawnCategoryMapper will not work.");
return;
}
Method getCategoryMethod = ReflectHelper.getMethod(entityTypeClass, "getCategory");
Method getCategoryMethod = ReflectHelper.tryGetMethod(entityTypeClass, "getCategory").getOrNull();
if (getCategoryMethod == null) {
Logging.warning("Failed to find getCategory method. SpawnCategoryMapper will not work.");
return;
}
Class<?> craftSpawnCategoryClass = ReflectHelper.getClass("org.bukkit.craftbukkit.util.CraftSpawnCategory");
Class<?> craftSpawnCategoryClass = ReflectHelper.tryGetClass("org.bukkit.craftbukkit.util.CraftSpawnCategory").getOrNull();
if (craftSpawnCategoryClass == null) {
Logging.warning("Failed to find CraftSpawnCategory class. SpawnCategoryMapper will not work.");
return;
@@ -53,25 +52,26 @@ final class SpawnCategoryMapper {
Logging.warning("Failed to find toBukkit method. SpawnCategoryMapper will not work.");
return;
}
Field[] entityTypeFields = entityTypeClass.getFields();
for (Field entityTypeField : entityTypeFields) {
String entityName = entityTypeField.getName();
EntityType entityType = Try.of(() -> EntityType.valueOf(entityName)).getOrNull();
if (entityType == null) {
continue;
}
Object nmsEntityType = ReflectHelper.getFieldValue(null, entityTypeField, entityTypeClass);
Object nsmMobCategory = ReflectHelper.invokeMethod(nmsEntityType, getCategoryMethod);
if (nsmMobCategory == null) {
continue;
}
Object bukkitSpawnCategory = ReflectHelper.invokeMethod(null, toBukkitMethod, nsmMobCategory);
if (!(bukkitSpawnCategory instanceof SpawnCategory spawnCategory)) {
continue;
}
spawnCategoryMap.computeIfAbsent(spawnCategory, ignore -> new ArrayList<>())
.add(entityType);
}
Arrays.stream(entityTypeClass.getFields()).forEach(entityTypeField -> Try.of(() -> EntityType.valueOf(entityTypeField.getName()))
.peek(entityType -> ReflectHelper.tryGetStaticFieldValue(entityTypeField, entityTypeClass)
.flatMap(nmsEntityType -> ReflectHelper.tryInvokeMethod(nmsEntityType, getCategoryMethod))
.flatMap(nsmMobCategory -> ReflectHelper.tryInvokeStaticMethod(toBukkitMethod, nsmMobCategory))
.filter(bukkitSpawnCategory -> bukkitSpawnCategory instanceof SpawnCategory)
.map(bukkitSpawnCategory -> (SpawnCategory) bukkitSpawnCategory)
.peek(bukkitSpawnCategory -> entityTypeToSpawnCategoryMap.put(entityType, bukkitSpawnCategory))
.peek(bukkitSpawnCategory -> spawnCategoryMap
.computeIfAbsent(bukkitSpawnCategory, ignore -> new ArrayList<>())
.add(entityType))));
}
/**
* Gets the spawn category that the given entity type is in.
*
* @param entityType The entity type
* @return The spawn category that the given entity type is in, or null if it cannot be determined
*/
static SpawnCategory getSpawnCategory(EntityType entityType) {
return entityTypeToSpawnCategoryMap.get(entityType);
}
/**
@@ -9,7 +9,9 @@ import org.bukkit.GameRule;
import org.bukkit.World;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.config.handle.StringPropertyHandle;
import org.mvplugins.multiverse.core.utils.compatibility.WorldBorderCompatibility;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
/**
* A data store for storing and restoring data from an object.
@@ -90,14 +92,14 @@ public interface DataStore<T> {
/**
* A {@link DataStore} for storing and restoring world properties for a multiverse world.
*/
class WorldConfigStore implements DataStore<LoadedMultiverseWorld> {
class WorldConfigStore<T extends MultiverseWorld> implements DataStore<T> {
private Map<String, Object> configMap;
/**
* {@inheritDoc}
*/
@Override
public WorldConfigStore copyFrom(LoadedMultiverseWorld world) {
public WorldConfigStore<T> copyFrom(T world) {
this.configMap = new HashMap<>();
StringPropertyHandle worldPropertyHandler = world.getStringPropertyHandle();
worldPropertyHandler.getAllPropertyNames().forEach(name -> worldPropertyHandler.getProperty(name)
@@ -111,7 +113,7 @@ public interface DataStore<T> {
* {@inheritDoc}
*/
@Override
public WorldConfigStore pasteTo(LoadedMultiverseWorld world) {
public WorldConfigStore<T> pasteTo(T world) {
if (configMap == null) {
return this;
}
@@ -139,14 +141,16 @@ public interface DataStore<T> {
*/
@Override
public WorldBorderStore copyFrom(LoadedMultiverseWorld world) {
world.getBukkitWorld().peek(bukkitWorld -> {
borderCenterX = bukkitWorld.getWorldBorder().getCenter().getX();
borderCenterZ = bukkitWorld.getWorldBorder().getCenter().getZ();
borderDamageAmount = bukkitWorld.getWorldBorder().getDamageAmount();
borderDamageBuffer = bukkitWorld.getWorldBorder().getDamageBuffer();
borderSize = bukkitWorld.getWorldBorder().getSize();
borderTimeRemaining = bukkitWorld.getWorldBorder().getWarningTime();
});
world.getBukkitWorld()
.map(World::getWorldBorder)
.peek(worldBorder -> {
borderCenterX = worldBorder.getCenter().getX();
borderCenterZ = worldBorder.getCenter().getZ();
borderDamageAmount = worldBorder.getDamageAmount();
borderDamageBuffer = worldBorder.getDamageBuffer();
borderSize = worldBorder.getSize();
borderTimeRemaining = WorldBorderCompatibility.getWarningTimeTicks(worldBorder);
});
return this;
}
@@ -155,13 +159,15 @@ public interface DataStore<T> {
*/
@Override
public WorldBorderStore pasteTo(LoadedMultiverseWorld world) {
world.getBukkitWorld().peek(bukkitWorld -> {
bukkitWorld.getWorldBorder().setCenter(borderCenterX, borderCenterZ);
bukkitWorld.getWorldBorder().setDamageAmount(borderDamageAmount);
bukkitWorld.getWorldBorder().setDamageBuffer(borderDamageBuffer);
bukkitWorld.getWorldBorder().setSize(borderSize);
bukkitWorld.getWorldBorder().setWarningTime(borderTimeRemaining);
});
world.getBukkitWorld()
.map(World::getWorldBorder)
.peek(worldBorder -> {
worldBorder.setCenter(borderCenterX, borderCenterZ);
worldBorder.setDamageAmount(borderDamageAmount);
worldBorder.setDamageBuffer(borderDamageBuffer);
worldBorder.setSize(borderSize);
WorldBorderCompatibility.setWarningTimeTicks(worldBorder, borderTimeRemaining);
});
return this;
}
}
@@ -4,6 +4,7 @@ import com.dumptruckman.minecraft.util.Logging;
import io.vavr.control.Option;
import jakarta.inject.Inject;
import org.bukkit.World.Environment;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.config.CoreConfig;
@@ -19,6 +20,24 @@ import java.util.regex.Pattern;
@Service
public final class DimensionFinder {
/**
* Default world for nether worlds, The default is "%overworld_nether%", which means that the nether world of
* "world" will be named "world_nether".
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static final DimensionFormat DEFAULT_NETHER_FORMAT = new DimensionFormat("%overworld%_nether");
/**
* Default world name format for end worlds. The default is "%overworld%_the_end", which means that the end world
* of "world" will be named "world_the_end".
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static final DimensionFormat DEFAULT_END_FORMAT = new DimensionFormat("%overworld%_the_end");
private final CoreConfig config;
private final WorldManager worldManager;
@@ -7,9 +7,11 @@ import org.bukkit.Bukkit;
import org.bukkit.Location;
import org.bukkit.World;
import org.bukkit.entity.Player;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.destination.DestinationInstance;
import org.mvplugins.multiverse.core.teleportation.AsyncSafetyTeleporter;
import org.mvplugins.multiverse.core.teleportation.TeleportFailureReason;
import org.mvplugins.multiverse.core.utils.result.AsyncAttemptsAggregate;
@@ -84,6 +86,24 @@ public final class PlayerWorldTeleporter {
.getOrElse(AsyncAttemptsAggregate::emptySuccess);
}
/**
* Transfers all players from the given world to the given destination.
*
* @param world The world to transfer players from.
* @param destinationInstance The destination instance to transfer players to.
* @return A list of async futures that represent the teleportation result of each player.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public AsyncAttemptsAggregate<Void, TeleportFailureReason> transferAllFromWorldToDestination(
@NotNull LoadedMultiverseWorld world,
@NotNull DestinationInstance<?, ?> destinationInstance) {
return world.getPlayers()
.map(players -> safetyTeleporter.to(destinationInstance).teleport(players))
.getOrElse(AsyncAttemptsAggregate::emptySuccess);
}
/**
* Teleports all players to the given world's spawn location.
*
@@ -0,0 +1,81 @@
package org.mvplugins.multiverse.core.world.helpers;
import io.vavr.control.Option;
import jakarta.inject.Inject;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Unmodifiable;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility;
import org.mvplugins.multiverse.core.world.WorldManager;
import java.io.File;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Stream;
/**
* Tool that traversals the server folders to find all potential worlds that can be imported, based on folder contents
* and name.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Service
public final class PotentialWorldFinder {
private final WorldManager worldManager;
private final WorldNameChecker worldNameChecker;
@Inject
private PotentialWorldFinder(@NotNull WorldManager worldManager, @NotNull WorldNameChecker worldNameChecker) {
this.worldManager = worldManager;
this.worldNameChecker = worldNameChecker;
}
/**
* Gets a list of all potential worlds that can be loaded from the server folders.
* Checks based on folder contents and name.
*
* @return A list of all potential worlds.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@Unmodifiable
@NotNull
public List<String> findPotentialWorlds() {
return BukkitCompatibility.isUsingNewDimensionStorage()
? Stream.concat(findFromDimensionsFolder(), findFromRootFolder()).toList()
: findFromRootFolder().toList();
}
private Stream<String> findFromRootFolder() {
Path worldContainer = Bukkit.getWorldContainer().toPath();
return Arrays.stream(listFolders(worldContainer))
.filter(worldNameChecker::isValidWorldFolder)
.map(File::getName)
.filter(worldNameChecker::isValidWorldName)
.filter(worldName -> !worldManager.isWorld(worldName));
}
private Stream<String> findFromDimensionsFolder() {
Path worldContainer = BukkitCompatibility.getWorldFoldersDirectory();
return Arrays.stream(listFolders(worldContainer))
.map(File::getName)
.flatMap(namespace -> Arrays.stream(listFolders(worldContainer.resolve(namespace)))
.filter(worldNameChecker::isValidWorldFolder)
.map(File::getName)
.map(key -> namespace + ":" + key))
.filter(namespacedKey -> NamespacedKey.fromString(namespacedKey) != null)
.filter(namespacedKey -> !worldManager.isWorld(namespacedKey));
}
private @NotNull File[] listFolders(Path folder) {
return Option.of(folder.toFile().listFiles(File::isDirectory))
.getOrElse(() -> new File[0]);
}
}
@@ -0,0 +1,108 @@
package org.mvplugins.multiverse.core.world.helpers;
import org.bukkit.Bukkit;
import org.bukkit.NamespacedKey;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import java.io.File;
/**
* <p>Utility class for resolving the file system location of a world folder.</p>
*
* <p>This class handles the differences in world folder locations between server versions,
* particularly the new dimension storage system introduced in PaperMC 26.1 which stores worlds
* in a dimensions folder under the world level directory.</p>
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public final class WorldFolderResolver {
/**
* Resolves the world folder file path for the given world key or name.
* <br />
* This method automatically selects the appropriate resolution method based on whether the server
* is using the new dimension storage system introduced in PaperMC 26.1.
*
* @param keyOrName The world key or name to resolve.
* @return The resolved world folder file.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static File resolve(@NotNull WorldKeyOrName keyOrName) {
return BukkitCompatibility.isUsingNewDimensionStorage()
? resolveAsDimensionKey(keyOrName.usableKey())
: resolveAsLegacyWorldName(keyOrName.usableName());
}
/**
* Resolves the world folder file path for the given Multiverse world.
* <br />
* This method automatically selects the appropriate resolution method based on whether the server
* is using the new dimension storage system introduced in PaperMC 26.1.
*
* @param multiverseWorld The Multiverse world to resolve.
* @return The resolved world folder file.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static File resolve(@NotNull MultiverseWorld multiverseWorld) {
File dimensionKey = resolveAsDimensionKey(multiverseWorld.getKey());
File legacy = resolveAsLegacyWorldName(multiverseWorld.getName());
if (BukkitCompatibility.isUsingNewDimensionStorage()) {
return !dimensionKey.isDirectory() && legacy.isDirectory() ? legacy : dimensionKey;
}
return legacy;
}
/**
* Gets the world folder using the new dimension storage path format of {@code [namespace]/[key]}.
* <br />
* This method is only valid for PaperMC 26.1 and above. For earlier server versions,
* use {@link #resolveAsLegacyWorldName(String)} instead.
*
* @param namespacedKey The namespaced key of the world.
* @return The resolved world folder file using the new dimension storage format.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static File resolveAsDimensionKey(@NotNull NamespacedKey namespacedKey) {
return BukkitCompatibility.getWorldFoldersDirectory()
.resolve(namespacedKey.getNamespace())
.resolve(namespacedKey.getKey())
.toFile();
}
/**
* Gets the world folder using the legacy world name format from the server root directory.
* <br />
* This method is the default for server versions before PaperMC 26.1.
*
* @param worldName The name of the world.
* @return The resolved world folder file using the legacy world name format.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public static File resolveAsLegacyWorldName(@NotNull String worldName) {
return Bukkit.getWorldContainer()
.toPath()
.resolve(worldName)
.toFile();
}
private WorldFolderResolver() {
throw new UnsupportedOperationException("This is a utility class and cannot be instantiated");
}
}
@@ -1,15 +1,18 @@
package org.mvplugins.multiverse.core.world.helpers;
import java.io.File;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import io.vavr.control.Option;
import org.bukkit.Bukkit;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jvnet.hk2.annotations.Service;
import org.mvplugins.multiverse.core.utils.REPatterns;
import org.mvplugins.multiverse.core.utils.compatibility.BukkitCompatibility;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
/**
* <p>Utility class in helping to check the status of a world name and it's associated world folder.</p>
@@ -29,6 +32,18 @@ public final class WorldNameChecker {
"plugins",
"versions");
private static final List<WorldFolderSchema> WORLD_FOLDER_SCHEMA = List.of(
// OLD
WorldFolderSchema.file("level.dat"),
WorldFolderSchema.folder("DIM1"),
WorldFolderSchema.folder("DIM-1"),
// NEW
WorldFolderSchema.file("paper-world.yml"),
WorldFolderSchema.folder("data"),
WorldFolderSchema.folder("entities"),
WorldFolderSchema.folder("poi"),
WorldFolderSchema.folder("region"));
/**
* Checks if a world name is valid.
*
@@ -53,7 +68,7 @@ public final class WorldNameChecker {
if (name.isEmpty()) {
return NameStatus.EMPTY;
}
if (BLACKLIST_NAMES.contains(name)) {
if (!BukkitCompatibility.isUsingNewDimensionStorage() && BLACKLIST_NAMES.contains(name)) {
return NameStatus.BLACKLISTED;
}
if (!REPatterns.NAMESPACE_KEY.matcher(name).matches()) {
@@ -69,19 +84,54 @@ public final class WorldNameChecker {
*
* @param worldName The world name to check on.
* @return True if the folder exists, else false.
*
* @deprecated Use {@link #hasWorldFolder(WorldKeyOrName)} instead, which is more robust and supports namespaced keys.
*/
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public boolean hasWorldFolder(@Nullable String worldName) {
return checkFolder(worldName) != FolderStatus.DOES_NOT_EXIST;
}
/**
* Check if a world name has a world folder directory. It may not contain valid world data.
*
* @param worldKey The world key to check on.
* @return True if the folder exists, else false.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public boolean hasWorldFolder(@Nullable WorldKeyOrName worldKey) {
return checkFolder(worldKey) != FolderStatus.DOES_NOT_EXIST;
}
/**
* Checks if a world name has a valid world folder with basic world data.
*
* @param worldName The world name to check on.
* @return True if check result is valid, else false.
*
* @deprecated Use {@link #isValidWorldFolder(WorldKeyOrName)} instead, which is more robust and supports
* namespaced keys.
*/
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public boolean isValidWorldFolder(@Nullable String worldName) {
return checkFolder(worldName) == FolderStatus.VALID;
return checkFolder(worldName).loadable;
}
/**
* Checks if a world name has a valid world folder with basic world data.
*
* @param nameOrKey The world key to check on.
* @return True if check result is valid, else false.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public boolean isValidWorldFolder(@Nullable WorldKeyOrName nameOrKey) {
return checkFolder(nameOrKey).loadable;
}
/**
@@ -91,7 +141,7 @@ public final class WorldNameChecker {
* @return True if check result is valid, else false.
*/
public boolean isValidWorldFolder(@Nullable File worldFolder) {
return checkFolder(worldFolder) == FolderStatus.VALID;
return checkFolder(worldFolder).loadable;
}
/**
@@ -99,14 +149,45 @@ public final class WorldNameChecker {
*
* @param worldName The world name to check on.
* @return The resulting folder status.
*
* @deprecated Use {@link #checkFolder(WorldKeyOrName)} instead, which is more robust and supports namespaced keys.
*/
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
@NotNull
public FolderStatus checkFolder(@Nullable String worldName) {
if (worldName == null) {
return FolderStatus.DOES_NOT_EXIST;
}
File worldFolder = new File(Bukkit.getWorldContainer(), worldName);
return checkFolder(worldFolder);
return WorldKeyOrName.parse(worldName)
.map(this::checkFolder)
.getOrElse(FolderStatus.NOT_A_WORLD);
}
/**
* Checks the current folder status for a world key or name.
*
* @param keyOrName The world key or name to check on.
* @return The resulting folder status.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
@NotNull
public FolderStatus checkFolder(@Nullable WorldKeyOrName keyOrName) {
if (keyOrName == null) {
return FolderStatus.DOES_NOT_EXIST;
}
if (BukkitCompatibility.isUsingNewDimensionStorage()) {
File oldWorldFolder = WorldFolderResolver.resolveAsLegacyWorldName(keyOrName.usableName());
if (checkFolder(oldWorldFolder) == FolderStatus.VALID) {
return FolderStatus.REQUIRES_MIGRATION;
}
}
return checkFolder(WorldFolderResolver.resolve(keyOrName));
}
/**
@@ -120,7 +201,7 @@ public final class WorldNameChecker {
if (worldFolder == null || !worldFolder.exists() || !worldFolder.isDirectory()) {
return FolderStatus.DOES_NOT_EXIST;
}
if (!folderHasDat(worldFolder)) {
if (!folderWorldSchemaCheck(worldFolder)) {
return FolderStatus.NOT_A_WORLD;
}
return FolderStatus.VALID;
@@ -133,9 +214,54 @@ public final class WorldNameChecker {
* @param worldFolder The File that may be a world.
* @return True if it looks like a world, else false.
*/
private boolean folderHasDat(@NotNull File worldFolder) {
File[] files = worldFolder.listFiles((file, name) -> name.toLowerCase(Locale.ENGLISH).endsWith(".dat"));
return files != null && files.length > 0;
private boolean folderWorldSchemaCheck(@NotNull File worldFolder) {
return WORLD_FOLDER_SCHEMA.stream()
.filter(schema -> schema.check(worldFolder))
.count() >= 2;
}
/**
* Helper class to check if a file or folder exist.
*/
private interface WorldFolderSchema {
static WorldFolderSchema file(String path) {
return new WorldFile(path);
}
static WorldFolderSchema folder(String path) {
return new WorldFolder(path);
}
boolean check(File worldFolder);
final class WorldFile implements WorldFolderSchema {
private final String path;
private WorldFile(String path) {
this.path = path;
}
@Override
public boolean check(File worldFolder) {
File thisFolder = worldFolder.toPath().resolve(path).toFile();
return thisFolder.exists() && thisFolder.isFile();
}
}
final class WorldFolder implements WorldFolderSchema {
private final String path;
private WorldFolder(String path) {
this.path = path;
}
@Override
public boolean check(File worldFolder) {
File thisFolder = worldFolder.toPath().resolve(path).toFile();
return thisFolder.exists() && thisFolder.isDirectory();
}
}
}
/**
@@ -170,16 +296,39 @@ public final class WorldNameChecker {
/**
* Folder is valid.
*/
VALID,
VALID(true),
/**
* This folder will cause PaperMC to migrate to new dimension world folder in 26.1+
*/
REQUIRES_MIGRATION(true),
/**
* Folder exist, but contents in it doesnt look like a world.
*/
NOT_A_WORLD,
NOT_A_WORLD(false),
/**
* Folder does not exist.
*/
DOES_NOT_EXIST
DOES_NOT_EXIST(false),
;
private final boolean loadable;
FolderStatus(boolean loadable) {
this.loadable = loadable;
}
/**
* Whether this folder status is loadable, meaning it has the basic world data and can be loaded as a world.
* Note that this does not guarantee the server will definitely load the world with no errors.
*
* @return True if folder probably is loadable by the server, else false.
*/
@ApiStatus.AvailableSince("5.6")
public boolean isLoadable() {
return loadable;
}
}
}
@@ -0,0 +1,394 @@
package org.mvplugins.multiverse.core.world.key;
import io.vavr.control.Either;
import io.vavr.control.Option;
import io.vavr.control.Try;
import org.bukkit.NamespacedKey;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.mvplugins.multiverse.core.locale.message.LocalizableMessage;
import org.mvplugins.multiverse.core.locale.message.Message;
import org.mvplugins.multiverse.core.locale.message.MessageReplacement;
import org.mvplugins.multiverse.core.utils.ServerProperties;
import org.mvplugins.multiverse.core.utils.compatibility.UnsafeValuesCompatibility;
import org.mvplugins.multiverse.core.utils.result.Attempt;
import org.mvplugins.multiverse.core.world.helpers.DimensionFinder;
import java.util.Locale;
import java.util.Objects;
/**
* Represents either a world key or a world name, with methods to parse from strings and retrieve the key or name.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public sealed abstract class WorldKeyOrName implements Comparable<WorldKeyOrName>, LocalizableMessage permits WorldKeyOrName.Key, WorldKeyOrName.Name {
private static final String DEFAULT_OVERWORLD_KEY = "overworld";
private static final String DEFAULT_NETHER_KEY = "the_nether";
private static final String DEFAULT_END_KEY = "the_end";
/**
* Parse a string into a {@link WorldKeyOrName} instance.
* <p>
* The provided input will be interpreted as a namespaced key if it contains a ':' character,
* otherwise it will be treated as a world name.
*
* @param nameOrKey The input string to parse, may be a world name or a namespaced key string
* @return An {@link Attempt} containing either a parsed {@link WorldKeyOrName} on success or a
* {@link WorldKeyParseFailReason} describing the failure
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static Attempt<WorldKeyOrName, WorldKeyParseFailReason> parse(@Nullable String nameOrKey) {
if (nameOrKey == null || nameOrKey.isEmpty()) {
return Attempt.failure(WorldKeyParseFailReason.EMPTY);
}
return nameOrKey.contains(":") ? parseKey(nameOrKey) : parseName(nameOrKey);
}
/**
* Parse a world name into a {@link WorldKeyOrName} instance.
* <p>
* This will attempt to create a usable {@link NamespacedKey} from the given name using
* {@link NamespacedKey#minecraft(String)}. If parsing fails, a failure {@link Attempt} will be
* returned describing an invalid world name.
*
* @param name The world name to parse
* @return An {@link Attempt} containing a {@link WorldKeyOrName.Name} on success or a
* {@link WorldKeyParseFailReason#INVALID_WORLD_NAME} failure on error
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static Attempt<WorldKeyOrName, WorldKeyParseFailReason> parseName(@NotNull String name) {
return Try.of(() -> NamespacedKey.minecraft(mapWorldNameToMinecraftKey(name)))
.map(usableKey -> Attempt.<WorldKeyOrName, WorldKeyParseFailReason>success(new Name(name, usableKey)))
.recover(throwable -> Attempt.failure(WorldKeyParseFailReason.INVALID_WORLD_NAME,
MessageReplacement.Replace.WORLD.with(name)))
.getOrElse(() -> Attempt.failure(WorldKeyParseFailReason.INVALID_WORLD_NAME,
MessageReplacement.Replace.WORLD.with(name)));
}
private static String mapWorldNameToMinecraftKey(@NotNull String nameOrKey) {
String defaultLevelName = getMostAccurateLevelName();
String lowerCaseName = nameOrKey.toLowerCase(Locale.ROOT);
if (defaultLevelName.equalsIgnoreCase(lowerCaseName)) {
lowerCaseName = DEFAULT_OVERWORLD_KEY;
} else if (DimensionFinder.DEFAULT_NETHER_FORMAT.replaceOverworld(defaultLevelName).equalsIgnoreCase(lowerCaseName)) {
lowerCaseName = DEFAULT_NETHER_KEY;
} else if (DimensionFinder.DEFAULT_END_FORMAT.replaceOverworld(defaultLevelName).equalsIgnoreCase(lowerCaseName)) {
lowerCaseName = DEFAULT_END_KEY;
}
return lowerCaseName;
}
/**
* Parse a namespaced key string into a {@link WorldKeyOrName} instance.
*
* @param nameOrKey The namespaced key string to parse (e.g. "minecraft:world")
* @return An {@link Attempt} containing a {@link WorldKeyOrName.Key} on success or a
* {@link WorldKeyParseFailReason#INVALID_NAMESPACED_KEY} failure on error
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static Attempt<WorldKeyOrName, WorldKeyParseFailReason> parseKey(@NotNull String nameOrKey) {
return Option.of(NamespacedKey.fromString(nameOrKey))
.filter(Objects::nonNull)
.map(key -> Attempt.<WorldKeyOrName, WorldKeyParseFailReason>success(new Key(key, usableNameFromKey(key))))
.getOrElse(() -> Attempt.failure(WorldKeyParseFailReason.INVALID_NAMESPACED_KEY,
MessageReplacement.Replace.NAMESPACE.with(nameOrKey)));
}
/**
* Create a {@link WorldKeyOrName} representing a namespaced key.
*
* @param key The {@link NamespacedKey} to wrap
* @return A {@link WorldKeyOrName} containing the provided key
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static WorldKeyOrName parseKey(@NotNull NamespacedKey key) {
return new Key(key, usableNameFromKey(key));
}
private static String usableNameFromKey(@NotNull NamespacedKey key) {
return key.getNamespace().equals(NamespacedKey.MINECRAFT)
? mapMinecraftKeyToWorldName(key)
: mapCustomKeyToWorldName(key);
}
private static String mapMinecraftKeyToWorldName(@NotNull NamespacedKey key) {
String defaultLevelName = getMostAccurateLevelName();
return switch (key.getKey()) {
case DEFAULT_OVERWORLD_KEY -> defaultLevelName;
case DEFAULT_NETHER_KEY -> DimensionFinder.DEFAULT_NETHER_FORMAT.replaceOverworld(defaultLevelName);
case DEFAULT_END_KEY -> DimensionFinder.DEFAULT_END_FORMAT.replaceOverworld(defaultLevelName);
default -> key.getKey();
};
}
private static String mapCustomKeyToWorldName(@NotNull NamespacedKey key) {
return key.getNamespace() + "_" + key.getKey();
}
private static String getMostAccurateLevelName() {
return UnsafeValuesCompatibility.getMainLevelName()
.orElse(ServerProperties::getStaticLevelName)
.getOrElse("world"); // Worse case assume its world
}
/**
* Returns true if this instance represents a world name (not a namespaced key).
*
* @return true if this is a name variant
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract boolean isName();
/**
* Returns true if this instance represents a namespaced key (not a world name).
*
* @return true if this is a key variant
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract boolean isKey();
/**
* Get the underlying {@link NamespacedKey} if this instance is a key variant.
*
* @return An {@link Option} containing the key when present, otherwise {@link Option#none()}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract @NotNull Option<NamespacedKey> getKey();
/**
* Get the underlying world name if this instance is a name variant.
*
* @return An {@link Option} containing the name when present, otherwise {@link Option#none()}
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract @NotNull Option<String> getName();
/**
* Represent this value as an {@link Either} where the left side is the {@link NamespacedKey}
* and the right side is the world name.
*
* @return An {@link Either} containing either the key (left) or the name (right)
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract @NotNull Either<NamespacedKey, String> asEither();
/**
* Get a usable {@link NamespacedKey} for this instance.
* <p>
* If this instance is a key variant, the underlying key is returned. If it is a name variant,
* an implementation-provided usable key (derived from the name) is returned.
*
* @return A non-null {@link NamespacedKey} that can be used where a key is required
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract @NotNull NamespacedKey usableKey();
/**
* Get a usable name for this instance.
* <p>
* If this instance is a name variant, the underlying name is returned. If it is a key variant,
* a reasonable string representation derived from the key is returned.
*
* @return A non-null {@link String} representing a usable name
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract @NotNull String usableName();
/**
* Serialize this instance to a string form suitable for storage or comparison.
*
* @return The serialized representation of this value
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public abstract @NotNull String serialise();
/**
* Sorts the instances based on their serialized form, which ensures that keys and names are sorted in an
* alphabetical manner by default.
*
* @param o the object to be compared.
* @return a negative integer, zero, or a positive integer as this object is less than, equal to, or greater than the specified object.
*/
@Override
public int compareTo(WorldKeyOrName o) {
return serialise().compareTo(o.serialise());
}
/**
* {@inheritDoc}
*/
@Override
public @Nullable Message getLocalizableMessage() {
return Message.of(serialise());
}
public static final class Key extends WorldKeyOrName {
private final NamespacedKey key;
private final String usableName;
private Key(@NotNull NamespacedKey key, @NotNull String usableName) {
this.key = key;
this.usableName = usableName;
}
@Override
public boolean isName() {
return false;
}
@Override
public boolean isKey() {
return true;
}
@Override
public @NotNull Option<NamespacedKey> getKey() {
return Option.of(key);
}
@Override
public @NotNull Option<String> getName() {
return Option.none();
}
@Override
public @NotNull Either<NamespacedKey, String> asEither() {
return Either.left(key);
}
@Override
public @NotNull NamespacedKey usableKey() {
return key;
}
@Override
public @NotNull String usableName() {
return usableName;
}
@Override
public @NotNull String serialise() {
return key.toString();
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Key key1 = (Key) o;
return Objects.equals(key, key1.key);
}
@Override
public int hashCode() {
return Objects.hashCode(key);
}
@Override
public String toString() {
return "Key{" +
"key=" + key +
", usableName='" + usableName + '\'' +
'}';
}
}
public static final class Name extends WorldKeyOrName {
private final String name;
private final NamespacedKey usableKey;
private Name(String name, NamespacedKey usableKey) {
this.name = name;
this.usableKey = usableKey;
}
@Override
public boolean isName() {
return true;
}
@Override
public boolean isKey() {
return false;
}
@Override
public @NotNull Option<NamespacedKey> getKey() {
return Option.none();
}
@Override
public @NotNull Option<String> getName() {
return Option.of(name);
}
@Override
public @NotNull Either<NamespacedKey, String> asEither() {
return Either.right(name);
}
@Override
public @NotNull NamespacedKey usableKey() {
return usableKey;
}
@Override
public @NotNull String usableName() {
return name;
}
@Override
public @NotNull String serialise() {
return name;
}
@Override
public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false;
Name name1 = (Name) o;
return Objects.equals(name, name1.name);
}
@Override
public int hashCode() {
return Objects.hash(name);
}
@Override
public String toString() {
return "Name{" +
"name='" + name + '\'' +
", usableKey=" + usableKey +
'}';
}
}
}
@@ -0,0 +1,64 @@
package org.mvplugins.multiverse.core.world.key;
import co.aikar.locales.MessageKey;
import co.aikar.locales.MessageKeyProvider;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.utils.result.FailureReason;
/**
* Reasons why parsing a world key or name failed.
* <br />
* These values are returned by parsing utilities such as {@link org.mvplugins.multiverse.core.world.key.WorldKeyOrName#parse}
* to indicate the specific cause of failure so callers can present an appropriate localized message.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public enum WorldKeyParseFailReason implements FailureReason {
/**
* The provided input was null or empty.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
EMPTY(MVCorei18n.WORLDKEYPARSE_EMPTY),
/**
* The provided world name contains invalid characters or format.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
INVALID_WORLD_NAME(MVCorei18n.WORLDKEYPARSE_INVALIDWORLDNAME),
/**
* The provided string was intended to be a namespaced key but did not parse as a valid
* {@link org.bukkit.NamespacedKey} (malformed namespace/key or missing parts).
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
INVALID_NAMESPACED_KEY(MVCorei18n.WORLDKEYPARSE_INVALIDNAMESPACEDKEY),
;
private final MessageKeyProvider message;
WorldKeyParseFailReason(MessageKeyProvider message) {
this.message = message;
}
/**
* Return the localized message key associated with this failure reason.
* <p>
* Implementations of {@link FailureReason} can use this to provide a user-facing localized
* message for the failure.
*
* @return The {@link MessageKey} that corresponds to this failure reason
* @since 5.7
*/
@Override
public MessageKey getMessageKey() {
return message.getMessageKey();
}
}
@@ -1,7 +1,12 @@
package org.mvplugins.multiverse.core.world.options;
import io.vavr.control.Either;
import org.bukkit.NamespacedKey;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
import org.mvplugins.multiverse.core.world.MultiverseWorld;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
/**
* Options for customizing the cloning of a world.
@@ -9,36 +14,112 @@ import org.mvplugins.multiverse.core.world.LoadedMultiverseWorld;
public final class CloneWorldOptions implements KeepWorldSettingsOptions {
/**
* Creates a new {@link CloneWorldOptions} instance with the given world.
* Creates a new {@link CloneWorldOptions} instance with the given loaded world.
*
* @param world The world to clone.
* @param fromWorld The loaded world to clone.
* @param newWorldName The name of the new world.
* @return A new {@link CloneWorldOptions} instance.
*
* @deprecated Cloning can be done from unloaded worlds as well. Use {@link #fromTo(MultiverseWorld, String)} instead.
* To ensure you use the non-deprecated method, you need to downcast the loaded world to a {@link MultiverseWorld}
* before passing it in, for example: {@code CloneWorldOptions.fromTo((MultiverseWorld) loadedWorld, newWorldName)}
*/
public static CloneWorldOptions fromTo(LoadedMultiverseWorld world, String newWorldName) {
return new CloneWorldOptions(world, newWorldName);
@Deprecated(forRemoval = true, since = "5.6")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public static @NotNull CloneWorldOptions fromTo(@NotNull LoadedMultiverseWorld fromWorld, @NotNull String newWorldName) {
return fromTo((MultiverseWorld) fromWorld, newWorldName);
}
private final LoadedMultiverseWorld world;
private final String newWorldName;
/**
* Creates a new {@link CloneWorldOptions} instance with the given world.
*
* @param fromWorld The world to clone.
* @param newWorldName The name of the new world.
* @return A new {@link CloneWorldOptions} instance.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
public static @NotNull CloneWorldOptions fromTo(@NotNull MultiverseWorld fromWorld, @NotNull String newWorldName) {
return new CloneWorldOptions(fromWorld, Either.left(newWorldName));
}
/**
* Creates a new {@link CloneWorldOptions} instance with the given world and namespaced key.
*
* @param fromWorld The world to clone.
* @param key The namespaced key for the new world.
* @return A new {@link CloneWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static @NotNull CloneWorldOptions fromTo(@NotNull MultiverseWorld fromWorld, @NotNull NamespacedKey key) {
return new CloneWorldOptions(fromWorld, Either.right(WorldKeyOrName.parseKey(key)));
}
/**
* Creates a new {@link CloneWorldOptions} instance with the given world and world key or name.
*
* @param fromWorld The world to clone.
* @param keyOrName The key or name for the new world.
* @return A new {@link CloneWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static @NotNull CloneWorldOptions fromTo(@NotNull MultiverseWorld fromWorld, @NotNull WorldKeyOrName keyOrName) {
return new CloneWorldOptions(fromWorld, Either.right(keyOrName));
}
private final MultiverseWorld fromWorld;
private final Either<String, WorldKeyOrName> newWorldKeyOrName;
private boolean keepGameRule = true;
private boolean keepWorldConfig = true;
private boolean saveBukkitWorld = true;
private boolean keepWorldBorder = true;
CloneWorldOptions(LoadedMultiverseWorld world, String newWorldName) {
this.world = world;
this.newWorldName = newWorldName;
CloneWorldOptions(MultiverseWorld fromWorld, Either<String, WorldKeyOrName> newWorldKeyOrName) {
this.fromWorld = fromWorld;
this.newWorldKeyOrName = newWorldKeyOrName;
}
/**
* Gets the world to clone.
* Gets the loaded world to clone.
*
* @return The world to clone.
*
* @deprecated Cloning can be done from unloaded worlds as well. Use {@link #fromWorld()} instead.
*/
@Deprecated(forRemoval = true, since = "5.6")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public LoadedMultiverseWorld world() {
return world;
return fromWorld.asLoadedWorld().getOrNull();
}
/**
* Gets the loaded world to clone.
*
* @return The world to clone.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
public @NotNull MultiverseWorld fromWorld() {
return fromWorld;
}
/**
* Gets the new world key or name, either unparsed as string or the {@link WorldKeyOrName} instance.
*
* @return The new world key or name.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Either<String, WorldKeyOrName> newWorldKeyOrName() {
return newWorldKeyOrName;
}
/**
@@ -46,8 +127,10 @@ public final class CloneWorldOptions implements KeepWorldSettingsOptions {
*
* @return The name of the new world.
*/
public String newWorldName() {
return newWorldName;
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public @NotNull String newWorldName() {
return newWorldKeyOrName.fold(name -> name, WorldKeyOrName::usableName);
}
/**
@@ -1,10 +1,21 @@
package org.mvplugins.multiverse.core.world.options;
import co.aikar.commands.ACFUtil;
import io.vavr.control.Either;
import io.vavr.control.Option;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.bukkit.WorldType;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jetbrains.annotations.UnmodifiableView;
import org.mvplugins.multiverse.core.utils.position.EntityPosition;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
/**
* Options for customizing the creation of a new world.
@@ -18,12 +29,43 @@ public final class CreateWorldOptions {
* @return A new {@link CreateWorldOptions} instance.
*/
public static @NotNull CreateWorldOptions worldName(@NotNull String worldName) {
return new CreateWorldOptions(worldName);
return new CreateWorldOptions(Either.left(worldName));
}
private final String worldName;
/**
* Creates a new {@link CreateWorldOptions} instance with the given namespaced key. Note that creating world with
* namespace requires PaperMC. This will not work on Spigot.
*
* @param key The namespaced key for the world to create.
* @return A new {@link CreateWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static @NotNull CreateWorldOptions worldKey(@NotNull NamespacedKey key) {
return new CreateWorldOptions(Either.right(WorldKeyOrName.parseKey(key)));
}
/**
* Creates a new {@link CreateWorldOptions} instance with the given world key or name. Note that creating world with
* namespace requires PaperMC. WorldKeyOrName parsed as namespaced key (i.e. {@link WorldKeyOrName#isKey()} is true)
* will not work on Spigot.
*
* @param keyOrName The key or name for the world to create.
* @return A new {@link CreateWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static @NotNull CreateWorldOptions worldKeyOrName(@NotNull WorldKeyOrName keyOrName) {
return new CreateWorldOptions(Either.right(keyOrName));
}
private final Either<String, WorldKeyOrName> keyOrName;
private String biome = "";
private boolean bonusChest = false;
private World.Environment environment = World.Environment.NORMAL;
private EntityPosition forcedSpawnPosition = null;
private boolean generateStructures = true;
private String generator = null;
private String generatorSettings = "";
@@ -31,24 +73,38 @@ public final class CreateWorldOptions {
private boolean useSpawnAdjust = true;
private WorldType worldType = WorldType.NORMAL;
private boolean doFolderCheck = true;
private final Map<String, String> worldPropertyStrings = new HashMap<>();
/**
* Creates a new {@link CreateWorldOptions} instance with the given world name.
* Creates a new {@link CreateWorldOptions} instance with either a world name or world key or name.
*
* @param worldName The name of the world to create.
* @param keyOrName Either the world name or the world key/name instance.
*/
CreateWorldOptions(@NotNull String worldName) {
this.worldName = worldName;
CreateWorldOptions(@NotNull Either<String, WorldKeyOrName> keyOrName) {
this.keyOrName = keyOrName;
this.seed = ACFUtil.RANDOM.nextLong();
}
/**
* Gets the new world key or name, either unparsed as string or the {@link WorldKeyOrName} instance.
*
* @return The new world key or name.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Either<String, WorldKeyOrName> keyOrName() {
return keyOrName;
}
/**
* Gets the name of the world to create.
*
* @return The name of the world to create.
*/
@Deprecated(forRemoval = true, since = "5.7")
public @NotNull String worldName() {
return worldName;
return keyOrName.fold(name -> name ,WorldKeyOrName::usableName);
}
/**
@@ -73,6 +129,32 @@ public final class CreateWorldOptions {
return biome;
}
/**
* Sets whether bonus chest should generate at spawn upon world creation.
* <br />
* This feature only works on PaperMC 1.21.5+
*
* @param bonusChest Whether bonus chest should generate at spawn upon world creation.
* @return This {@link CreateWorldOptions} instance.
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull CreateWorldOptions bonusChest(boolean bonusChest) {
this.bonusChest = bonusChest;
return this;
}
/**
* Gets whether bonus chest should generate at spawn upon world creation.
* <br />
* This feature only works on PaperMC 1.21.5+
*
* @return true if bonus chest should generate, else false.
*/
@ApiStatus.AvailableSince("5.7")
public boolean bonusChest() {
return bonusChest;
}
/**
* Sets the environment of the world to create.
*
@@ -93,6 +175,37 @@ public final class CreateWorldOptions {
return environment;
}
/**
* Sets the forced spawn position of the world to apply. This may be null, in which case the spawn position will
* be determined by the default generator. Setting spawn position and {@link #useSpawnAdjust(boolean)} to false
* will improve world creation speed on PaperMC as chunks will not be loaded to search for spawn point.
* <br />
* This feature only works on PaperMC 26.1+
*
* @param forcedSpawnPosition The forced spawn position of the world to create.
* @return This {@link CreateWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull CreateWorldOptions forcedSpawnPosition(@Nullable EntityPosition forcedSpawnPosition) {
this.forcedSpawnPosition = forcedSpawnPosition;
return this;
}
/**
* Sets the forced spawn position of the world to apply. This may be null, in which case the spawn position will
* be determined by the default generator.
* <br />
* This feature only works on PaperMC 26.1+
*
* @return The force spawn position to apply if available.
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Option<EntityPosition> forcedSpawnPosition() {
return Option.of(this.forcedSpawnPosition);
}
/**
* Sets whether structures such as NPC villages should be generated.
*
@@ -260,4 +373,46 @@ public final class CreateWorldOptions {
public boolean doFolderCheck() {
return doFolderCheck;
}
/**
* Sets a world property string key-value pair. Overwrites any existing key.
*
* @param key The key of the world property string.
* @param value The value of the world property string.
* @return This {@link CreateWorldOptions} instance.
*
* @since 5.5
*/
@ApiStatus.AvailableSince("5.5")
public @NotNull CreateWorldOptions worldPropertyString(@NotNull String key, @Nullable String value) {
this.worldPropertyStrings.put(key, value);
return this;
}
/**
* Sets multiple world property string key-value pairs. Overwrites any existing keys.
*
* @param worldProperties A map of world property string key-value pairs.
* @return This {@link CreateWorldOptions} instance.
*
* @since 5.5
*/
@ApiStatus.AvailableSince("5.5")
public @NotNull CreateWorldOptions worldPropertyStrings(@NotNull Map<@NotNull String, @Nullable String> worldProperties) {
this.worldPropertyStrings.putAll(worldProperties);
return this;
}
/**
* Gets an unmodifiable view of the world property strings. Use {@link #worldPropertyString(String, String)} and
* {@link #worldPropertyStrings(Map)} to modify the world property strings.
*
* @return An unmodifiable view of the world property strings.
*
* @since 5.5
*/
@ApiStatus.AvailableSince("5.5")
public @UnmodifiableView @NotNull Map<String, String> worldPropertyStrings() {
return Collections.unmodifiableMap(worldPropertyStrings);
}
}
@@ -1,9 +1,12 @@
package org.mvplugins.multiverse.core.world.options;
import io.vavr.control.Either;
import org.bukkit.NamespacedKey;
import org.bukkit.World;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.mvplugins.multiverse.core.world.key.WorldKeyOrName;
/**
* Options for customizing the import of a new world.
@@ -17,18 +20,65 @@ public final class ImportWorldOptions {
* @return A new {@link ImportWorldOptions} instance.
*/
public static @NotNull ImportWorldOptions worldName(@NotNull String worldName) {
return new ImportWorldOptions(worldName);
return new ImportWorldOptions(Either.left(worldName));
}
private final String worldName;
/**
* Creates a new {@link ImportWorldOptions} instance with the given namespaced key. Note that importing world with
* namespace requires PaperMC. This will not work on Spigot.
*
* @param key The namespaced key for the world to import.
* @return A new {@link ImportWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static @NotNull ImportWorldOptions worldKey(@NotNull NamespacedKey key) {
return new ImportWorldOptions(Either.right(WorldKeyOrName.parseKey(key)));
}
/**
* Creates a new {@link ImportWorldOptions} instance with the given world key or name. Note that importing world with
* namespace requires PaperMC. WorldKeyOrName parsed as namespaced key (i.e. {@link WorldKeyOrName#isKey()} is true)
* will not work on Spigot.
*
* @param keyOrName The key or name for the world to import.
* @return A new {@link ImportWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public static @NotNull ImportWorldOptions worldKeyOrName(@NotNull WorldKeyOrName keyOrName) {
return new ImportWorldOptions(Either.right(keyOrName));
}
private final Either<String, WorldKeyOrName> keyOrName;
private String biome = "";
private World.Environment environment = World.Environment.NORMAL;
private String generator = null;
private String generatorSettings = "";
private boolean useSpawnAdjust = true;
private boolean doFolderCheck = true;
ImportWorldOptions(String worldName) {
this.worldName = worldName;
/**
* Creates a new {@link ImportWorldOptions} instance with either a world name or world key or name.
*
* @param keyOrName Either the world name or the world key/name instance.
*/
ImportWorldOptions(Either<String, WorldKeyOrName> keyOrName) {
this.keyOrName = keyOrName;
}
/**
* Gets the new world key or name, either unparsed as string or the {@link WorldKeyOrName} instance.
*
* @return The new world key or name.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull Either<String, WorldKeyOrName> keyOrName() {
return keyOrName;
}
/**
@@ -36,8 +86,10 @@ public final class ImportWorldOptions {
*
* @return The name of the world to create.
*/
@Deprecated(forRemoval = true, since = "5.7")
@ApiStatus.ScheduledForRemoval(inVersion = "6.0")
public @NotNull String worldName() {
return worldName;
return keyOrName.fold(name -> name ,WorldKeyOrName::usableName);
}
/**
@@ -102,6 +154,32 @@ public final class ImportWorldOptions {
return generator;
}
/**
* Sets the custom generator settings string used when creating the world.
*
* @param generatorSettingsInput The custom generator settings string, or null to use the default settings.
* @return This {@link ImportWorldOptions} instance.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull ImportWorldOptions generatorSettings(@NotNull String generatorSettingsInput) {
this.generatorSettings = generatorSettingsInput;
return this;
}
/**
* Gets the custom generator settings string used when creating the world.
*
* @return The custom generator settings string, or null if none was set.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
public @NotNull String generatorSettings() {
return generatorSettings;
}
/**
* Sets whether multiverse will search for a safe spawn location.
*
@@ -3,6 +3,7 @@ package org.mvplugins.multiverse.core.world.reasons;
import co.aikar.locales.MessageKey;
import co.aikar.locales.MessageKeyProvider;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.utils.result.FailureReason;
@@ -15,6 +16,15 @@ public enum CloneFailureReason implements FailureReason {
*/
INVALID_WORLDNAME(MVCorei18n.CLONEWORLD_INVALIDWORLDNAME),
/**
* The server software does not support create worlds using namespaced key. Only legacy world name is supported.
* Generally this will only be an issue on Spigot servers.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
NAMESPACEDKEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED),
/**
* The target new world folder already exists.
*/
@@ -30,6 +40,14 @@ public enum CloneFailureReason implements FailureReason {
*/
WORLD_EXIST_LOADED(MVCorei18n.CLONEWORLD_WORLDEXISTLOADED),
/**
* The source world folder is invalid.
*
* @since 5.6
*/
@ApiStatus.AvailableSince("5.6")
FROM_WORLD_FOLDER_INVALID(MVCorei18n.CLONEWORLD_FROMWORLDFOLDERINVALID),
/**
* Failed to copy the world folder contents.
*/
@@ -3,6 +3,7 @@ package org.mvplugins.multiverse.core.world.reasons;
import co.aikar.locales.MessageKey;
import co.aikar.locales.MessageKeyProvider;
import org.jetbrains.annotations.ApiStatus;
import org.mvplugins.multiverse.core.locale.MVCorei18n;
import org.mvplugins.multiverse.core.utils.result.FailureReason;
@@ -15,6 +16,15 @@ public enum CreateFailureReason implements FailureReason {
*/
INVALID_WORLDNAME(MVCorei18n.CREATEWORLD_INVALIDWORLDNAME),
/**
* The server software does not support create worlds using namespaced key. Only legacy world name is supported.
* Generally this will only be an issue on Spigot servers.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
NAMESPACEDKEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED),
/**
* The target new world folder already exists.
*/
@@ -16,6 +16,15 @@ public enum ImportFailureReason implements FailureReason {
*/
INVALID_WORLDNAME(MVCorei18n.IMPORTWORLD_INVALIDWORLDNAME),
/**
* The server software does not support create worlds using namespaced key. Only legacy world name is supported.
* Generally this will only be an issue on Spigot servers.
*
* @since 5.7
*/
@ApiStatus.AvailableSince("5.7")
NAMESPACEDKEY_UNSUPPORTED(MVCorei18n.WORLDKEYPARSE_NAMESPACEDKEYUNSUPPORTED),
/**
* The world folder is invalid.
*/
@@ -47,6 +47,12 @@ public enum LoadFailureReason implements FailureReason {
@ApiStatus.AvailableSince("5.2")
BUKKIT_ENVIRONMENT_MISMATCH(MVCorei18n.LOADWORLD_BUKKITENVIRONMENTMISMATCH),
/**
* The mv world's namespace key does not match the loaded Bukkit world's key.
*/
@ApiStatus.AvailableSince("5.7")
BUKKIT_NAMESPACED_KEY_MISMATCH(MVCorei18n.LOADWORLD_BUKKITNAMESPACEDKEYMISMATCH),
/**
* Bukkit API failed to create the world.
*/
@@ -72,7 +72,8 @@ mv-core.delete.success=&aWorld '{world}' deleted!
# /mv dumps
mv-core.dumps.description=Dumps version info to the console or paste services
mv-core.dumps.url.list={service} : {link}
mv-core.dumps.starting=Gathering logs and debug info...
mv-core.dumps.url.list=&aUploaded with {service} backend: &6{link}
# /mv gamerule set
mv-core.gamerule.set.description=Changes a gamerule in one or more worlds.
@@ -127,6 +128,24 @@ mv-core.load.world.description=Name of world you want to load.
mv-core.load.loading=Loading world '{world}'...
mv-core.load.success=&aWorld '{world}' loaded!
# /mv meta info
mv-core.meta.info.description=Shows all metadata for a world.
mv-core.meta.info.world.description=The world to show metadata for.
mv-core.meta.info.header=&a&l---- World Metadata: &f&l{world}&a&l ----
mv-core.meta.info.nocontent=&cNo metadata found for world '{world}'. You can set metadata with '&6/mv meta modify {world} set <key> <value>&c' command!
# /mv meta modify
mv-core.meta.modify.description=Modifies a metadata of a given world.
mv-core.meta.modify.world.description=Name of world you want to modify metadata of.
mv-core.meta.modify.action.description=Action to perform on the metadata. Can be 'set', 'remove', or 'reset'.
mv-core.meta.modify.key.description=Metadata key to modify.
mv-core.meta.modify.value.description=Value to set the metadata to. Only used when action is 'set'.
mv-core.meta.modify.set.success=&aSuccessfully set metadata key '&9{key}&a' to '&9{value}&a' for world &9{world}&a.
mv-core.meta.modify.set.failure=&cFailed to set metadata key '&9{key}&c' to '&9{value}&c' for world &9{world}&c.\n&c{error}
mv-core.meta.modify.remove.success=&aSuccessfully removed metadata key '&9{key}&a' for world &9{world}&a.
mv-core.meta.modify.remove.failure=&cFailed to remove metadata key '&9{key}&c' for world &9{world}&c.\n&c{error}
mv-core.meta.modify.invalidaction=&cInvalid action '&6{action}&c'! Valid actions are 'set' and 'remove' only.
# /mv modify
mv-core.modify.description=Modifies a world property of a given world.
mv-core.modify.world.description=Name of world you want to modify.
@@ -221,15 +240,17 @@ mv-core.worldborder.damagebuffer.success=&rSet the world border damage buffer to
mv-core.worldborder.get.size=&rThe world border is currently {size} block(s) wide for world '&6{world}&r'.
mv-core.worldborder.set.nothingchanged=&cNothing changed. The world border is already that size for world '&6{world}&c'.
mv-core.worldborder.set.immediate=&rSet the world border to {size} block(s) wide for world '&6{world}&r'.
mv-core.worldborder.set.shrinking=&rShrinking the world border to {size} block(s) wide over {time} second(s) for world '&6{world}&r'.
mv-core.worldborder.set.growing=&rGrowing the world border to {size} block(s) wide over {time} second(s) for world '&6{world}&r'.
mv-core.worldborder.set.immediate=&fSet the world border to &6{size}&f block(s) wide for world '&6{world}&f'.
mv-core.worldborder.set.shrinking=&fShrinking the world border to &6{size}&f block(s) wide over &6{time}&f second(s) for world '&6{world}&f'.
mv-core.worldborder.set.growing=&fGrowing the world border to &6{size} block(s) wide over &6{time}&f second(s) for world '&6{world}&f'.
mv-core.worldborder.warningdistance.nothingchanged=&cNothing changed. The world border warning is already that distance for world '&6{world}&c'.
mv-core.worldborder.warningdistance.success=&rSet the world border warning distance to {distance} block(s) for world '&6{world}&r'.
mv-core.worldborder.warningdistance.success=&fSet the world border warning distance to &6{distance}&f block(s) for world '&6{world}&f'.
mv-core.worldborder.warningtime.nothingchanged=&cNothing changed. The world border warning is already that amount of time for world '&6{world}&c'.
mv-core.worldborder.warningtime.success=&rSet the world border warning time to {time} second(s) for world '&6{world}&r'.
mv-core.worldborder.warningtime.success=&fSet the world border warning time to &6{time}&f second(s) for world '&6{world}&f'.
mv-core.worldborder.roundoff.warning=&cYour server version does not support setting time in ticks. Hence, it will be rounded to the nearest second from &6{fromTime}&c second(s) to &6{toTime}&c second(s).
# commands error
mv-core.commands.error.playersonly=&cThis command can only be used by players
@@ -259,6 +280,10 @@ mv-core.destination.shared.failurereason.invalidnumberformat=&cInvalid destinati
mv-core.destination.shared.failurereason.worldnotfound=&cWorld '&6{world}&c' does not exist or is not loaded!
mv-core.destination.parse.failurereason.invaliddestinationid=Invalid destination id: {id}. Valid destination ids are: {ids}
# portal form disabled
mv-core.portalform.disabled.end=&cCreating end portals is disabled in this world.
mv-core.portalform.disabled.nether=&cCreating nether portals is disabled in this world.
# teleport failure reason
mv-core.teleportfailurereason.null.destination=The destination is invalid!
mv-core.teleportfailurereason.null.location=The location is invalid!
@@ -273,6 +298,7 @@ mv-core.cloneworld.invalidworldname=World '{world}' contains invalid characters!
mv-core.cloneworld.worldexistfolder=World '{world}' exists in server folders! You need to delete it first before cloning.
mv-core.cloneworld.worldexistunloaded=World '{world}' already exists and it's unloaded! You need to delete it first before cloning.
mv-core.cloneworld.worldexistloaded=World '{world}' already exists! You need to delete it first before cloning.
mv-core.cloneworld.fromworldfolderinvalid=Source world '{world}' folder contents does not seem to be a valid world! Make sure it contains the correct world structure of data and region folders.
mv-core.cloneworld.copyfailed=Failed to copy world to '{world}': {error}\n&fSee console for more details.
mv-core.createworld.invalidworldname=World '{world}' contains invalid characters!
@@ -288,14 +314,16 @@ mv-core.deleteworld.failedtodeletefolder=Failed to delete world folder '{world}'
mv-core.importworld.invalidworldname=World '{world}' contains invalid characters!
mv-core.importworld.worldexistunloaded=World '{world}' already exists, but it's not loaded!&f Type '&a/mv load {world}&f' if you wish to load it.
mv-core.importworld.worldexistloaded=World '{world}' already exists!
mv-core.importworld.worldfolderinvalid=World '{world}' folder contents does not seem to be a valid world!\n&cIf the server software does something different with world folders, or you are very certain the world is valid, use '&3--skip-folder-check&c' flag.
mv-core.importworld.worldfolderinvalid=World '{world}' folder contents does not seem to be a valid world! Make sure it contains the correct world structure of data and region folders.\n&cIf the server software does something different with world folders, or you are very certain the world is valid, use '&3--skip-folder-check&c' flag.
mv-core.importworld.bukkitenvironmentmismatch=Environment mismatch detected!&f The world '{world}' is already loaded with environment '{bukkitEnvironment}', but Multiverse is trying to import it with environment '{mvEnvironment}'.
mv-core.importworld.bukkitnamespacemismatch=Namespace mismatch detected!&f The world '{world}' is already loaded with namespace '{bukkitNamespace}', but Multiverse is trying to import it with namespace '{mvNamespace}'.
mv-core.loadworld.worldalreadyloading=World '{world}' is already loading! Please wait...
mv-core.loadworld.worldnonexistent=World '{world}' not found! Use '&a/mv create {world} <environment>&f' to create it.
mv-core.loadworld.worldexistfolder=World '{world}' exists in server folders, but it's not known to Multiverse!&f Type '&a/mv import {world} <environment>&f' if you wish to import it.
mv-core.loadworld.worldexistloaded=World '{world}' is already loaded!
mv-core.loadworld.bukkitenvironmentmismatch=Environment mismatch detected!&f The world '{world}' is already loaded with environment '{bukkitEnvironment}', but Multiverse is trying to load it with environment '{mvEnvironment}'.
mv-core.loadworld.bukkitnamespacemismatch=Namespace mismatch detected!&f The world '{world}' is already loaded with namespace '{bukkitNamespace}', but Multiverse is trying to load it with namespace '{mvNamespace}'.
mv-core.removeworld.worldnonexistent=World '{world}' not found!
@@ -332,6 +360,12 @@ mv-core.exception.positionparse.invaliddirection=&cInvalid direction string form
mv-core.exception.positionparse.invalidcoordinates=&cInvalid coordinates format: {format}. Expected format: <x>,<y>,<z>
mv-core.exception.positionparse.invalidnumber=&cInvalid number format: {number}. Expects a numeric value.
# world key parse failures
mv-core.worldkeyparse.empty==&cWorld name/key cannot be empty!
mv-core.worldkeyparse.invalidworldname=&cWorld name '{world}' contains invalid characters. The allowed characters are: a-z, A-Z, 0-9, _, -, and . (dot)
mv-core.worldkeyparse.invalidnamespacedkey=&cNamespaced key '{namespace}' contains invalid characters. Expected a namespaced key format is 'namespace:key'. The allowed characters are: a-z (only lowercase), 0-9, _, -, and . (dot), with a single ':'.
mv-core.worldkeyparse.namespacedkeyunsupported=&cYour server software does not support namespaced keys, but the world name '{namespace}' is in namespaced key format! Please change the world key to the legacy format without ':', or switch to PaperMC that supports namespaced keys.
# generic
mv-core.generic.success=Success!
mv-core.generic.failure=Failed!
@@ -1,6 +1,6 @@
# configuration
mv-core.config.save.failed=保存 Multiverse-Core 的 config.yml 文件失败,所有操作都将是临时的!
mv-core.config.node.notfound=在配置文件中无法找到节点: {node}
mv-core.config.save.failed=保存 Multiverse-Core 的 config.yml 文件失败,所有修改都将是临时的!
mv-core.config.node.notfound=在配置文件中无法找到节点: {node}
# /mv check
mv-core.check.description=检查某位玩家是否可以将自己传送至某个地方。
@@ -8,7 +8,7 @@ mv-core.check.player.description=要检查的玩家。
mv-core.check.destination.description=目标的地址,例如某个世界的名字。
mv-core.check.haspermission=&a{player} 可以传送至 {destination}
mv-core.check.nopermission=&c{player} 不能传送至 {destination}
mv-core.check.location=目标的地址是: &f{location}
mv-core.check.location=目标的地址是: &f{location}
# /mv clone
mv-core.clone.description=复制一个世界。
@@ -19,7 +19,7 @@ mv-core.clone.success=&a已将世界复制到 '{world}'!
# /mv config
mv-core.config.description=查看或设置某个配置的值。
mv-core.config.name.description=需要查看或设置的配置。
mv-core.config.name.description=需要设置或查看的配置。
mv-core.config.value.description=需要设置的值。如果没有指定,将显示当前配置的值。
mv-core.config.show.success=&a配置 '&6{name}&a' 已被设置为 '&6{value}&a'。
mv-core.config.show.error=&c获取配置 '&6{name}&c' 的值失败。\n&c{error}
@@ -30,29 +30,29 @@ mv-core.config.set.error=&c将配置 '&6{name}&c' 设置为 '&6{value}&c' 失败
mv-core.confirm.description=在执行危险的指令前再一次进行确认。
# /mv coordinates
mv-core.coordinates.description=发送你的坐标信息
mv-core.coordinates.description=直接发送你的坐标
mv-core.coordinates.info.title=&b--- 位置信息 ---
mv-core.coordinates.info.world=&b世界: &f{world}
mv-core.coordinates.info.alias=&b别名: &f{alias}
mv-core.coordinates.info.worldScale=&b世界尺度: &f{scale}
mv-core.coordinates.info.coordinates=&b坐标: &f{coordinates}
mv-core.coordinates.info.direction=&b方向: &f{direction}
mv-core.coordinates.info.world=&b世界: &f{world}
mv-core.coordinates.info.alias=&b别名: &f{alias}
mv-core.coordinates.info.worldScale=&b世界比例: &f{scale}
mv-core.coordinates.info.coordinates=&b坐标: &f{coordinates}
mv-core.coordinates.info.direction=&b方向: &f{direction}
# /mv create
mv-core.create.description=创建一个新的世界并且加载。
mv-core.create.name.description=新世界的名称。
mv-core.create.environment.description=世界的环境。详见:/mv environments
mv-core.create.environment.description=世界的环境。详见: /mv environments
mv-core.create.flags.description=附加的世界设置。在 https://gg.gg/nn8bl 上查看所有可能的标志。
mv-core.create.properties=使用以下属性创建世界 {worldName} :
mv-core.create.properties.environment=- 环境: &f{environment}
mv-core.create.properties.seed=- 种子: &f{seed}
mv-core.create.properties.worldtype=- 世界类型: &f{worldType}
mv-core.create.properties.adjustspawn=- 调节出生点: &f{adjustSpawn}
mv-core.create.properties.biome=- 生物群系: &f{biome}
mv-core.create.properties.generator=- 生成器: &f{generator}
mv-core.create.properties.generatorsettings=- 生成器设置: &f{generatorSettings}
mv-core.create.properties.environment=- 环境: &f{environment}
mv-core.create.properties.seed=- 种子: &f{seed}
mv-core.create.properties.worldtype=- 世界类型: &f{worldType}
mv-core.create.properties.adjustspawn=- 调节出生点: &f{adjustSpawn}
mv-core.create.properties.biome=- 生物群系: &f{biome}
mv-core.create.properties.generator=- 生成器: &f{generator}
mv-core.create.properties.generatorsettings=- 生成器设置: &f{generatorSettings}
mv-core.create.properties.structures=- 结构: &f{structures}
mv-core.create.properties.structures=- 结构: &f{structures}
mv-core.create.loading=创建世界中……
mv-core.create.success=&a世界 '{world}' 已经创建!
@@ -65,14 +65,14 @@ mv-core.debug.change.syntax=等级
mv-core.debug.change.level.description=需要设置的调试等级。
# /mv delete
mv-core.delete.description=在服务器上永久删除某个世界。
mv-core.delete.description=在服务器上“永久”删除某个世界。
mv-core.delete.deleting=正在删除世界 '{world}'……
mv-core.delete.prompt=你确定想要删除世界 '{world}' 吗?
mv-core.delete.success=&a世界 '{world}' 已经删除!
# /mv dumps
mv-core.dumps.description=导出版本信息到控制台或者粘贴服务。
mv-core.dumps.url.list={service}:{link}
mv-core.dumps.url.list={service}:{link}
# /mv gamerule set
mv-core.gamerule.set.description=修改某个世界或某些世界的游戏规则。
@@ -105,24 +105,24 @@ mv-core.generators.empty=&c未找到生成器插件。
# /mv import
mv-core.import.description=导入一个已经存在的世界文件夹。
mv-core.import.name.description=世界文件夹的名称。
mv-core.import.env.description=世界的环境。详见:/mv env
mv-core.import.other.description=其他世界设置。详见:https://gg.gg/nn8c2
mv-core.import.env.description=世界的环境。详见: /mv env
mv-core.import.other.description=其他世界设置。详见: https://gg.gg/nn8c2
mv-core.import.importing=开始导入世界 '{world}'……
mv-core.import.success=&a已导入世界 '{world}'!
# /mv info
mv-core.info.description=显示某个世界所有已配置的属性。
mv-core.info.description.world=需要显示信息的世界。
mv-core.info.header=&a&l---- 世界信息: &f&l{world}&a&l ----
mv-core.info.header=&a&l---- 世界信息: &f&l{world}&a&l ----
mv-core.info.nocontent=&c没有找到这样的世界属性!
# /mv list
mv-core.list.description=显示所有可进入世界的列表。
mv-core.list.description=显示你所有可进入世界的列表。
mv-core.list.header=&6====[ 多世界列表 ]====
mv-core.list.nocontent=&c未找到任何世界,或者你没有权限进入它们!
# /mv load
mv-core.load.description=加载某个世界,世界必须已经在 worlds.yml,或者请使用 /mv import 导入。
mv-core.load.description=加载某个世界。世界必须已经在 worlds.yml 中,或者请使用 /mv import 导入。
mv-core.load.world.description=想要加载的世界名称。
mv-core.load.loading=加载世界 '{world}' 中……
mv-core.load.success=&a已加载世界 '{world}'!
@@ -132,6 +132,7 @@ mv-core.modify.description=修改某个给定世界的属性。
mv-core.modify.world.description=需要修改的世界的名称。
mv-core.modify.property.description=需要修改的属性。
mv-core.modify.value.description=需要修改属性的值。
mv-core.modify.propertyremoved=&c你不能修改 '&3{property}&c',因为它已经改变或被移除。请使用 '&a{replacement}&c'。
mv-core.modify.cannothavevalue=你不能指定一个值来 {action} '{property}'。
mv-core.modify.specifyvalue=你必须指定一个值来 {action} '{property}'。
mv-core.modify.success=&a成功在世界 &9{world}&a 中 {action} '&9{property}&a' 为 '&9{value}&a' 。
@@ -139,12 +140,12 @@ mv-core.modify.failure=&c在世界 &9{world}&c 中 {action} '&9{property}&c' 为
mv-core.modify.failure.novalue=&c在世界 &9{world}&c 中 {action} '&9{property}&c' 失败。\n&c{error}
# /mv regen
mv-core.regen.description=在你的服务器上重新生成一个世界。之前的状态将会被永久丢弃。
mv-core.regen.description=在你的服务器上重新生成一个世界。之前的状态将会“永久”丢失。
mv-core.regen.world.description=你想要重新生成的世界。
mv-core.regen.other.description=其他的世界设置。详见:http://gg.gg/nn8lk
mv-core.regen.other.description=其他的世界设置。详见: http://gg.gg/nn8lk
mv-core.regen.regenerating=重新生成世界 '{world}'……
mv-core.regen.prompt=你确定想要重新生成世界 '{world}' 吗?
mv-core.regen.success=&a已重新生成 世界 '{world}'!
mv-core.regen.success=&a已重新生成世界 '{world}'!
# /mv reload
mv-core.reload.description=重新加载所有 Multiverse 模块的配置文件。
@@ -153,7 +154,7 @@ mv-core.reload.success=&a重新加载完成!
# /mv remove
mv-core.remove.description=从 Multiverse 中卸载某个世界并从 worlds.yml 中移除。这不会删除世界的文件夹。
mv-core.remove.world.description=你想要从MV认知中移除的世界。
mv-core.remove.world.description=你想要从 Multiverse 认知中移除的世界。
mv-core.remove.success=&a已移除世界 '{world}'!
# /mv
@@ -164,10 +165,10 @@ mv-core.root.help=&a查看 &f/mv help&a 了解可以执行的指令。
mv-core.setspawn.description=为某个特定的世界设置出生点。
mv-core.setspawn.location.description=新出生点的位置。
mv-core.setspawn.world.description=需要设置出生点的目标世界(默认为玩家的当前世界)
mv-core.setspawn.unsafe=&c新的生成位置不安全!如果这是有意为之,你可以通过使用&6--unsafe&c标志来禁用安全检查。
mv-core.setspawn.success=&a成功在&6{world}&a中将生成点设置为&6{location}&a!
mv-core.setspawn.failed=&c在&6{world}&c中将生成点设置为&6{location}&c时失败。{error}
mv-core.setspawn.notmvworld=&c无法为&c{world}设置生成点,因为它不是一个Multiverse世界。
mv-core.setspawn.unsafe=&c新出生点不安全!如果是有意为之,你可以通过使用 &6--unsafe&c 标志来禁用安全检查。
mv-core.setspawn.success=&a成功在 &6{world}&a 中将出生点设置为 &6{location}&a!
mv-core.setspawn.failed=&c在 &6{world}&c 中将出生点设置为 &6{location}&c 时失败。{error}
mv-core.setspawn.notmvworld=&c无法为 &c{world} 设置出生点,因为它不是一个 Multiverse 世界。
# /mv spawn
mv-core.spawn.description=传送某个特定的玩家到他们所在世界的出生点。
@@ -184,7 +185,7 @@ mv-core.teleport.success=已传送 {player} 到 {destination}。
mv-core.teleport.failed=传送 {player} 到 {destination}失败。{reason}
# /mv unload
mv-core.unload.description=从 Multiverse 中卸载某个世界。这不会移除世界的文件夹。这不会将它从配置文件中移除。
mv-core.unload.description=从 Multiverse 中卸载某个世界。这“不会”移除世界的文件夹。这“不会”将它从配置文件中移除。
mv-core.unload.world.description=你想要卸载的世界名称。
mv-core.unload.unloading=正在卸载世界 '{world}'……
mv-core.unload.success=&a已卸载 '{world}'!
@@ -196,7 +197,7 @@ mv-core.usage.description=显示 Multiverse-Core 指令的用法。
mv-core.version.description=显示版本和作者。
mv-core.version.mv=Multiverse Core 版本 &fv{version}
mv-core.version.authors=Multiverse Core 作者 &f{authors}
mv-core.version.secretcode=特征码: &fFRN002
mv-core.version.secretcode=特征码: &fFRN002
# /mv who
# /mv whoall
@@ -205,7 +206,7 @@ mv-core.who.all.description=列出所有世界的玩家
mv-core.who.world.description=需要列出玩家的世界名称
mv-core.who.flags.description=筛选 - 仅显示匹配的条目。页码 - 查看的页面序号。
mv-core.who.empty=&7&o空
mv-core.who.header=&b====[ 多世界玩家列表 ]====
mv-core.who.header=&b====[ Multiverse世界玩家列表 ]====
# /mv worldborder
mv-core.worldborder.center.nothingchanged=&c无变化。世界边界的中心在世界 '&6{world}&c' 中已经有相同的设置。
@@ -214,8 +215,8 @@ mv-core.worldborder.center.success=&r已设置世界 '&6{world}&r' 边界的中
mv-core.worldborder.damageamount.nothingchanged=&c无变化。世界边界的伤害在世界 '&6{world}&c' 中已经有相同的设置。
mv-core.worldborder.damageamount.success=&r已设置世界 '&6{world}&r' 的边界伤害为每秒每格 {amount} 点伤害。
mv-core.worldborder.damagebuffer.nothingchanged=&c无变化。世界边界伤害的缓冲在世界 '&6{world}&c' 中已经有相同的设置。
mv-core.worldborder.damagebuffer.success=&r已设置世界 '&6{world}&r' 边界伤害的缓冲为 {distance} 格。
mv-core.worldborder.damagebuffer.nothingchanged=&c无变化。世界边界伤害的缓冲区在世界 '&6{world}&c' 中已经有相同的设置。
mv-core.worldborder.damagebuffer.success=&r已设置世界 '&6{world}&r' 边界伤害的缓冲区为 {distance} 格。
mv-core.worldborder.get.size=&r世界 '&6{world}&r' 的边界宽度目前为 {size} 格。
@@ -232,7 +233,7 @@ mv-core.worldborder.warningtime.success=&r已设置世界 '&6{world}&r' 的边
# commands error
mv-core.commands.error.playersonly=&c该指令仅玩家可以使用。
mv-core.commands.error.multiverseworldonly=&c该指令仅可以在多世界中使用。
mv-core.commands.error.multiverseworldonly=&c该指令仅可以在Multiverse世界中使用。
# entry check
mv-core.entrycheck.blacklisted='{world}' 在黑名单中。
@@ -242,29 +243,29 @@ mv-core.entrycheck.exceedplayerlimit=该世界已经达到最大玩家数量限
mv-core.entrycheck.noworldaccess=你没有权限进入该世界。
# economy
mv-core.economy.item.deposit=&a{price}&f已存入你的背包。
mv-core.economy.item.withdraw=&f你已被收取&a{price}。
mv-core.economy.vault.deposit=&2{price}&f已存入你的账户。
mv-core.economy.vault.withdraw=&f你已被收取&2{price}。
mv-core.economy.item.deposit=&a{price}&f 已存入你的背包。
mv-core.economy.item.withdraw=&f你已被收取 &a{price}。
mv-core.economy.vault.deposit=&2{price}&f 已存入你的账户。
mv-core.economy.vault.withdraw=&f你已被收取 &2{price}。
# multiverse parse destination failure reason
mv-core.destination.anchor.failurereason.anchornotfound=&c锚点 '&6{anchor}&c' 不存在!
mv-core.destination.bed.failurereason.playernotfound=&玩家 '&6{player}&c' 不存在或不在线!如果想传送到自己的床,使用 'playerbed'。
mv-core.destination.cannon.failurereason.invalidformat=&c大炮目标格式是:&6ca:worldname:x,y,z:pitch:yaw:speed
mv-core.destination.exact.failurereason.invalidformat=&c精确目标格式是:&6e:worldname:x,y,z:pitch:yaw
mv-core.destination.cannon.failurereason.invalidformat=&c大炮目标格式是: &6ca:worldname:x,y,z:pitch:yaw:speed
mv-core.destination.exact.failurereason.invalidformat=&c精确目标格式是: &6e:worldname:x,y,z:pitch:yaw
mv-core.destination.player.failurereason.playernotfound=&c玩家 '&6{player}&c' 不存在或不在线!
mv-core.destination.shared.failurereason.invalidcoordinatesformat=你必须指定一个具有以下格式有效的坐标:&6{x},{y},{z}
mv-core.destination.shared.failurereason.invalidnumberformat=给定的值不是一个有效的数字。{error}。
mv-core.destination.shared.failurereason.invalidcoordinatesformat=你必须指定一个具有以下格式有效的坐标: &6{x},{y},{z}
mv-core.destination.shared.failurereason.invalidnumberformat=无效的目标输入。{error}。
mv-core.destination.shared.failurereason.worldnotfound=&c世界 '&6{world}&c' 不存在或着未被加载!
mv-core.destination.parse.failurereason.invaliddestinationid=无效的目标ID:{id}。有效的目标ID有:{ids}
mv-core.destination.parse.failurereason.invaliddestinationid=无效的目标ID: {id}。有效的目标ID有: {ids}
# teleport failure reason
mv-core.teleportfailurereason.null.destination=目标无效!
mv-core.teleportfailurereason.null.location=位置无效!
mv-core.teleportfailurereason.null.world=&c位置所在的世界无效!
mv-core.teleportfailurereason.unsafe.location=位置是不安全的!使用 `--unsafe` 忽视该问题。
mv-core.teleportfailurereason.teleport.failed=出于某些拒绝了该传送。
mv-core.teleportfailurereason.teleport.failed.exception=在传送过程中出现了一个错误。查看控制台获取更多信息。
mv-core.teleportfailurereason.null.world=&c目标世界没有加载或者不存在!
mv-core.teleportfailurereason.unsafe.location=位置被认为是不安全的!如果是有意为之,使用 `--unsafe` 强制执行。
mv-core.teleportfailurereason.teleport.failed=传送被拒绝。请确保其他插件没有阻止传送,并查看服务器的日志获得错误信息。
mv-core.teleportfailurereason.teleport.failed.exception=在传送过程中出现了一个错误。查看服务器日志获取更多信息。
mv-core.teleportfailurereason.event.cancelled=传送被另一个插件取消了。
# world manager result
@@ -272,7 +273,8 @@ mv-core.cloneworld.invalidworldname=世界 '{world}' 包含无效的字符!
mv-core.cloneworld.worldexistfolder=世界 '{world}' 已经存在于服务器的文件中!在复制前你需要先删除它。
mv-core.cloneworld.worldexistunloaded=世界 '{world}' 已经存在并且未被加载!在复制前你需要先删除它。
mv-core.cloneworld.worldexistloaded=世界 '{world}' 已经存在!在复制前你需要先删除它。
mv-core.cloneworld.copyfailed=复制世界到 '{world}' 失败:{error}\n&f查看控制台获取更多信息。
mv-core.cloneworld.fromworldfolderinvalid=源世界 '{world}' 文件夹的内容似乎不是一个有效的世界!请确保它包含正确的世界数据和区域文件夹结构。
mv-core.cloneworld.copyfailed=复制世界到 '{world}' 失败: {error}\n&f查看控制台获取更多信息。
mv-core.createworld.invalidworldname=世界 '{world}' 包含无效的字符!
mv-core.createworld.worldexistfolder=世界 '{world}' 已经存在于服务器的文件中!&f 如果你想要导入它,输入 '&a/mv import {world} <environment>&f'。
@@ -280,35 +282,37 @@ mv-core.createworld.worldexistunloaded=世界 '{world}' 已经存在, 但是未
mv-core.createworld.worldexistloaded=世界 '{world}' 已经存在!
mv-core.deleteworld.worldnonexistent=世界 '{world}' 不存在!
mv-core.deleteworld.loadfailed=不能加载世界 '{world}',请确认世界文件夹是否存在。&f 你可以执行 '&a/mv remove {world}&f' 将它从 Multiverse 中移除,或者使用 '&a/mv load {world}&f' 尝试重新加载。
mv-core.deleteworld.worldfoldernotfound=世界 '{world}' folder not found!
mv-core.deleteworld.failedtodeletefolder=删除世界 '{world}' 的文件夹失败:{error}\n&f查看控制台获取更多信息。
mv-core.deleteworld.loadfailed=不能加载世界 '{world}',请确认世界文件夹是否被删除或者损坏。&f 你可以执行 '&a/mv remove {world}&f' 将它从 Multiverse 中移除,或者使用 '&a/mv load {world}&f' 尝试重新加载。
mv-core.deleteworld.worldfoldernotfound=&c无法定位 '{world}' 文件夹!&f 你可以执行 '&a/mv remove {world}&c' 将它从 Multiverse 中移除,如果存在,也可以手动删除文件夹。
mv-core.deleteworld.failedtodeletefolder=删除世界 '{world}' 的文件夹失败: {error}\n&f查看控制台获取更多信息。
mv-core.importworld.invalidworldname=世界 '{world}' 包含无效的字符!
mv-core.importworld.worldexistunloaded=世界 '{world}' 已经存在, 但是未被加载!&f 如果你想要加载它,输入 '&a/mv load {world}&f'。
mv-core.importworld.worldexistloaded=世界 '{world}' 已经存在!
mv-core.importworld.worldfolderinvalid=世界 '{world}' 文件夹的内容看上去不像是一个有效的世界!
mv-core.importworld.worldfolderinvalid=世界 '{world}' 文件夹的内容似乎不是一个有效的世界!请确保它包含正确的世界数据和区域文件夹结构。\n&c如果服务器软件对世界文件夹的处理方式不同,或者你确信世界是有效的,使用 '&3--skip-folder-check&c' 标志。
mv-core.importworld.bukkitenvironmentmismatch=环境不匹配!&f 世界 '{world}' 已经用环境 '{bukkitEnvironment}' 加载,但 Multiverse 正在尝试用环境 '{mvEnvironment}' 导入它。
mv-core.loadworld.worldalreadyloading=世界 '{world}' 已经正在加载!请稍后……
mv-core.loadworld.worldnonexistent=世界 '{world}' 不存在!使用 '&a/mv create {world} <environment>&f' 来创建。
mv-core.loadworld.worldexistfolder=世界 '{world}' 已经存在于服务器的文件中, 但是未被 Multiverse 识别!&f 如果你想要导入它,输入 '&a/mv import {world} <environment>&f'。
mv-core.loadworld.worldexistloaded=世界 '{world}' 已经存在!
mv-core.loadworld.worldexistloaded=世界 '{world}' 已经加载!
mv-core.loadworld.bukkitenvironmentmismatch=环境不匹配!&f 世界 '{world}' 已经用环境 '{bukkitEnvironment}' 加载,但 Multiverse 正在尝试用环境 '{mvEnvironment}' 加载它。
mv-core.removeworld.worldnonexistent=世界 '{world}' 不存在!
mv-core.unloadworld.worldalreadyunloading=世界 '{world}' 已经在卸载! 请稍后……
mv-core.unloadworld.worldnonexistent=世界 '{world}' 不存在!
mv-core.unloadworld.worldunloaded=世界 '{world}' 已经卸载!
mv-core.unloadworld.bukkitunloadfailed=Bukkit 卸载世界 '{world}' 失败:{error}
mv-core.unloadworld.bukkitunloadfailed=Bukkit 卸载世界 '{world}' 失败: {error}
mv-core.worldcreator.invalidbiomeprovider=&c无效的生物群系 '&6{biome}&c'!{error}\n&c查看控制台获取更多信息。
mv-core.worldcreator.invalidchunkgenerator=&c无效的区块生成器 '&6{generator}&c'! {error}\n&c这更像是生成器插件的问题,而不是 Multiverse。查看控制台获取更多信息。
mv-core.worldcreator.bukkitcreationfailed=&cBukkit 创建世界 '{world}' 失败:{error}\n&c查看控制台获取更多信息。
mv-core.worldcreator.bukkitcreationfailed=&cBukkit 创建世界 '{world}' 失败: {error}\n&c查看控制台获取更多信息。
# queue command result
mv-core.queuecommand.nocommandinqueue=&c你没有任何指令在列队中。
mv-core.queuecommand.invalidotp=&c无效的 OTP 数字 '&6{otp}&c'。请重试……
mv-core.queuecommand.commandexecutionerror=&c在处理列队中命令时发生了错误:{error}\n&f查看控制台获取更多信息。
mv-core.queuecommand.commandexecutionerror=&c在处理列队中命令时发生了错误: {error}\n&f查看控制台获取更多信息。
# content display messages
mv-core.contentdisplay.nocontent=&c没有可显示的内容
@@ -322,7 +326,12 @@ mv-core.contentdisplay.null=&7&o错误
mv-core.exception.multiverseworld.createnull=创建的世界返回为null!
mv-core.exception.multiverseworld.unloaddefaultworld=&c你不能卸载默认的世界!世界 &6{world}&c 是定义在 server.properties '&6level-name&c' 中的默认世界。
mv-core.exception.multiverseworld.unloadplayersinworld=&c目前仍然有 &6{count}&c 位玩家在世界中!在命令中使用 '&6--remove-players&c' 标签将所有的玩家从世界中移除。
mv-core.exception.multiverseworld.unloaderror=&c在卸载世界 &6{world}&c 过程中出现了一个位置的错误。\n&c查看控制台获取更多信息。
mv-core.exception.multiverseworld.unloaderror=&c在卸载世界 &6{world}&c 过程中出现了一个未知的错误。\n&c查看控制台获取更多信息。
# multiverse position parse exception
mv-core.exception.positionparse.invaliddirection=&c无效的方向字符串格式: {format}。预期的格式: <pitch>:<yaw>
mv-core.exception.positionparse.invalidcoordinates=&c无效的坐标格式: {format}。预期的格式: <x>,<y>,<z>
mv-core.exception.positionparse.invalidnumber=&c无效的数字格式: {number}。预期为一个数字值。
# generic
mv-core.generic.success=成功!
+2 -1
View File
@@ -1,7 +1,8 @@
name: Multiverse-Core
main: org.mvplugins.multiverse.core.MultiverseCore
authors: ['dumptruckman', 'Rigby', 'fernferret', 'lithium3141', 'main--', 'benwoo1110', 'Zax71']
website: 'https://dev.bukkit.org/projects/multiverse-core'
website: 'https://modrinth.com/plugin/multiverse-core'
description: 'The original Bukkit Multi-World Plugin!'
softdepend: ['Vault', 'PlaceholderAPI']
api-version: 1.13
version: ${version}
@@ -0,0 +1,54 @@
package org.mvplugins.multiverse.core.config.node
import io.vavr.control.Try
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class MapConfigNodeTest {
@Test
fun `MapConfigNode parses from string with defaults`() {
val node: MapConfigNode<String, Int> =
MapConfigNode.mapBuilder("my-map", String::class.java, Int::class.javaObjectType).build()
val expected: Map<String, Int> = mapOf("key1" to 1, "key2" to 2)
assertEquals(
expected,
node.parseFromString("key1=1;key2=2").get()
)
assertEquals(emptyMap(), node.parseFromString(" ").get())
}
@Test
fun `MapConfigNode default serializer converts values by key and value type`() {
val node: MapConfigNode<String, Int> =
MapConfigNode.mapBuilder("my-map", String::class.java, Int::class.javaObjectType).build()
val serializer = node.serializer
val expected: Map<String, Int> = mapOf("alpha" to 4, "beta" to 8)
val rawMap: Map<String, Any> = mapOf("alpha" to "4", "beta" to 8)
val deserialized = serializer!!.deserialize(rawMap, node.type)
assertEquals(expected, deserialized)
val serialized = serializer.serialize(expected, node.type)
assertEquals(expected, serialized)
}
@Test
fun `MapConfigNode key and value validators are used for node validation`() {
val node: MapConfigNode<String, Int> =
MapConfigNode.mapBuilder("my-map", String::class.java, Int::class.javaObjectType)
.keyValidator { key ->
if (key.startsWith("k")) Try.success(null) else Try.failure(IllegalArgumentException("invalid key"))
}
.valueValidator { value ->
if (value >= 0) Try.success(null) else Try.failure(IllegalArgumentException("invalid value"))
}
.build()
assertTrue(node.validate(mapOf("key" to 1)).isSuccess)
assertTrue(node.validate(mapOf("bad" to 1)).isFailure)
assertTrue(node.validate(mapOf("key" to -1)).isFailure)
}
}
@@ -77,7 +77,7 @@ class InjectionTest : TestWithMockBukkit() {
@Test
fun `Commands are available as services`() {
val commands = serviceLocator.getAllActiveServices(CoreCommand::class.java)
assertEquals(57, commands.size)
assertEquals(58, commands.size)
}
@Test
@@ -39,6 +39,8 @@ public class MVServerMock extends ServerMock {
world.getWorldFolder().mkdirs();
createFile(new File(world.getWorldFolder(), "uid.dat"));
createFile(new File(world.getWorldFolder(), "level.dat"));
new File(world.getWorldFolder(), "region").mkdir();
new File(world.getWorldFolder(), "data").mkdir();
addWorld(world);
return world;
}
@@ -2,6 +2,7 @@ package org.mvplugins.multiverse.core.utils
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class StringFormatterTest {
@Test
@@ -42,5 +43,22 @@ class StringFormatterTest {
"test",
StringFormatter.quoteMultiWordString("test")
)
assertNull(StringFormatter.quoteMultiWordString(null))
}
@Test
fun `StringFormatter parseCSVMap`() {
assertEquals(
emptyMap<String, String>(),
StringFormatter.parseCSVMap("")
)
assertEquals(
mapOf("key" to "value"),
StringFormatter.parseCSVMap("key=value")
)
assertEquals(
mapOf("key1" to "value1", "key2" to "value2", "key3" to "value3"),
StringFormatter.parseCSVMap("key1=value1,key2=value2,key3=value3")
)
}
}
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more