From 6bf2a9393536ffd4ecccef60fbed4cdcad6f349e Mon Sep 17 00:00:00 2001 From: Laura Date: Sun, 9 Aug 2026 15:17:12 +0200 Subject: [PATCH] Initial commit --- .editorconfig | 363 ++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 52 + .github/ISSUE_TEMPLATE/config.yml | 5 + .github/ISSUE_TEMPLATE/feature_request.yml | 26 + .github/images/blocks.png | Bin 0 -> 45884 bytes .github/images/header.png | Bin 0 -> 222365 bytes .github/images/navigator.png | Bin 0 -> 18602 bytes .github/images/predefined_worlds.png | Bin 0 -> 36199 bytes .github/images/scoreboard.png | Bin 0 -> 13975 bytes .github/images/settings.png | Bin 0 -> 39002 bytes .github/images/setup.png | Bin 0 -> 55132 bytes .github/images/status.png | Bin 0 -> 17445 bytes .github/images/templates.png | Bin 0 -> 22052 bytes .github/images/worlds.png | Bin 0 -> 28813 bytes .github/renovate.json | 21 + .github/workflows/build.yml | 27 + .github/workflows/publish.yml | 51 + .gitignore | 125 ++ HEADER.txt | 15 + LICENSE.txt | 858 +++++++++++++ README.md | 144 +++ build.gradle.kts | 12 + buildSrc/build.gradle.kts | 26 + buildSrc/src/main/kotlin/CommonConfig.kt | 41 + buildSrc/src/main/kotlin/CoreConfig.kt | 29 + buildsystem-api/build.gradle.kts | 72 ++ buildsystem-api/javadoc/overview.html | 11 + .../eintosti/buildsystem/api/BuildSystem.java | 47 + .../buildsystem/api/BuildSystemProvider.java | 68 + .../buildsystem/api/data/Bypassable.java | 31 + .../buildsystem/api/data/Capability.java | 27 + .../buildsystem/api/data/Overridable.java | 39 + .../eintosti/buildsystem/api/data/Type.java | 98 ++ .../buildsystem/api/event/package-info.java | 22 + .../api/event/world/BuildWorldEvent.java | 70 ++ .../api/event/world/BuildWorldLoadEvent.java | 54 + .../event/world/BuildWorldPostLoadEvent.java | 41 + .../world/BuildWorldPostUnloadEvent.java | 41 + .../event/world/BuildWorldUnloadEvent.java | 54 + .../world/PlayerBuildModeToggleEvent.java | 105 ++ .../api/event/world/package-info.java | 22 + .../api/exception/WorldDeletionException.java | 36 + .../WorldDirectoryNotFoundException.java | 36 + .../api/exception/WorldException.java | 45 + .../api/exception/WorldNotFoundException.java | 35 + .../buildsystem/api/package-info.java | 22 + .../buildsystem/api/player/BuildPlayer.java | 114 ++ .../buildsystem/api/player/CachedValues.java | 97 ++ .../api/player/LogoutLocation.java | 48 + .../buildsystem/api/player/PlayerService.java | 88 ++ .../buildsystem/api/player/package-info.java | 23 + .../api/player/settings/DesignColor.java | 123 ++ .../api/player/settings/Settings.java | 259 ++++ .../api/player/settings/package-info.java | 23 + .../api/storage/FolderStorage.java | 110 ++ .../api/storage/PlayerStorage.java | 79 ++ .../buildsystem/api/storage/Storage.java | 71 ++ .../buildsystem/api/storage/WorldStorage.java | 108 ++ .../buildsystem/api/world/BuildWorld.java | 188 +++ .../buildsystem/api/world/WorldService.java | 73 ++ .../buildsystem/api/world/backup/Backup.java | 48 + .../api/world/backup/BackupProfile.java | 43 + .../api/world/backup/BackupStorage.java | 81 ++ .../api/world/backup/package-info.java | 22 + .../api/world/builder/Builder.java | 103 ++ .../api/world/builder/BuilderImpl.java | 66 + .../api/world/builder/Builders.java | 135 ++ .../api/world/builder/package-info.java | 22 + .../api/world/creation/BuildWorldCreator.java | 154 +++ .../creation/generator/CustomGenerator.java | 54 + .../world/creation/generator/Generator.java | 50 + .../creation/generator/package-info.java | 22 + .../api/world/creation/package-info.java | 22 + .../api/world/data/BuildWorldStatus.java | 85 ++ .../api/world/data/BuildWorldType.java | 83 ++ .../api/world/data/Visibility.java | 57 + .../buildsystem/api/world/data/WorldData.java | 180 +++ .../api/world/data/package-info.java | 23 + .../api/world/display/Displayable.java | 149 +++ .../buildsystem/api/world/display/Folder.java | 176 +++ .../api/world/display/NavigatorCategory.java | 74 ++ .../api/world/display/package-info.java | 22 + .../navigator/settings/NavigatorType.java | 39 + .../navigator/settings/WorldDisplay.java | 51 + .../world/navigator/settings/WorldFilter.java | 113 ++ .../world/navigator/settings/WorldSort.java | 150 +++ .../navigator/settings/package-info.java | 23 + .../buildsystem/api/world/package-info.java | 22 + .../api/world/util/WorldLoader.java | 43 + .../api/world/util/WorldPermissions.java | 118 ++ .../api/world/util/WorldTeleporter.java | 39 + .../api/world/util/WorldUnloader.java | 61 + .../api/world/util/package-info.java | 22 + buildsystem-core/build.gradle.kts | 233 ++++ .../buildsystem/BuildSystemPlugin.java | 460 +++++++ .../de/eintosti/buildsystem/Messages.java | 1103 +++++++++++++++++ .../buildsystem/api/BuildSystemApi.java | 51 + .../buildsystem/command/BackCommand.java | 84 ++ .../buildsystem/command/BlocksCommand.java | 56 + .../buildsystem/command/BuildCommand.java | 140 +++ .../command/BuildSystemCommand.java | 94 ++ .../buildsystem/command/ConfigCommand.java | 64 + .../command/ExplosionsCommand.java | 92 ++ .../buildsystem/command/GamemodeCommand.java | 124 ++ .../buildsystem/command/NoAICommand.java | 95 ++ .../buildsystem/command/PagedCommand.java | 97 ++ .../buildsystem/command/PhysicsCommand.java | 103 ++ .../buildsystem/command/SettingsCommand.java | 54 + .../buildsystem/command/SetupCommand.java | 54 + .../buildsystem/command/SkullCommand.java | 78 ++ .../buildsystem/command/SpawnCommand.java | 117 ++ .../buildsystem/command/SpeedCommand.java | 93 ++ .../buildsystem/command/TimeCommand.java | 99 ++ .../buildsystem/command/TopCommand.java | 84 ++ .../buildsystem/command/WorldsCommand.java | 125 ++ .../command/subcommand/Argument.java | 40 + .../command/subcommand/SubCommand.java | 34 + .../worlds/AddBuilderSubCommand.java | 130 ++ .../subcommand/worlds/ArchiveSubCommand.java | 52 + .../subcommand/worlds/BackupsSubCommand.java | 87 ++ .../subcommand/worlds/BuildersSubCommand.java | 69 ++ .../subcommand/worlds/DeleteSubCommand.java | 75 ++ .../subcommand/worlds/EditSubCommand.java | 76 ++ .../subcommand/worlds/FolderSubCommand.java | 241 ++++ .../subcommand/worlds/HelpSubCommand.java | 91 ++ .../worlds/ImportAllSubCommand.java | 120 ++ .../subcommand/worlds/ImportSubCommand.java | 147 +++ .../subcommand/worlds/InfoSubCommand.java | 118 ++ .../subcommand/worlds/ItemSubCommand.java | 49 + .../subcommand/worlds/PrivateSubCommand.java | 52 + .../subcommand/worlds/PublicSubCommand.java | 52 + .../worlds/RemoveBuilderSubCommand.java | 119 ++ .../worlds/RemoveSpawnSubCommand.java | 63 + .../subcommand/worlds/RenameSubCommand.java | 74 ++ .../worlds/SetCreatorSubCommand.java | 87 ++ .../subcommand/worlds/SetItemSubCommand.java | 78 ++ .../worlds/SetPermissionSubCommand.java | 90 ++ .../worlds/SetProjectSubCommand.java | 90 ++ .../subcommand/worlds/SetSpawnSubCommand.java | 67 + .../worlds/SetStatusSubCommand.java | 69 ++ .../subcommand/worlds/TeleportSubCommand.java | 76 ++ .../subcommand/worlds/UnimportSubCommand.java | 71 ++ .../command/tabcomplete/ArgumentSorter.java | 31 + .../tabcomplete/BuildTabCompleter.java | 54 + .../tabcomplete/ConfigTabCompleter.java | 45 + .../tabcomplete/EmptyTabCompleter.java | 46 + .../tabcomplete/GamemodeTabCompleter.java | 68 + .../tabcomplete/PhysicsTabCompleter.java | 55 + .../tabcomplete/SpawnTabCompleter.java | 56 + .../tabcomplete/SpeedTabCompleter.java | 43 + .../command/tabcomplete/TimeTabCompleter.java | 61 + .../tabcomplete/WorldsTabCompleter.java | 301 +++++ .../eintosti/buildsystem/config/Config.java | 646 ++++++++++ .../migration/ConfigMigrationManager.java | 82 ++ .../config/migration/Migration.java | 37 + .../config/migration/MigrationV1ToV2.java | 53 + .../buildsystem/event/EventDispatcher.java | 73 ++ .../player/PlayerInventoryClearEvent.java | 58 + .../world/BuildWorldManipulationEvent.java | 78 ++ .../luckperms/LuckPermsExpansion.java | 67 + .../calculators/BuildModeCalculator.java | 53 + .../luckperms/calculators/RoleCalculator.java | 98 ++ .../PlaceholderApiExpansion.java | 209 ++++ .../listener/AsyncPlayerChatListener.java | 46 + .../listener/AsyncPlayerPreLoginListener.java | 76 ++ .../listener/BlockPhysicsListener.java | 233 ++++ .../BuildModePreventationListener.java | 81 ++ .../BuildWorldResetUnloadListener.java | 61 + .../listener/EditSessionListener.java | 119 ++ .../listener/EntityDamageListener.java | 77 ++ .../listener/EntitySpawnListener.java | 56 + .../listener/FoodLevelChangeListener.java | 51 + .../listener/InventoryCreativeListener.java | 61 + .../listener/InventoryListener.java | 53 + .../listener/NavigatorListener.java | 273 ++++ .../listener/PlayerChangedWorldListener.java | 200 +++ .../PlayerCommandPreprocessListener.java | 303 +++++ .../PlayerInventoryClearListener.java | 54 + .../listener/PlayerJoinListener.java | 201 +++ .../listener/PlayerMoveListener.java | 69 ++ .../listener/PlayerQuitListener.java | 108 ++ .../listener/PlayerRespawnListener.java | 51 + .../listener/PlayerTeleportListener.java | 79 ++ .../listener/SettingsInteractListener.java | 425 +++++++ .../listener/SignChangeListener.java | 53 + .../listener/WeatherChangeListener.java | 57 + .../WorldManipulateByAxiomListener.java | 55 + .../listener/WorldManipulateListener.java | 151 +++ .../buildsystem/player/BuildPlayerImpl.java | 97 ++ .../buildsystem/player/CachedValuesImpl.java | 104 ++ .../player/LogoutLocationImpl.java | 71 ++ .../buildsystem/player/PlayerServiceImpl.java | 391 ++++++ .../player/customblock/CustomBlock.java | 126 ++ .../customblock/CustomBlockInventory.java | 166 +++ .../customblock/CustomBlockManager.java | 258 ++++ .../player/settings/DesignInventory.java | 193 +++ .../player/settings/NoClipManager.java | 174 +++ .../player/settings/SettingsImpl.java | 258 ++++ .../player/settings/SettingsInventory.java | 243 ++++ .../player/settings/SettingsManager.java | 169 +++ .../player/settings/SpeedInventory.java | 127 ++ .../storage/FolderStorageImpl.java | 122 ++ .../storage/PlayerStorageImpl.java | 91 ++ .../buildsystem/storage/WorldStorageImpl.java | 261 ++++ .../storage/factory/FolderStorageFactory.java | 49 + .../storage/factory/PlayerStorageFactory.java | 47 + .../storage/factory/WorldStorageFactory.java | 49 + .../storage/yaml/AbstractYamlStorage.java | 71 ++ .../storage/yaml/YamlFolderStorage.java | 190 +++ .../storage/yaml/YamlPlayerStorage.java | 246 ++++ .../storage/yaml/YamlSetupStorage.java | 81 ++ .../storage/yaml/YamlSpawnStorage.java | 46 + .../storage/yaml/YamlWorldStorage.java | 291 +++++ .../buildsystem/util/ArgumentParser.java | 131 ++ .../buildsystem/util/DirectionUtil.java | 97 ++ .../eintosti/buildsystem/util/FileUtils.java | 226 ++++ .../buildsystem/util/NumberUtils.java | 81 ++ .../buildsystem/util/PlayerChatInput.java | 118 ++ .../buildsystem/util/ServerModeChecker.java | 93 ++ .../buildsystem/util/StringCleaner.java | 70 ++ .../buildsystem/util/StringUtils.java | 43 + .../buildsystem/util/UUIDFetcher.java | 162 +++ .../buildsystem/util/UpdateChecker.java | 317 +++++ .../buildsystem/util/color/ColorAPI.java | 233 ++++ .../buildsystem/util/color/ColorPattern.java | 35 + .../util/color/patterns/GradientPattern.java | 46 + .../util/color/patterns/HexPattern.java | 96 ++ .../util/color/patterns/RainbowPattern.java | 41 + .../util/color/patterns/SolidPattern.java | 44 + .../util/inventory/BuildSystemHolder.java | 52 + .../util/inventory/BuildWorldHolder.java | 51 + .../util/inventory/InventoryHandler.java | 54 + .../util/inventory/InventoryManager.java | 103 ++ .../util/inventory/InventoryUtils.java | 326 +++++ .../util/inventory/MaterialUtils.java | 80 ++ .../util/inventory/PaginatedInventory.java | 104 ++ .../buildsystem/world/BuildWorldImpl.java | 362 ++++++ .../buildsystem/world/SpawnManager.java | 136 ++ .../buildsystem/world/WorldServiceImpl.java | 431 +++++++ .../buildsystem/world/backup/BackupImpl.java | 27 + .../world/backup/BackupProfileImpl.java | 151 +++ .../world/backup/BackupService.java | 124 ++ .../world/backup/BackupsInventory.java | 214 ++++ .../backup/storage/LocalBackupStorage.java | 136 ++ .../world/backup/storage/S3BackupStorage.java | 198 +++ .../backup/storage/SftpBackupStorage.java | 340 +++++ .../world/builder/BuilderInventory.java | 231 ++++ .../world/builder/BuildersImpl.java | 158 +++ .../world/builder/package-info.java | 22 + .../world/creation/BuildWorldCreatorImpl.java | 593 +++++++++ .../world/creation/CreateInventory.java | 311 +++++ .../generator/CustomGeneratorImpl.java | 78 ++ .../creation/generator/VoidGenerator.java | 75 ++ .../world/data/StatusInventory.java | 193 +++ .../buildsystem/world/data/WorldDataImpl.java | 397 ++++++ .../world/data/type/ConfigurableType.java | 119 ++ .../world/display/CustomizableIcons.java | 138 +++ .../buildsystem/world/display/FolderImpl.java | 271 ++++ .../world/modification/DeleteInventory.java | 111 ++ .../world/modification/EditInventory.java | 450 +++++++ .../world/modification/GameRuleEntry.java | 32 + .../modification/GameRulesInventory.java | 299 +++++ .../world/modification/SetupInventory.java | 179 +++ .../world/navigator/ArmorStandManager.java | 129 ++ .../inventory/ArchivedWorldsInventory.java | 66 + .../inventory/CreatableWorldsInventory.java | 120 ++ .../inventory/DisplayablesInventory.java | 595 +++++++++ .../inventory/FolderContentInventory.java | 120 ++ .../inventory/NavigatorInventory.java | 109 ++ .../inventory/PrivateWorldsInventory.java | 70 ++ .../inventory/PublicWorldsInventory.java | 70 ++ .../navigator/settings/WorldDisplayImpl.java | 54 + .../navigator/settings/WorldFilterImpl.java | 69 ++ .../world/util/WorldLoaderImpl.java | 87 ++ .../world/util/WorldPermissionsImpl.java | 169 +++ .../world/util/WorldTeleporterImpl.java | 129 ++ .../world/util/WorldUnloaderImpl.java | 155 +++ .../src/main/resources/config.yml | 100 ++ gradle.properties | 7 + gradle/libs.versions.toml | 57 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 61574 bytes gradle/wrapper/gradle-wrapper.properties | 23 + gradlew | 244 ++++ gradlew.bat | 92 ++ settings.gradle.kts | 16 + 285 files changed, 32332 insertions(+) create mode 100644 .editorconfig create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/images/blocks.png create mode 100644 .github/images/header.png create mode 100644 .github/images/navigator.png create mode 100644 .github/images/predefined_worlds.png create mode 100644 .github/images/scoreboard.png create mode 100644 .github/images/settings.png create mode 100644 .github/images/setup.png create mode 100644 .github/images/status.png create mode 100644 .github/images/templates.png create mode 100644 .github/images/worlds.png create mode 100644 .github/renovate.json create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/publish.yml create mode 100644 .gitignore create mode 100644 HEADER.txt create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 build.gradle.kts create mode 100644 buildSrc/build.gradle.kts create mode 100644 buildSrc/src/main/kotlin/CommonConfig.kt create mode 100644 buildSrc/src/main/kotlin/CoreConfig.kt create mode 100644 buildsystem-api/build.gradle.kts create mode 100644 buildsystem-api/javadoc/overview.html create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystem.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystemProvider.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Bypassable.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Capability.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Overridable.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Type.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldEvent.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldLoadEvent.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostLoadEvent.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostUnloadEvent.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldUnloadEvent.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/PlayerBuildModeToggleEvent.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDeletionException.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDirectoryNotFoundException.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldException.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldNotFoundException.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/BuildPlayer.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/CachedValues.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/LogoutLocation.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/PlayerService.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/DesignColor.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/Settings.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/FolderStorage.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/PlayerStorage.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/Storage.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/WorldStorage.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/BuildWorld.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/WorldService.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/Backup.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupProfile.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupStorage.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builder.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/BuilderImpl.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builders.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/BuildWorldCreator.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/CustomGenerator.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/Generator.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldStatus.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldType.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/Visibility.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/WorldData.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Displayable.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Folder.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/NavigatorCategory.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/NavigatorType.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldDisplay.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldFilter.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldSort.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/package-info.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldLoader.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldPermissions.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldTeleporter.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldUnloader.java create mode 100644 buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/package-info.java create mode 100644 buildsystem-core/build.gradle.kts create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/Messages.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/api/BuildSystemApi.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BackCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BlocksCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildSystemCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ConfigCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ExplosionsCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/GamemodeCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/NoAICommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PagedCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PhysicsCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SettingsCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SetupCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SkullCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpawnCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpeedCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TimeCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TopCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/Argument.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/SubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/AddBuilderSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ArchiveSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BackupsSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BuildersSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DeleteSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/EditSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/FolderSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportAllSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/InfoSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ItemSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PrivateSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PublicSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveBuilderSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveSpawnSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RenameSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetCreatorSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetPermissionSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetProjectSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetSpawnSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetStatusSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/TeleportSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/UnimportSubCommand.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ArgumentSorter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/BuildTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ConfigTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/EmptyTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/GamemodeTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/PhysicsTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpawnTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpeedTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/TimeTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/WorldsTabCompleter.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/config/Config.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/ConfigMigrationManager.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/Migration.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/MigrationV1ToV2.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/event/EventDispatcher.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/event/player/PlayerInventoryClearEvent.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/event/world/BuildWorldManipulationEvent.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/LuckPermsExpansion.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/BuildModeCalculator.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/RoleCalculator.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/placeholderapi/PlaceholderApiExpansion.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerChatListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerPreLoginListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BlockPhysicsListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildModePreventationListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildWorldResetUnloadListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EditSessionListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntityDamageListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntitySpawnListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/FoodLevelChangeListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryCreativeListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/NavigatorListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerChangedWorldListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerCommandPreprocessListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerInventoryClearListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerJoinListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerMoveListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerQuitListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerRespawnListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerTeleportListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SettingsInteractListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SignChangeListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WeatherChangeListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateByAxiomListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateListener.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/BuildPlayerImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/CachedValuesImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/LogoutLocationImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/PlayerServiceImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlock.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockManager.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/DesignInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/NoClipManager.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsManager.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SpeedInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/FolderStorageImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/PlayerStorageImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/WorldStorageImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/FolderStorageFactory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/PlayerStorageFactory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/WorldStorageFactory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/AbstractYamlStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlFolderStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlPlayerStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSetupStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSpawnStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlWorldStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ArgumentParser.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/DirectionUtil.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/FileUtils.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/NumberUtils.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/PlayerChatInput.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ServerModeChecker.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringCleaner.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringUtils.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UUIDFetcher.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UpdateChecker.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorAPI.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorPattern.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/GradientPattern.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/HexPattern.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/RainbowPattern.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/SolidPattern.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildSystemHolder.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildWorldHolder.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryHandler.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryManager.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryUtils.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/MaterialUtils.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/PaginatedInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/BuildWorldImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/SpawnManager.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/WorldServiceImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupService.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupsInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/LocalBackupStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/S3BackupStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/SftpBackupStorage.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuilderInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuildersImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/package-info.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/BuildWorldCreatorImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/CreateInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/CustomGeneratorImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/VoidGenerator.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/StatusInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/WorldDataImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/type/ConfigurableType.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/CustomizableIcons.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/FolderImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/DeleteInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/EditInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRuleEntry.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRulesInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/SetupInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/ArmorStandManager.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/ArchivedWorldsInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/CreatableWorldsInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/DisplayablesInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/FolderContentInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/NavigatorInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PrivateWorldsInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PublicWorldsInventory.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldDisplayImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldFilterImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldLoaderImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldPermissionsImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldTeleporterImpl.java create mode 100644 buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldUnloaderImpl.java create mode 100644 buildsystem-core/src/main/resources/config.yml create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100644 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle.kts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..0b8f560 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,363 @@ +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = false +max_line_length = 180 +tab_width = 4 +ij_continuation_indent_size = 4 +ij_formatter_off_tag = @formatter:off +ij_formatter_on_tag = @formatter:on +ij_formatter_tags_enabled = true +ij_smart_tabs = false +ij_visual_guides = +ij_wrap_on_typing = false + +[*.java] +ij_continuation_indent_size = 8 +ij_java_align_consecutive_assignments = false +ij_java_align_consecutive_variable_declarations = false +ij_java_align_group_field_declarations = false +ij_java_align_multiline_annotation_parameters = false +ij_java_align_multiline_array_initializer_expression = false +ij_java_align_multiline_assignment = false +ij_java_align_multiline_binary_operation = false +ij_java_align_multiline_chained_methods = false +ij_java_align_multiline_deconstruction_list_components = true +ij_java_align_multiline_extends_list = false +ij_java_align_multiline_for = false +ij_java_align_multiline_method_parentheses = false +ij_java_align_multiline_parameters = false +ij_java_align_multiline_parameters_in_calls = false +ij_java_align_multiline_parenthesized_expression = false +ij_java_align_multiline_records = true +ij_java_align_multiline_resources = false +ij_java_align_multiline_ternary_operation = false +ij_java_align_multiline_text_blocks = false +ij_java_align_multiline_throws_list = false +ij_java_align_subsequent_simple_methods = false +ij_java_align_throws_keyword = false +ij_java_align_types_in_multi_catch = true +ij_java_annotation_parameter_wrap = off +ij_java_array_initializer_new_line_after_left_brace = false +ij_java_array_initializer_right_brace_on_new_line = false +ij_java_array_initializer_wrap = normal +ij_java_assert_statement_colon_on_next_line = false +ij_java_assert_statement_wrap = off +ij_java_assignment_wrap = off +ij_java_binary_operation_sign_on_next_line = true +ij_java_binary_operation_wrap = normal +ij_java_blank_lines_after_anonymous_class_header = 0 +ij_java_blank_lines_after_class_header = 1 +ij_java_blank_lines_after_imports = 1 +ij_java_blank_lines_after_package = 1 +ij_java_blank_lines_around_class = 1 +ij_java_blank_lines_around_field = 0 +ij_java_blank_lines_around_field_in_interface = 0 +ij_java_blank_lines_around_initializer = 1 +ij_java_blank_lines_around_method = 1 +ij_java_blank_lines_around_method_in_interface = 1 +ij_java_blank_lines_before_class_end = 0 +ij_java_blank_lines_before_imports = 1 +ij_java_blank_lines_before_method_body = 0 +ij_java_blank_lines_before_package = 0 +ij_java_block_brace_style = end_of_line +ij_java_block_comment_add_space = false +ij_java_block_comment_at_first_column = true +ij_java_builder_methods = +ij_java_call_parameters_new_line_after_left_paren = false +ij_java_call_parameters_right_paren_on_new_line = false +ij_java_call_parameters_wrap = off +ij_java_case_statement_on_separate_line = true +ij_java_catch_on_new_line = false +ij_java_class_annotation_wrap = split_into_lines +ij_java_class_brace_style = end_of_line +ij_java_class_count_to_use_import_on_demand = 999 +ij_java_class_names_in_javadoc = 1 +ij_java_deconstruction_list_wrap = normal +ij_java_do_not_indent_top_level_class_members = false +ij_java_do_not_wrap_after_single_annotation = false +ij_java_do_not_wrap_after_single_annotation_in_parameter = false +ij_java_do_while_brace_force = always +ij_java_doc_add_blank_line_after_description = true +ij_java_doc_add_blank_line_after_param_comments = false +ij_java_doc_add_blank_line_after_return = false +ij_java_doc_add_p_tag_on_empty_lines = true +ij_java_doc_align_exception_comments = true +ij_java_doc_align_param_comments = true +ij_java_doc_do_not_wrap_if_one_line = false +ij_java_doc_enable_formatting = true +ij_java_doc_enable_leading_asterisks = true +ij_java_doc_indent_on_continuation = false +ij_java_doc_keep_empty_lines = true +ij_java_doc_keep_empty_parameter_tag = true +ij_java_doc_keep_empty_return_tag = true +ij_java_doc_keep_empty_throws_tag = true +ij_java_doc_keep_invalid_tags = true +ij_java_doc_param_description_on_new_line = false +ij_java_doc_preserve_line_breaks = false +ij_java_doc_use_throws_not_exception_tag = true +ij_java_else_on_new_line = false +ij_java_entity_dd_prefix = +ij_java_entity_dd_suffix = EJB +ij_java_entity_eb_prefix = +ij_java_entity_eb_suffix = Bean +ij_java_entity_hi_prefix = +ij_java_entity_hi_suffix = Home +ij_java_entity_lhi_prefix = Local +ij_java_entity_lhi_suffix = Home +ij_java_entity_li_prefix = Local +ij_java_entity_li_suffix = +ij_java_entity_pk_class = java.lang.String +ij_java_entity_ri_prefix = +ij_java_entity_ri_suffix = +ij_java_entity_vo_prefix = +ij_java_entity_vo_suffix = VO +ij_java_enum_constants_wrap = off +ij_java_enum_field_annotation_wrap = off +ij_java_extends_keyword_wrap = off +ij_java_extends_list_wrap = normal +ij_java_field_annotation_wrap = split_into_lines +ij_java_field_name_prefix = +ij_java_field_name_suffix = +ij_java_filter_class_prefix = +ij_java_filter_class_suffix = +ij_java_filter_dd_prefix = +ij_java_filter_dd_suffix = +ij_java_finally_on_new_line = false +ij_java_for_brace_force = always +ij_java_for_statement_new_line_after_left_paren = false +ij_java_for_statement_right_paren_on_new_line = false +ij_java_for_statement_wrap = normal +ij_java_generate_final_locals = false +ij_java_generate_final_parameters = false +ij_java_generate_use_type_annotation_before_type = true +ij_java_if_brace_force = always +ij_java_imports_layout = $*, |, * +ij_java_indent_case_from_switch = true +ij_java_insert_inner_class_imports = true +ij_java_insert_override_annotation = true +ij_java_keep_blank_lines_before_right_brace = 2 +ij_java_keep_blank_lines_between_package_declaration_and_header = 2 +ij_java_keep_blank_lines_in_code = 1 +ij_java_keep_blank_lines_in_declarations = 2 +ij_java_keep_builder_methods_indents = false +ij_java_keep_control_statement_in_one_line = false +ij_java_keep_first_column_comment = true +ij_java_keep_indents_on_empty_lines = false +ij_java_keep_line_breaks = true +ij_java_keep_multiple_expressions_in_one_line = false +ij_java_keep_simple_blocks_in_one_line = false +ij_java_keep_simple_classes_in_one_line = false +ij_java_keep_simple_lambdas_in_one_line = false +ij_java_keep_simple_methods_in_one_line = false +ij_java_label_indent_absolute = false +ij_java_label_indent_size = 0 +ij_java_lambda_brace_style = end_of_line +ij_java_layout_static_imports_separately = true +ij_java_line_comment_add_space = false +ij_java_line_comment_add_space_on_reformat = false +ij_java_line_comment_at_first_column = true +ij_java_listener_class_prefix = +ij_java_listener_class_suffix = +ij_java_local_variable_name_prefix = +ij_java_local_variable_name_suffix = +ij_java_message_dd_prefix = +ij_java_message_dd_suffix = EJB +ij_java_message_eb_prefix = +ij_java_message_eb_suffix = Bean +ij_java_method_annotation_wrap = split_into_lines +ij_java_method_brace_style = end_of_line +ij_java_method_call_chain_wrap = normal +ij_java_method_parameters_new_line_after_left_paren = false +ij_java_method_parameters_right_paren_on_new_line = false +ij_java_method_parameters_wrap = off +ij_java_modifier_list_wrap = false +ij_java_multi_catch_types_wrap = normal +ij_java_names_count_to_use_import_on_demand = 999 +ij_java_new_line_after_lparen_in_annotation = false +ij_java_new_line_after_lparen_in_deconstruction_pattern = true +ij_java_new_line_after_lparen_in_record_header = false +ij_java_new_line_when_body_is_presented = false +ij_java_packages_to_use_import_on_demand = +ij_java_parameter_annotation_wrap = off +ij_java_parameter_name_prefix = +ij_java_parameter_name_suffix = +ij_java_parentheses_expression_new_line_after_left_paren = false +ij_java_parentheses_expression_right_paren_on_new_line = false +ij_java_place_assignment_sign_on_next_line = false +ij_java_prefer_longer_names = true +ij_java_prefer_parameters_wrap = false +ij_java_record_components_wrap = normal +ij_java_repeat_synchronized = true +ij_java_replace_instanceof_and_cast = false +ij_java_replace_null_check = true +ij_java_replace_sum_lambda_with_method_ref = true +ij_java_resource_list_new_line_after_left_paren = false +ij_java_resource_list_right_paren_on_new_line = false +ij_java_resource_list_wrap = off +ij_java_rparen_on_new_line_in_annotation = false +ij_java_rparen_on_new_line_in_deconstruction_pattern = true +ij_java_rparen_on_new_line_in_record_header = false +ij_java_servlet_class_prefix = +ij_java_servlet_class_suffix = +ij_java_servlet_dd_prefix = +ij_java_servlet_dd_suffix = +ij_java_session_dd_prefix = +ij_java_session_dd_suffix = EJB +ij_java_session_eb_prefix = +ij_java_session_eb_suffix = Bean +ij_java_session_hi_prefix = +ij_java_session_hi_suffix = Home +ij_java_session_lhi_prefix = Local +ij_java_session_lhi_suffix = Home +ij_java_session_li_prefix = Local +ij_java_session_li_suffix = +ij_java_session_ri_prefix = +ij_java_session_ri_suffix = +ij_java_session_si_prefix = +ij_java_session_si_suffix = Service +ij_java_space_after_closing_angle_bracket_in_type_argument = false +ij_java_space_after_colon = true +ij_java_space_after_comma = true +ij_java_space_after_comma_in_type_arguments = true +ij_java_space_after_for_semicolon = true +ij_java_space_after_quest = true +ij_java_space_after_type_cast = true +ij_java_space_before_annotation_array_initializer_left_brace = false +ij_java_space_before_annotation_parameter_list = false +ij_java_space_before_array_initializer_left_brace = false +ij_java_space_before_catch_keyword = true +ij_java_space_before_catch_left_brace = true +ij_java_space_before_catch_parentheses = true +ij_java_space_before_class_left_brace = true +ij_java_space_before_colon = true +ij_java_space_before_colon_in_foreach = true +ij_java_space_before_comma = false +ij_java_space_before_deconstruction_list = false +ij_java_space_before_do_left_brace = true +ij_java_space_before_else_keyword = true +ij_java_space_before_else_left_brace = true +ij_java_space_before_finally_keyword = true +ij_java_space_before_finally_left_brace = true +ij_java_space_before_for_left_brace = true +ij_java_space_before_for_parentheses = true +ij_java_space_before_for_semicolon = false +ij_java_space_before_if_left_brace = true +ij_java_space_before_if_parentheses = true +ij_java_space_before_method_call_parentheses = false +ij_java_space_before_method_left_brace = true +ij_java_space_before_method_parentheses = false +ij_java_space_before_opening_angle_bracket_in_type_parameter = false +ij_java_space_before_quest = true +ij_java_space_before_switch_left_brace = true +ij_java_space_before_switch_parentheses = true +ij_java_space_before_synchronized_left_brace = true +ij_java_space_before_synchronized_parentheses = true +ij_java_space_before_try_left_brace = true +ij_java_space_before_try_parentheses = true +ij_java_space_before_type_parameter_list = false +ij_java_space_before_while_keyword = true +ij_java_space_before_while_left_brace = true +ij_java_space_before_while_parentheses = true +ij_java_space_inside_one_line_enum_braces = false +ij_java_space_within_empty_array_initializer_braces = false +ij_java_space_within_empty_method_call_parentheses = false +ij_java_space_within_empty_method_parentheses = false +ij_java_spaces_around_additive_operators = true +ij_java_spaces_around_annotation_eq = true +ij_java_spaces_around_assignment_operators = true +ij_java_spaces_around_bitwise_operators = true +ij_java_spaces_around_equality_operators = true +ij_java_spaces_around_lambda_arrow = true +ij_java_spaces_around_logical_operators = true +ij_java_spaces_around_method_ref_dbl_colon = false +ij_java_spaces_around_multiplicative_operators = true +ij_java_spaces_around_relational_operators = true +ij_java_spaces_around_shift_operators = true +ij_java_spaces_around_type_bounds_in_type_parameters = true +ij_java_spaces_around_unary_operator = false +ij_java_spaces_inside_block_braces_when_body_is_present = false +ij_java_spaces_within_angle_brackets = false +ij_java_spaces_within_annotation_parentheses = false +ij_java_spaces_within_array_initializer_braces = false +ij_java_spaces_within_braces = false +ij_java_spaces_within_brackets = false +ij_java_spaces_within_cast_parentheses = false +ij_java_spaces_within_catch_parentheses = false +ij_java_spaces_within_deconstruction_list = false +ij_java_spaces_within_for_parentheses = false +ij_java_spaces_within_if_parentheses = false +ij_java_spaces_within_method_call_parentheses = false +ij_java_spaces_within_method_parentheses = false +ij_java_spaces_within_parentheses = false +ij_java_spaces_within_record_header = false +ij_java_spaces_within_switch_parentheses = false +ij_java_spaces_within_synchronized_parentheses = false +ij_java_spaces_within_try_parentheses = false +ij_java_spaces_within_while_parentheses = false +ij_java_special_else_if_treatment = true +ij_java_static_field_name_prefix = +ij_java_static_field_name_suffix = +ij_java_subclass_name_prefix = +ij_java_subclass_name_suffix = Impl +ij_java_switch_expressions_wrap = normal +ij_java_ternary_operation_signs_on_next_line = true +ij_java_ternary_operation_wrap = normal +ij_java_test_name_prefix = +ij_java_test_name_suffix = Test +ij_java_throws_keyword_wrap = normal +ij_java_throws_list_wrap = off +ij_java_use_external_annotations = false +ij_java_use_fq_class_names = false +ij_java_use_relative_indents = false +ij_java_use_single_class_imports = true +ij_java_variable_annotation_wrap = off +ij_java_visibility = public +ij_java_while_brace_force = always +ij_java_while_on_new_line = false +ij_java_wrap_comments = true +ij_java_wrap_first_method_in_call_chain = false +ij_java_wrap_long_lines = false +ij_java_wrap_semicolon_after_call_chain = false + +[{*.har,*.jsb2,*.jsb3,*.json,*.jsonc,*.png.mcmeta,*.postman_collection,*.postman_collection.json,*.postman_environment,*.postman_environment.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,.ws-context,jest.config,mcmod.info,pack.mcmeta}] +indent_size = 2 +tab_width = 2 +ij_json_array_wrapping = split_into_lines +ij_json_keep_blank_lines_in_code = 0 +ij_json_keep_indents_on_empty_lines = false +ij_json_keep_line_breaks = true +ij_json_keep_trailing_comma = false +ij_json_object_wrapping = split_into_lines +ij_json_property_alignment = do_not_align +ij_json_space_after_colon = true +ij_json_space_after_comma = true +ij_json_space_before_colon = false +ij_json_space_before_comma = false +ij_json_spaces_within_braces = false +ij_json_spaces_within_brackets = false +ij_json_wrap_long_lines = false + +[{*.properties,spring.handlers,spring.schemas}] +ij_properties_align_group_field_declarations = false +ij_properties_keep_blank_lines = false +ij_properties_key_value_delimiter = equals +ij_properties_spaces_around_key_value_delimiter = false + +[{*.yaml,*.yml}] +indent_size = 2 +ij_yaml_align_values_properties = do_not_align +ij_yaml_autoinsert_sequence_marker = true +ij_yaml_block_mapping_on_new_line = false +ij_yaml_indent_sequence_value = true +ij_yaml_keep_indents_on_empty_lines = false +ij_yaml_keep_line_breaks = true +ij_yaml_line_comment_add_space = false +ij_yaml_line_comment_add_space_on_reformat = false +ij_yaml_line_comment_at_first_column = true +ij_yaml_sequence_on_new_line = false +ij_yaml_space_before_colon = false +ij_yaml_spaces_within_braces = true +ij_yaml_spaces_within_brackets = true diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..13adaa4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,52 @@ +name: "🔥 Bug report" +description: Create a report to help us improve +labels: [ "status: needs testing", "type: bug" ] +body: + - type: markdown + attributes: + value: | + **Before You Start:** + + This form is for submitting bug reports. If you have a usage question + or are unsure if this is really a bug, make sure to: + + - Ask on our [Discord Chat](https://discord.com/invite/rduPF3yk62) + + Also try to search for your issue - it may have already been answered or even fixed in a development branch. + However, if you find that an old, closed issue still persists in the latest version, + you should open a new issue using the form below instead of commenting on the old issue. + - type: textarea + id: steps-to-reproduce + attributes: + label: Steps to reproduce + description: | + What do we need to do in order to make the bug happen? Clear and concise reproduction instructions are important for us to be able to triage your issue in a timely manner. + placeholder: Steps to reproduce + validations: + required: true + - type: textarea + id: expected + attributes: + label: What is expected? + validations: + required: true + - type: textarea + id: actually-happening + attributes: + label: What is actually happening? + validations: + required: true + - type: textarea + id: server-info + attributes: + label: Server Info + description: "Minecraft version: (e.g. 1.21.4), Server version (e.g. Spigot, Paper), Plugin version (e.g. 2.17.1)" + render: shell + placeholder: "Minecraft version 1.21.4 on the server version Paper-386 with the Plugin version 2.17.1" + validations: + required: true + - type: textarea + id: additional-comments + attributes: + label: Any additional comments? + description: For example some background / context of how you ran into this bug. diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..038ae5c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: 🗣️ Discord Chat + url: https://discord.com/invite/rduPF3yk62 + about: Support Discord, please ask questions and seek support here. \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..05138bc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,26 @@ +name: "✨ New feature request" +description: Suggest an idea for this project +labels: [ "type: feature" ] +body: + - type: markdown + attributes: + value: | + **Before You Start:** + + This form is only for submitting feature requests. If you have a usage question + or are unsure if this is really a bug, make sure to: + + - Ask on our [Discord Chat](https://discord.com/invite/rduPF3yk62) + + Also try to search for your issue - another user may have already requested something similar! + + - type: textarea + id: problem-description + attributes: + label: What problem does this feature solve? + description: | + Explain your use case, context, and rationale behind this feature request. More importantly, what is the **end user experience** you are trying to build that led to the need for this feature? + + placeholder: Problem description + validations: + required: true diff --git a/.github/images/blocks.png b/.github/images/blocks.png new file mode 100644 index 0000000000000000000000000000000000000000..6d7ff0aeab2a0526db2afecc0ca598401da4f1aa GIT binary patch literal 45884 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

s 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

    A?xt5Bma8lm%8mOUQ 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 literal 0 HcmV?d00001 diff --git a/.github/images/navigator.png b/.github/images/navigator.png new file mode 100644 index 0000000000000000000000000000000000000000..6f1eebd45dacb176425d7bb86fc5daca716018db GIT binary patch literal 18602 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 literal 0 HcmV?d00001 diff --git a/.github/images/predefined_worlds.png b/.github/images/predefined_worlds.png new file mode 100644 index 0000000000000000000000000000000000000000..6aa59fe1c53c925b39da2475f4a420ff55d9b8d3 GIT binary patch literal 36199 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 E1KI;KmjD0& literal 0 HcmV?d00001 diff --git a/.github/images/scoreboard.png b/.github/images/scoreboard.png new file mode 100644 index 0000000000000000000000000000000000000000..9f1c93b80aeba5278220ef0521038245146238fc GIT binary patch literal 13975 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

    eMnwbVyKir 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

    + BuildSystem is a utility plugin targeted towards build-teams. + It allows worlds to be easily managed and also provides handy tools to assist with building. +

    +

    Useful Links

    +
    + \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystem.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystem.java new file mode 100644 index 0000000..514c1b1 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystem.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api; + +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.PlayerService; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.WorldService; +import org.jspecify.annotations.NullMarked; + +/** + * The BuildSystem API. + * + * @since 3.0.0 + */ +@NullMarked +public interface BuildSystem { + + /** + * Gets the {@link WorldService}, responsible for managing {@link BuildWorld} instances. + * + * @return The world manager + */ + WorldService getWorldService(); + + /** + * Gets the {@link PlayerService}, responsible for managing {@link BuildPlayer} instances. + * + * @return The player manager + */ + PlayerService getPlayerService(); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystemProvider.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystemProvider.java new file mode 100644 index 0000000..0222f37 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/BuildSystemProvider.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api; + +import org.jetbrains.annotations.ApiStatus; +import org.jetbrains.annotations.Nullable; +import org.jspecify.annotations.NullMarked; + +/** + * Provides static access to the {@link BuildSystem} API. + * + * @since 3.0.0 + */ +@NullMarked +public class BuildSystemProvider { + + @Nullable + private static BuildSystem instance = null; + + /** + * Sole private constructor to prevent instantiation. + * + * @throws AssertionError Always, as this class is not meant to be instantiated + */ + @ApiStatus.Internal + private BuildSystemProvider() { + throw new AssertionError("This class is not meant to be instantiated"); + } + + /** + * Gets an instance of the {@link BuildSystem} API. + * + * @return An instance of the BuildSystem API + * @throws IllegalStateException if the API is not loaded yet + */ + public static BuildSystem get() { + BuildSystem instance = BuildSystemProvider.instance; + if (instance == null) { + throw new IllegalStateException("BuildSystem has not loaded yet!"); + } + return instance; + } + + @ApiStatus.Internal + static void register(BuildSystem instance) { + BuildSystemProvider.instance = instance; + } + + @ApiStatus.Internal + static void unregister() { + BuildSystemProvider.instance = null; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Bypassable.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Bypassable.java new file mode 100644 index 0000000..98baba8 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Bypassable.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.data; + +import org.jspecify.annotations.NullMarked; + +/** + * A {@link Capability} that marks a {@link Type} as being bypassable with a specific permission. + * + * @param permission The permission node required to bypass this type + * @since 3.0.1 + */ +@NullMarked +public record Bypassable(String permission) implements Capability { + +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Capability.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Capability.java new file mode 100644 index 0000000..85c600f --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Capability.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.data; + +/** + * A marker interface for a "capability" or "attachment" that can be added to a {@link Type}. + * + * @since 3.0.1 + */ +public interface Capability { + +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Overridable.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Overridable.java new file mode 100644 index 0000000..28b13e1 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Overridable.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.data; + +import java.util.function.BooleanSupplier; +import java.util.function.Supplier; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * A {@link Capability} that marks a {@link Type} as being overridable by an external source. + * + * @param The type of the value being overridden + * @param isEnabled A supplier that returns {@code true} if the override is active + * @param provider A supplier that returns the override value or {@code null} if no override is set + * @since 3.0.1 + */ +@NullMarked +public record Overridable( + BooleanSupplier isEnabled, + Supplier<@Nullable T> provider +) implements Capability { + +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Type.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Type.java new file mode 100644 index 0000000..432d150 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/data/Type.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.data; + +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +/** + * A generic interface representing a configurable data type. + * + * @param The type of the value held by this data point + */ +@NullMarked +public interface Type { + + /** + * An immutable implementation of the {@link Type} interface using a Java Record. This class holds a final, read-only value. + * + * @param The type of the value held + * @param value The immutable value + */ + record ImmutableType(T value) implements Type { + + /** + * Gets the immutable value. + * + * @return The value + */ + @Override + public T get() { + return value; + } + + /** + * Throws {@link UnsupportedOperationException} as this type is immutable. + * + * @param value The value to set (which is ignored) + * @throws UnsupportedOperationException Always, as this type cannot be modified + */ + @Contract("_ -> fail") + @Override + public void set(T value) { + throw new UnsupportedOperationException("This Type is immutable and cannot be modified."); + } + + /** + * Gets the immutable value formatted for storage. + * + * @return The immutable value + */ + @Override + public Object getConfigFormat() { + return value; + } + } + + /** + * An immutable {@link Type} representing the boolean value {@code true}. + */ + Type TRUE = new ImmutableType<>(true); + + /** + * Gets the current value of this data point. + * + * @return The current value + */ + T get(); + + /** + * Sets the value of this data point. + * + * @param value The new value to set + */ + void set(T value); + + /** + * Gets the value of this data point formatted for storage in a configuration file. + * This might involve converting complex objects into simpler types (e.g., enums to strings). + * + * @return The value formatted for a config file + */ + Object getConfigFormat(); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/package-info.java new file mode 100644 index 0000000..21749fe --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces for events related to the BuildSystem API. These events allow external plugins to hook into and react to various actions within the BuildSystem. + */ +package de.eintosti.buildsystem.api.event; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldEvent.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldEvent.java new file mode 100644 index 0000000..af30747 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldEvent.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.event.world; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.event.Event; +import org.bukkit.event.HandlerList; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; + +/** + * Represents a {@link BuildWorld} related event. + * + * @since 3.0.0 + */ +@NullMarked +public class BuildWorldEvent extends Event { + + private static final HandlerList HANDLER_LIST = new HandlerList(); + + private final BuildWorld buildWorld; + + /** + * Constructs a new {@link BuildWorldEvent}. + * + * @param buildWorld The {@link BuildWorld} involved in this event + */ + @Internal + public BuildWorldEvent(BuildWorld buildWorld) { + this.buildWorld = buildWorld; + } + + /** + * Gets the {@link BuildWorld} involved in this event + * + * @return The world involved in this event + */ + public BuildWorld getBuildWorld() { + return buildWorld; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + /** + * Gets the handler list for this event. + * + * @return The handler list + */ + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldLoadEvent.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldLoadEvent.java new file mode 100644 index 0000000..d9c7bab --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldLoadEvent.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.event.world; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.event.Cancellable; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; + +/** + * Called when a {@link BuildWorld} is loaded. + * + * @since 3.0.0 + */ +@NullMarked +public class BuildWorldLoadEvent extends BuildWorldEvent implements Cancellable { + + private boolean cancelled = false; + + /** + * Constructs a new {@link BuildWorldLoadEvent}. + * + * @param buildWorld The {@link BuildWorld} that is about to be loaded + */ + @Internal + public BuildWorldLoadEvent(BuildWorld buildWorld) { + super(buildWorld); + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostLoadEvent.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostLoadEvent.java new file mode 100644 index 0000000..98819ea --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostLoadEvent.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.event.world; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; + +/** + * Called after a {@link BuildWorld} has loaded. + * + * @since 3.0.0 + */ +@NullMarked +public class BuildWorldPostLoadEvent extends BuildWorldEvent { + + /** + * Constructs a new {@link BuildWorldPostLoadEvent}. + * + * @param buildWorld The {@link BuildWorld} that has been loaded + */ + @Internal + public BuildWorldPostLoadEvent(BuildWorld buildWorld) { + super(buildWorld); + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostUnloadEvent.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostUnloadEvent.java new file mode 100644 index 0000000..690d766 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldPostUnloadEvent.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.event.world; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; + +/** + * Called after a {@link BuildWorld} has unloaded. + * + * @since 3.0.0 + */ +@NullMarked +public class BuildWorldPostUnloadEvent extends BuildWorldEvent { + + /** + * Constructs a new {@link BuildWorldPostUnloadEvent}. + * + * @param buildWorld The {@link BuildWorld} that has been unloaded + */ + @Internal + public BuildWorldPostUnloadEvent(BuildWorld buildWorld) { + super(buildWorld); + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldUnloadEvent.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldUnloadEvent.java new file mode 100644 index 0000000..2dc7fa4 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/BuildWorldUnloadEvent.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.event.world; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.event.Cancellable; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; + +/** + * Called when a {@link BuildWorld} is unloaded. + * + * @since 3.0.0 + */ +@NullMarked +public class BuildWorldUnloadEvent extends BuildWorldEvent implements Cancellable { + + private boolean cancelled = false; + + /** + * Constructs a new {@link BuildWorldUnloadEvent}. + * + * @param buildWorld The {@link BuildWorld} that is about to be unloaded + */ + @Internal + public BuildWorldUnloadEvent(BuildWorld buildWorld) { + super(buildWorld); + } + + @Override + public boolean isCancelled() { + return cancelled; + } + + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/PlayerBuildModeToggleEvent.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/PlayerBuildModeToggleEvent.java new file mode 100644 index 0000000..a13de4c --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/PlayerBuildModeToggleEvent.java @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.event.world; + +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; +import org.jspecify.annotations.NullMarked; + +/** + * Called when a player's build mode is toggled. This event can be triggered by the player themselves or by another plugin/player. + */ +@NullMarked +public class PlayerBuildModeToggleEvent extends PlayerEvent implements Cancellable { + + private static final HandlerList HANDLER_LIST = new HandlerList(); + + private final boolean buildMode; + private final Player causer; + private boolean cancelled; + + /** + * Constructs a new {@link PlayerBuildModeToggleEvent}. + * + * @param who The player whose build mode is being toggled + * @param buildMode The new build mode status ({@code true} for enabled, {@code false} for disabled) + * @param causer The player who caused the build mode to be toggled + */ + public PlayerBuildModeToggleEvent(Player who, boolean buildMode, Player causer) { + super(who); + this.buildMode = buildMode; + this.causer = causer; + this.cancelled = false; + } + + /** + * Gets the new build mode status. + * + * @return {@code true} if build mode is being enabled, {@code false} otherwise + */ + public boolean isBuildMode() { + return this.buildMode; + } + + /** + * Gets the player who caused the build mode to be toggled. + *

    + * This will return the player themselves if they toggled their own build mode. + * + * @return The player who caused the action, or null. + */ + public Player getCauser() { + return this.causer; + } + + /** + * Gets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins + * + * @return true if this event is cancelled + */ + @Override + public boolean isCancelled() { + return this.cancelled; + } + + /** + * Sets the cancellation state of this event. A cancelled event will not be executed in the server, but will still pass to other plugins. + * + * @param cancel true if you wish to cancel this event + */ + @Override + public void setCancelled(boolean cancel) { + this.cancelled = cancel; + } + + @Override + public HandlerList getHandlers() { + return HANDLER_LIST; + } + + /** + * Gets the handler list for this event. + * + * @return The handler list + */ + public static HandlerList getHandlerList() { + return HANDLER_LIST; + } +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/package-info.java new file mode 100644 index 0000000..ff2f6d4 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/event/world/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Events relating to a {@link de.eintosti.buildsystem.api.world.BuildWorld}. + */ +package de.eintosti.buildsystem.api.event.world; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDeletionException.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDeletionException.java new file mode 100644 index 0000000..7873116 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDeletionException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.exception; + +/** + * Thrown when an error occurs during the deletion of a world. + * + * @since 3.0.0 + */ +public class WorldDeletionException extends WorldException { + + /** + * Constructs a new {@link WorldDeletionException} with the specified message and cause. + * + * @param message The detail message + * @param cause The cause of the exception + */ + public WorldDeletionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDirectoryNotFoundException.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDirectoryNotFoundException.java new file mode 100644 index 0000000..6066728 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldDirectoryNotFoundException.java @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.exception; + +/** + * Thrown when a world's directory is not found at the expected path. + * + * @since 3.0.0 + */ +public class WorldDirectoryNotFoundException extends WorldException { + + /** + * Constructs a new {@link WorldDirectoryNotFoundException} with the specified world name and path. + * + * @param worldName The name of the world + * @param path The path to the expected world directory + */ + public WorldDirectoryNotFoundException(String worldName, String path) { + super("World directory for '" + worldName + "' not found at: " + path); + } +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldException.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldException.java new file mode 100644 index 0000000..1e49d41 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldException.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.exception; + +/** + * The base exception for all world-related errors in the BuildSystem API. + * + * @since 3.0.0 + */ +public class WorldException extends RuntimeException { + + /** + * Constructs a new {@link WorldException} with the specified detail message. + * + * @param message The detail message + */ + public WorldException(String message) { + super(message); + } + + /** + * Constructs a new {@link WorldException} with the specified detail message and cause. + * + * @param message The detail message + * @param cause The cause of the exception + */ + public WorldException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldNotFoundException.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldNotFoundException.java new file mode 100644 index 0000000..1519328 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/exception/WorldNotFoundException.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.exception; + +/** + * Thrown when a requested world cannot be found. + * + * @since 3.0.0 + */ +public class WorldNotFoundException extends WorldException { + + /** + * Constructs a new {@link WorldNotFoundException} with the specified world name. + * + * @param worldName The name of the world that was not found + */ + public WorldNotFoundException(String worldName) { + super("World '" + worldName + "' does not exist."); + } +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/package-info.java new file mode 100644 index 0000000..7abd9b9 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces for events related to the BuildSystem API. These events allow external plugins to hook into and react to various actions within the BuildSystem. + */ +package de.eintosti.buildsystem.api; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/BuildPlayer.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/BuildPlayer.java new file mode 100644 index 0000000..748c9a6 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/BuildPlayer.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.player; + +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents a player managed by the BuildSystem. This interface provides access to player-specific data and settings within the BuildSystem. + */ +@NullMarked +public interface BuildPlayer { + + /** + * Gets the unique-id of the player. + *

    + * Should match the wrapped {@link Player}'s UUID. + * + * @return The player's UUID + * @see Player#getUniqueId() + */ + UUID getUniqueId(); + + /** + * Gets the player's custom settings. + * + * @return The player's settings + */ + Settings getSettings(); + + /** + * Gets values that are supposed to be cached for a short amount of time. + * + * @return The player's cached values + */ + @Internal + CachedValues getCachedValues(); + + /** + * Gets the location where the player was last before logging off. + * + * @return The location + */ + @Internal + @Nullable + LogoutLocation getLogoutLocation(); + + /** + * Sets the location where the player was last before logging off. + * + * @param logoutLocation The logout location + */ + @Internal + void setLogoutLocation(@Nullable LogoutLocation logoutLocation); + + /** + * Gets the location the player was last at. + *

    + * Usually this is the last location before teleportation. + * + * @return The player's previous location + */ + @Internal + @Nullable + Location getPreviousLocation(); + + /** + * Sets the location the player was last at. + *

    + * Usually this is the last location before teleportation. + * + * @param location The location + */ + @Internal + void setPreviousLocation(@Nullable Location location); + + /** + * Gets the {@link NavigatorCategory} the player last looked at. + * + * @return The last looked navigator inventory type + */ + @Internal + @Nullable + NavigatorCategory getLastLookedAt(); + + /** + * Sets the {@link NavigatorCategory} the player last looked at. + * + * @param type The last looked navigator inventory type + */ + @Internal + void setLastLookedAt(@Nullable NavigatorCategory type); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/CachedValues.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/CachedValues.java new file mode 100644 index 0000000..e914692 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/CachedValues.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.player; + +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; + +/** + * Interface for managing cached values of a player. + * + * @since 3.0.0 + */ +@Internal +@NullMarked +public interface CachedValues { + + /** + * Saves the given {@link GameMode} to be restored later. + * + * @param gameMode The game mode to save + */ + void saveGameMode(GameMode gameMode); + + /** + * Resets the player's game mode to the previously saved one, if present. + * + * @param player The player whose game mode is to be reset + */ + void resetGameModeIfPresent(Player player); + + /** + * Saves the given inventory contents to be restored later. + * + * @param inventory The inventory contents to save + */ + void saveInventory(ItemStack[] inventory); + + /** + * Resets the player's inventory to the previously saved one, if present. + * + * @param player The player whose inventory is to be reset + */ + void resetInventoryIfPresent(Player player); + + /** + * Saves the given walk speed to be restored later. + * + * @param walkSpeed The walk speed to save + */ + void saveWalkSpeed(float walkSpeed); + + /** + * Resets the player's walk speed to the previously saved one, if present. + * + * @param player The player whose walk speed is to be reset + */ + void resetWalkSpeedIfPresent(Player player); + + /** + * Saves the given fly speed to be restored later. + * + * @param flySpeed The fly speed to save + */ + void saveFlySpeed(float flySpeed); + + /** + * Resets the player's fly speed to the previously saved one, if present. + * + * @param player The player whose fly speed is to be reset + */ + void resetFlySpeedIfPresent(Player player); + + /** + * Resets all cached values for the given player. + * + * @param player The player whose cached values are to be reset + */ + void resetCachedValues(Player player); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/LogoutLocation.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/LogoutLocation.java new file mode 100644 index 0000000..749d407 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/LogoutLocation.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.player; + +import org.bukkit.Location; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents a player's logout location, which includes the world name and the location coordinates. + * + * @since 3.0.0 + */ +@Internal +@NullMarked +public interface LogoutLocation { + + /** + * Gets the name of the world the player logged out from. + * + * @return The world name + */ + String worldName(); + + /** + * Gets the exact {@link Location} the player logged out from. + * + * @return The logout location + */ + @Nullable + Location location(); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/PlayerService.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/PlayerService.java new file mode 100644 index 0000000..b98a089 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/PlayerService.java @@ -0,0 +1,88 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.player; + +import de.eintosti.buildsystem.api.storage.PlayerStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.Visibility; +import java.util.Set; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +/** + * Service for managing {@link BuildPlayer}. + * + * @since 3.0.0 + */ +@NullMarked +public interface PlayerService { + + /** + * Gets the {@link PlayerStorage} implementation for managing {@link BuildPlayer} persistence. + * + * @return The {@link PlayerStorage} implementation + */ + PlayerStorage getPlayerStorage(); + + /** + * Gets a set of all players currently in "build mode". + * + * @return A set of all players in "build mode". + * @see #isInBuildMode(Player) + */ + Set getBuildModePlayers(); + + /** + * Gets whether a player is currently in "build mode". + * + * @param player The player + * @return {@code true} if the player is in "build mode", otherwise {@code false} + */ + boolean isInBuildMode(Player player); + + /** + * Gets whether the given player is allowed to create a new {@link BuildWorld}.
    This depends on the following factors: + *

      + *
    • Is the maximum number of worlds set by the config lower than the number of existing worlds?
    • + *
    • Is the maximum number of worlds created by the player less than the number of worlds said player is allowed to create?
    • + *
    + * + * @param player The player trying to create a world + * @param visibility The visibility of the world trying to be created + * @return {@code true} if the player is allowed to create a world, otherwise {@code false} + */ + boolean canCreateWorld(Player player, Visibility visibility); + + /** + * Returns the maximum amount of {@link BuildWorld}s a player can create. + *

    + * If the player has the permission {@code buildsystem.admin}, unlimited worlds can be created. Otherwise, there are two different permissions to set said amount: + *

    + * To set the maximum of... + *

      + *
    • ...public worlds, use {@code buildsystem.create.public.%amount%}. + *
    • ...private worlds, use {@code buildsystem.create.private.%amount%}. + *
    + * + * @param player The player object + * @param visibility The visibility of the worlds to check the maximum of. Possible values: {@link Visibility#PUBLIC} or {@link Visibility#PRIVATE} + * @return If set, the maximum number of worlds a player can create, otherwise -1 + */ + int getMaxWorlds(Player player, Visibility visibility); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/package-info.java new file mode 100644 index 0000000..bb81b8d --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces and classes related to {@link de.eintosti.buildsystem.api.player.BuildPlayer} management within the BuildSystem API. This includes player-specific data, + * settings, and cached values. + */ +package de.eintosti.buildsystem.api.player; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/DesignColor.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/DesignColor.java new file mode 100644 index 0000000..513d4e9 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/DesignColor.java @@ -0,0 +1,123 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.player.settings; + +import org.jspecify.annotations.NullMarked; + +/** + * A {@link DesignColor} is the color which glass panes are tinted to in different menus. + * + * @since 3.0.0 + */ +@NullMarked +public enum DesignColor { + + /** + * The color red. + */ + RED, + + /** + * The color orange. + */ + ORANGE, + + /** + * The color yellow. + */ + YELLOW, + + /** + * The color pink. + */ + PINK, + + /** + * The color magenta. + */ + MAGENTA, + + /** + * The color purple. + */ + PURPLE, + + /** + * The color brown. + */ + BROWN, + + /** + * The color lime. + */ + LIME, + + /** + * The color green. + */ + GREEN, + + /** + * The color blue. + */ + BLUE, + + /** + * The color cyan. + */ + CYAN, + + /** + * The color light blue. + */ + LIGHT_BLUE, + + /** + * The color white. + */ + WHITE, + + /** + * The color gray. + */ + GRAY, + + /** + * The color light gray. + */ + LIGHT_GRAY, + + /** + * The color black. + */ + BLACK; + + /** + * Gets the {@link DesignColor} from a string. + * + * @param colorName The name of the color + * @return The {@link DesignColor} or {@link DesignColor#BLACK} if the color does not exist + */ + public static DesignColor matchColor(String colorName) { + try { + return valueOf(colorName); + } catch (IllegalArgumentException e) { + return DesignColor.BLACK; + } + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/Settings.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/Settings.java new file mode 100644 index 0000000..4b61da9 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/Settings.java @@ -0,0 +1,259 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.player.settings; + +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.world.navigator.settings.NavigatorType; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldDisplay; +import org.bukkit.GameMode; +import org.bukkit.potion.PotionEffectType; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.ApiStatus.Internal; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Different settings a {@link BuildPlayer} can modify for themselves. + * + * @since 3.0.0 + */ +@NullMarked +public interface Settings { + + /** + * Gets the mode the navigator is set to. + * + * @return The navigator type + */ + NavigatorType getNavigatorType(); + + /** + * Sets the navigator type. + *

    + * The {@link NavigatorType#OLD} is the classic chest menu, whereas {@link NavigatorType#NEW} is a new 3D selector. + * + * @param navigatorType The navigator type + */ + void setNavigatorType(NavigatorType navigatorType); + + /** + * Gets the design color used in menus. + * + * @return The design color + */ + DesignColor getDesignColor(); + + /** + * Sets the design color used in menus. + * + * @param designColor The design color + */ + void setDesignColor(DesignColor designColor); + + /** + * Gets the set of rules by which worlds are displayed in the navigator. + * + * @return The world display rules + */ + WorldDisplay getWorldDisplay(); + + /** + * Gets whether the player's inventory is to be cleared when joining the server. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isClearInventory(); + + /** + * Sets whether the player's inventory is to be cleared when joining the server. + * + * @param clearInventory If the inventory is to be cleared + */ + void setClearInventory(boolean clearInventory); + + /** + * Gets whether the interaction with blocks is disabled. + * + * @return {@code true} if disabled, otherwise {@code false} + */ + boolean isDisableInteract(); + + /** + * Sets whether the interaction with blocks should be disabled. + * + * @param disableInteract If the interaction with blocks is to be disabled + */ + void setDisableInteract(boolean disableInteract); + + /** + * Gets whether all online players are to be hidden. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isHidePlayers(); + + /** + * Sets whether all online players are to be hidden. + * + * @param hidePlayers If the players are to be hidden + */ + void setHidePlayers(boolean hidePlayers); + + /** + * Gets whether signs should be placed without opening the text input. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isInstantPlaceSigns(); + + /** + * Sets whether signs should be placed without opening the text input. + * + * @param instantPlaceSigns If signs are to be placed instantly + */ + void setInstantPlaceSigns(boolean instantPlaceSigns); + + /** + * Gets whether the navigator is kept in the player's inventory after a clear. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isKeepNavigator(); + + /** + * Sets whether the navigator is kept in the player's inventory after a clear. + * + * @param keepNavigator If the navigator is to kept + */ + void setKeepNavigator(boolean keepNavigator); + + /** + * Gets whether the player has permanent {@link PotionEffectType#NIGHT_VISION}. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isNightVision(); + + /** + * Sets whether the player has permanent {@link PotionEffectType#NIGHT_VISION}. + * + * @param nightVision If the night vision is to be enabled + */ + void setNightVision(boolean nightVision); + + /** + * Gets whether fling against a wall puts the player in {@link GameMode#SPECTATOR}. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isNoClip(); + + /** + * Sets whether fling against a wall puts the player in {@link GameMode#SPECTATOR}. + * + * @param noClip If no-clip is to be enabled + */ + void setNoClip(boolean noClip); + + /** + * Gets whether plants can be placed anywhere. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isPlacePlants(); + + /** + * Sets whether plants can be placed anywhere. + * + * @param placePlants If plants are to be placed anywhere + */ + void setPlacePlants(boolean placePlants); + + /** + * Gets whether the scoreboard is enabled. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isScoreboard(); + + /** + * Sets whether the scoreboard is enabled. + * + * @param scoreboard If the scoreboard is to be enabled + */ + void setScoreboard(boolean scoreboard); + + /** + * Gets whether only one half of a slab will be broken when breaking double slabs. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isSlabBreaking(); + + /** + * Sets whether only one half of a slab will be broken when breaking double slabs. + * + * @param slabBreaking If precise slab breaking is to be enabled + */ + void setSlabBreaking(boolean slabBreaking); + + /** + * Gets whether the player will be teleported to the spawn, if set, when joining the server. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isSpawnTeleport(); + + /** + * Sets whether the player will be teleported to the spawn, if set, when joining the server. + * + * @param spawnTeleport If the player is to be teleported to the spawn + */ + void setSpawnTeleport(boolean spawnTeleport); + + /** + * Gets whether right-clicking iron (trap-)doors will be open/close them. + * + * @return {@code true} if enabled, otherwise {@code false} + */ + boolean isOpenTrapDoors(); + + /** + * Sets whether right-clicking iron (trap-)doors will be open/close them. + * + * @param openTrapDoors If the iron (trap-)doors are to be opened/closed via right-click + */ + void setOpenTrapDoors(boolean openTrapDoors); + + /** + * Gets the {@link BukkitTask} responsible for updating the player's scoreboard. + * + * @return The scoreboard task + */ + @Nullable + @Internal + BukkitTask getScoreboardTask(); + + /** + * Sets the {@link BukkitTask} responsible for updating the player's scoreboard. + * + * @param scoreboardTask The scoreboard task + */ + @Internal + void setScoreboardTask(@Nullable BukkitTask scoreboardTask); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/package-info.java new file mode 100644 index 0000000..9ebe205 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/player/settings/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces and enumerations for player-specific settings within the BuildSystem API. This includes customizable options like design colors and various gameplay + * preferences. + */ +package de.eintosti.buildsystem.api.player.settings; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/FolderStorage.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/FolderStorage.java new file mode 100644 index 0000000..e31cb3c --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/FolderStorage.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.storage; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import java.util.Collection; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Interface for managing the storage of {@link Folder} objects. + * + * @since 3.0.0 + */ +@NullMarked +public interface FolderStorage extends Storage { + + /** + * Gets a list of all {@link Folder}s. + * + * @return An unmodifiable list of all folders + */ + @Unmodifiable + Collection getFolders(); + + /** + * Gets a {@link Folder} by its name (case-insensitive). + * + * @param name The name of the folder to retrieve + * @return The folder if it exists, or {@code null} if it does not + */ + @Nullable + Folder getFolder(String name); + + /** + * Checks if a {@link Folder} with the given name (case-insensitive) exists. + * + * @param name The name of the folder to check + * @return {@code true} if the folder exists, {@code false} otherwise + */ + boolean folderExists(String name); + + /** + * Creates a new {@link Folder} with the given name. + * + * @param name The name folder to create + * @param category The category in which the folder should be displayed + * @param creator The builder who created the folder + * @return The newly created folder + */ + Folder createFolder(String name, NavigatorCategory category, Builder creator); + + /** + * Creates a new nested {@link Folder} with the given name. + * + * @param name The name folder to create + * @param category The category in which the folder should be displayed + * @param parent The parent folder, or {@code null} if this is a top-level folder + * @param creator The builder who created the folder + * @return The newly created folder + */ + Folder createFolder(String name, NavigatorCategory category, @Nullable Folder parent, Builder creator); + + /** + * Removes the {@link Folder} with the given name. + *

    + * This operation cascades: + *

      + *
    • All subfolders within the specified folder will also be removed.
    • + *
    • Any {@link BuildWorld} instances associated with this folder will have their folder reference unset.
    • + *
    + * + * @param name The name of the folder to remove + * @see #removeFolder(Folder) + */ + void removeFolder(String name); + + /** + * Removes the given {@link Folder}. + *

    + * This operation cascades: + *

      + *
    • All subfolders within the specified folder will also be removed.
    • + *
    • Any {@link BuildWorld} instances associated with this folder will have their folder reference unset.
    • + *
    + * + * @param folder The folder to remove + * @see #removeFolder(String) + */ + void removeFolder(Folder folder); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/PlayerStorage.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/PlayerStorage.java new file mode 100644 index 0000000..01a732b --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/PlayerStorage.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.storage; + +import de.eintosti.buildsystem.api.player.BuildPlayer; +import java.util.Collection; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Interface for managing the storage of {@link BuildPlayer} objects. + * + * @since 3.0.0 + */ +@NullMarked +public interface PlayerStorage extends Storage { + + /** + * Creates a new {@link BuildPlayer} with the given uuid and settings. + * + * @param uuid The uuid of the player + * @return The created build-player + */ + BuildPlayer createBuildPlayer(UUID uuid); + + /** + * Creates a new {@link BuildPlayer} with the given player. + * + * @param player The player + * @return The created build-player + */ + BuildPlayer createBuildPlayer(Player player); + + /** + * Gets the {@link BuildPlayer} whose unique-id matches the given uuid. + * + * @param uuid The uuid of the player + * @return The player, if found, otherwise {@code null} + */ + @Nullable + BuildPlayer getBuildPlayer(UUID uuid); + + /** + * Gets the {@link BuildPlayer} which wraps the given player. + *

    + * If the player is not found, a new {@link BuildPlayer} will be created using {@link #createBuildPlayer(Player)}. + * + * @param player The wrapped player + * @return The player + * @see #createBuildPlayer(Player) + */ + BuildPlayer getBuildPlayer(Player player); + + /** + * Gets a collection of all {@link BuildPlayer}s. + * + * @return A collection of all build-players. + */ + @Unmodifiable + Collection getBuildPlayers(); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/Storage.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/Storage.java new file mode 100644 index 0000000..3921c72 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/Storage.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.storage; + +import java.util.Collection; +import java.util.concurrent.CompletableFuture; +import org.jspecify.annotations.NullMarked; + +/** + * A generic interface for storage operations. + * + * @param The type of objects to be stored + * @since 3.0.0 + */ +@NullMarked +public interface Storage { + + /** + * Saves the given object to the storage. + * + * @param object The object to save + * @return A {@link CompletableFuture} that completes when the save operation is done + */ + CompletableFuture save(T object); + + /** + * Saves all the given objects to the storage. + * + * @param objects The objects to save + * @return A {@link CompletableFuture} that completes when the save operation is done + */ + CompletableFuture save(Collection objects); + + /** + * Loads all objects from the storage. + * + * @return A {@link CompletableFuture} that completes with a collection of loaded objects + */ + CompletableFuture> load(); + + /** + * Deletes the given object from the storage. + * + * @param object The object to delete + * @return A {@link CompletableFuture} that completes when the deletion finishes + */ + CompletableFuture delete(T object); + + /** + * Deletes the object with the given key from the storage. + * + * @param key The key of the object to delete + * @return A {@link CompletableFuture} that completes when the deletion finishes + */ + CompletableFuture delete(String key); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/WorldStorage.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/WorldStorage.java new file mode 100644 index 0000000..2f9eb3e --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/storage/WorldStorage.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.storage; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.Visibility; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Interface for managing the storage of {@link BuildWorld} objects. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldStorage extends Storage { + + /** + * Gets the {@link BuildWorld} by the given name. + * + * @param name The name of the world + * @return The world object if one was found, {@code null} otherwise + */ + @Nullable + BuildWorld getBuildWorld(String name); + + /** + * Gets the {@link BuildWorld} by the given {@link World}. + * + * @param world The bukkit world object + * @return The world object if one was found, {@code null} otherwise + */ + @Nullable + BuildWorld getBuildWorld(World world); + + /** + * Gets the {@link BuildWorld} by the given {@link UUID}. + * + * @param uuid The build world's unique identifier + * @return The world object if one was found, {@code null} otherwise + */ + @Nullable + BuildWorld getBuildWorld(UUID uuid); + + /** + * Gets a list of all {@link BuildWorld}s. + * + * @return An unmodifiable list of all worlds + */ + @Unmodifiable + Collection getBuildWorlds(); + + /** + * Gets a list of {@link BuildWorld}s created by the given player. + * + * @param player The player who created the worlds + * @return A list of worlds created by the player + */ + @Unmodifiable + List getBuildWorldsCreatedByPlayer(Player player); + + /** + * Gets a list of {@link BuildWorld}s created by the given player with the given visibility. + * + * @param player The player who created the worlds + * @param visibility The visibility of the worlds + * @return A list of worlds created by the player with the given visibility + */ + @Unmodifiable + List getBuildWorldsCreatedByPlayer(Player player, Visibility visibility); + + /** + * Checks if a {@link BuildWorld} with the given name (case-insensitive) exists. + * + * @param worldName The name of the world to check + * @return {@code true} if the world exists, {@code false} otherwise + */ + boolean worldExists(String worldName); + + /** + * Checks if a {@link BuildWorld} exists and if the world folder exists on disk. + * + * @param worldName The name of the world to check + * @return {@code true} if the world exists in the map or on disk, {@code false} otherwise + */ + boolean worldAndFolderExist(String worldName); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/BuildWorld.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/BuildWorld.java new file mode 100644 index 0000000..5564e91 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/BuildWorld.java @@ -0,0 +1,188 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world; + +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.creation.generator.CustomGenerator; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.api.world.display.Displayable; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.util.WorldLoader; +import de.eintosti.buildsystem.api.world.util.WorldPermissions; +import de.eintosti.buildsystem.api.world.util.WorldTeleporter; +import de.eintosti.buildsystem.api.world.util.WorldUnloader; +import java.util.UUID; +import org.bukkit.Difficulty; +import org.bukkit.Material; +import org.bukkit.World; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents a world managed by the BuildSystem plugin, extending the {@link Displayable} interface. This interface provides comprehensive access to world-specific properties, + * data, and utility methods. + * + * @since 3.0.0 + */ +@NullMarked +public interface BuildWorld extends Displayable { + + /** + * Gets the Bukkit {@link World} associated with this {@link BuildWorld}. + * + * @return The Bukkit world, or {@code null} if not loaded + */ + @Nullable + World getWorld(); + + /** + * Gets the unique identifier of this world. + *

    + * Not equivalent to {@link World#getUID()}. + * + * @return The uuid of this world + */ + UUID getUniqueId(); + + /** + * Sets the name of this world. + * + * @param name The name of the world + */ + void setName(String name); + + /** + * Gets the {@link Profileable} representation of this build world which is applied when {@link WorldData#material()} is set to {@link Material#PLAYER_HEAD}. + * + * @return The {@link Profileable} representation of this build world + */ + Profileable asProfilable(); + + /** + * Gets this world's {@link BuildWorldType}. + * + * @return The type of this world + */ + BuildWorldType getType(); + + /** + * Gets this world's {@link WorldData}. + * + * @return The data of the world + */ + WorldData getData(); + + /** + * Gets the custom chunk generator used to generate this world. + *

    + * Only set when the world type is {@link BuildWorldType#CUSTOM} or {@link BuildWorldType#IMPORTED}. + * + * @return The custom chunk generator used to generate this world, or {@code null} if not set + */ + @Nullable + CustomGenerator getCustomGenerator(); + + /** + * Cycles to the next {@link Difficulty} for this world. + *

    + * The cycle order is: {@link Difficulty#PEACEFUL} -> {@link Difficulty#EASY} -> {@link Difficulty#NORMAL} -> {@link Difficulty#HARD} -> {@link Difficulty#PEACEFUL}. + * + * @return The new difficulty after cycling + */ + Difficulty cycleDifficulty(); + + /** + * Gets the {@link Builders} object, which manages all players allowed to modify this world. + * + * @return The {@link Builders} instance for this world + */ + Builders getBuilders(); + + /** + * Gets the time of day in the {@link World} linked to this build world as a formatted string. + * + * @return This world time as a string (e.g., "Day", "Night") + */ + String getWorldTime(); + + /** + * Gets whether this world is currently loaded into server memory, allowing players to enter it. + * + * @return {@code true} if this world is loaded, otherwise {@code false} + */ + boolean isLoaded(); + + /** + * Sets whether this world is currently loaded into server memory. + * + * @param loaded {@code true} if this world is to be loaded, {@code false} if it should be unloaded + */ + void setLoaded(boolean loaded); + + /** + * Gets the {@link WorldLoader} utility used to manage loading operations for this world. + * + * @return The {@link WorldLoader} instance + */ + WorldLoader getLoader(); + + /** + * Gets the {@link WorldUnloader} utility used to manage unloading operations for this world. + * + * @return The {@link WorldUnloader} instance + */ + WorldUnloader getUnloader(); + + /** + * Gets the {@link WorldTeleporter} utility used to manage teleportation of players to this world. + * + * @return The {@link WorldTeleporter} instance + */ + WorldTeleporter getTeleporter(); + + /** + * Gets the {@link WorldPermissions} associated with this world, which define access and modification rules. + * + * @return The {@link WorldPermissions} instance for this world + */ + WorldPermissions getPermissions(); + + /** + * Gets the {@link Folder} this world is assigned to. + * + * @return The folder this world is assigned to, or {@code null} if not assigned + */ + @Nullable + Folder getFolder(); + + /** + * Checks whether this world is assigned to a {@link Folder}. + * + * @return {@code true} if this world is in any folder, {@code false} otherwise + */ + boolean isAssignedToFolder(); + + /** + * Sets the {@link Folder} this world is assigned to. + * + * @param folder The folder to assign this world to, or {@code null} to remove the assignment + */ + void setFolder(@Nullable Folder folder); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/WorldService.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/WorldService.java new file mode 100644 index 0000000..6a6f1aa --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/WorldService.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world; + +import de.eintosti.buildsystem.api.storage.FolderStorage; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.creation.BuildWorldCreator; +import de.eintosti.buildsystem.api.world.display.Folder; +import java.util.concurrent.CompletableFuture; +import org.jspecify.annotations.NullMarked; + +/** + * Provides a service for managing world-related operations and data. This interface offers methods to access and interact with world storage and folder management. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldService { + + /** + * Gets the {@link FolderStorage} implementation for managing {@link Folder} persistence. + * + * @return The folder storage instance + */ + FolderStorage getFolderStorage(); + + /** + * Gets the {@link WorldStorage} implementation for managing {@link BuildWorld} persistence. + * + * @return The world storage instance + */ + WorldStorage getWorldStorage(); + + /** + * Creates a new {@link BuildWorldCreator} for the given name. + * + * @param name The name of the world to create + * @return A new {@link BuildWorldCreator} instance for the specified world name + */ + BuildWorldCreator createWorld(String name); + + /** + * Unimport an existing {@link BuildWorld}. In comparison to {@link #deleteWorld(BuildWorld)}, unimporting a world does not delete the world's directory. + * + * @param buildWorld The world to unimport + * @param save Whether to save the world before unloading + * @return A future that completes when the unimport operation is finished + */ + CompletableFuture unimportWorld(BuildWorld buildWorld, boolean save); + + /** + * Delete an existing {@link BuildWorld}. In comparison to {@link #unimportWorld(BuildWorld, boolean)}, deleting a world deletes the world's directory. + * + * @param buildWorld The world to be deleted + * @return A future that completes when the delete operation is finished + */ + CompletableFuture deleteWorld(BuildWorld buildWorld); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/Backup.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/Backup.java new file mode 100644 index 0000000..d9ea295 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/Backup.java @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.backup; + +import org.jspecify.annotations.NullMarked; + +/** + * Represents a single backup of a {@link de.eintosti.buildsystem.api.world.BuildWorld}. + */ +@NullMarked +public interface Backup { + + /** + * Returns the {@link BackupProfile} that owns this backup. + * + * @return The owner of the backup. + */ + BackupProfile owner(); + + /** + * Returns the timestamp when this backup was created. + * + * @return The creation time in milliseconds since the Unix epoch. + */ + long creationTime(); + + /** + * Returns a unique key or identifier for this backup. + * + * @return The key of the backup. + */ + String key(); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupProfile.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupProfile.java new file mode 100644 index 0000000..eef8e2f --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupProfile.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2023-2025, Thomas Meaney + * All rights reserved. + * + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + */ +package de.eintosti.buildsystem.api.world.backup; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +/** + * Represents a profile for managing backups of a specific {@link BuildWorld}. This interface defines operations related to listing, creating, restoring, and destroying backups. + */ +@NullMarked +public interface BackupProfile { + + /** + * Asynchronously populates a list of available {@link Backup}s under this profile. + * + * @return Future that will be completed with available backups + */ + CompletableFuture> listBackups(); + + /** + * Creates a backup of the {@link BuildWorld}. If the profile is at the maximum backup capacity, the oldest backup will be deleted. + * + * @return Future that completes with the created backup. + */ + CompletableFuture createBackup(); + + /** + * Restores a {@link Backup}. + * + * @param backup Backup to restore + * @param player The player restoring the backup + */ + void restoreBackup(Backup backup, Player player); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupStorage.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupStorage.java new file mode 100644 index 0000000..aabaf63 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/BackupStorage.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.backup; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.io.File; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.jspecify.annotations.NullMarked; + +/** + * Represents a storage mechanism for managing world backups. + */ +@NullMarked +public interface BackupStorage { + + /** + * Generates a unique backup name based on a given timestamp. + * + * @param timestamp The timestamp to use for the backup name. + * @return A string representing the backup name (e.g., "1678886400000.zip"). + */ + default String getBackupName(long timestamp) { + return timestamp + ".zip"; + } + + /** + * Lists all available {@link Backup}s for a specific {@link BuildWorld}. + * + * @param buildWorld The world for which to list backups + * @return A future with a list of backup objects associated with the specified world + */ + CompletableFuture> listBackups(BuildWorld buildWorld); + + /** + * Creates and stores a new {@link Backup} for a given {@link BuildWorld}. The result of the operation is communicated via the provided {@link CompletableFuture}. + *

    + * In comparison to {@link BackupProfile#createBackup()}, a backup will always be created and no older backups will be deleted. This method is intended for immediate backup + * creation and storage, rather than profile management. + * + * @param buildWorld The world to be backed up + * @return A future that will be completed with the backup object upon successful storage, or exceptionally if an error occurs + */ + CompletableFuture storeBackup(BuildWorld buildWorld); + + /** + * Downloads a specific {@link Backup} file asynchronously. + * + * @param backup The backup object representing the backup to be downloaded + * @return A future that will complete with a {@link File} object pointing to the downloaded backup once the download operation is finished + */ + CompletableFuture downloadBackup(Backup backup); + + /** + * Deletes a specific {@link Backup}. + * + * @param backup The backup object representing the backup to be deleted + * @return A future that will complete after the deletion + */ + CompletableFuture deleteBackup(Backup backup); + + /** + * Closes the backup storage, releasing any resources. + */ + void close(); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/package-info.java new file mode 100644 index 0000000..cafd7c5 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/backup/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces and classes for managing world backups. + */ +package de.eintosti.buildsystem.api.world.backup; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builder.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builder.java new file mode 100644 index 0000000..92258b6 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builder.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.builder; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * A {@link Builder} represents a player allowed to build in a {@link BuildWorld}. + * + * @since 3.0.0 + */ +@NullMarked +public sealed interface Builder permits BuilderImpl { + + /** + * Creates a new {@link Builder} instance with the given uuid and name. + * + * @param uuid The uuid + * @param name The name + * @return The builder + */ + @Contract("_, _ -> new") + static Builder of(UUID uuid, String name) { + return new BuilderImpl(uuid, name); + } + + /** + * Creates a new {@link Builder} instance using the given player. + * + * @param player The player + * @return The builder + */ + @Contract("_ -> new") + static Builder of(Player player) { + return of(player.getUniqueId(), player.getName()); + } + + /** + * Creates a new {@link Builder} instance using a serialized string. + *

    + * The format of the string must be {@code ,}. + * + * @param serialized The serialized builder + * @return The builder if all the input is valid, otherwise {@code null} + */ + @Nullable + static Builder deserialize(@Nullable String serialized) { + if (serialized == null || serialized.equals("-")) { + return null; + } + + String[] parts = serialized.split(BuilderImpl.SEPARATOR); + if (parts.length != 2) { + return null; + } + + return of(UUID.fromString(parts[0]), parts[1]); + } + + /** + * Returns a unique and persistent id for the builder. + *

    + * Should be equal to the corresponding {@link Player}'s unique id. + * + * @return The uuid + * @see Player#getUniqueId() + */ + UUID getUniqueId(); + + /** + * Gets the name of the builder. + * + * @return The builder name + */ + String getName(); + + /** + * Sets the name of the builder. + * + * @param name The name to change to + */ + void setName(String name); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/BuilderImpl.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/BuilderImpl.java new file mode 100644 index 0000000..fbfdc27 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/BuilderImpl.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.builder; + +import java.util.UUID; +import org.jspecify.annotations.NullMarked; + +/** + * Concrete implementation of the {@link Builder} interface. + * + * @since 3.0.0 + */ +@NullMarked +final class BuilderImpl implements Builder { + + static final String SEPARATOR = ","; + + private final UUID uuid; + private String name; + + /** + * Constructs a new {@link BuilderImpl} with the given unique ID and name. + * + * @param uuid The unique ID of the builder + * @param name The name of the builder + */ + BuilderImpl(UUID uuid, String name) { + this.uuid = uuid; + this.name = name; + } + + @Override + public UUID getUniqueId() { + return uuid; + } + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public String toString() { + return this.uuid + SEPARATOR + this.name; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builders.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builders.java new file mode 100644 index 0000000..218ba95 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/Builders.java @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.builder; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.util.Collection; +import java.util.List; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Interface for managing builders in a {@link BuildWorld}. + * + * @since 3.0.0 + */ +@NullMarked +public interface Builders { + + /** + * Checks if the world has a creator. + * + * @return {@code true} if the world has a creator, {@code false} otherwise + */ + boolean hasCreator(); + + /** + * Gets the creator of the world. + * + * @return The creator of the world, or {@code null} if there is none + */ + @Nullable + Builder getCreator(); + + /** + * Sets the creator of the world. + * + * @param creator The new creator + */ + void setCreator(@Nullable Builder creator); + + /** + * Checks if the given player is the creator of the world. + * + * @param player The player to check + * @return {@code true} if the player is the creator, {@code false} otherwise + */ + boolean isCreator(Player player); + + /** + * Gets an unmodifiable list of all builders. + * + * @return List of builders + */ + Collection getAllBuilders(); + + /** + * Gets a builder by their UUID. + * + * @param uuid The UUID to search for + * @return The builder if found, {@code null} otherwise + */ + @Nullable + Builder getBuilder(UUID uuid); + + /** + * Get an unmodifiable list of all {@link Builder} names + * + * @return A list of all builder names + */ + @Unmodifiable + List getBuilderNames(); + + /** + * Checks if a player is a builder. + * + * @param player The player to check + * @return {@code true} if the player is a builder, {@code false} otherwise + */ + boolean isBuilder(Player player); + + /** + * Checks if a UUID belongs to a builder. + * + * @param uuid The UUID to check + * @return {@code true} if the given UUID belongs to a builder, {@code false} otherwise + */ + boolean isBuilder(UUID uuid); + + /** + * Adds a builder to the world. + * + * @param builder The builder to add + */ + void addBuilder(Builder builder); + + /** + * Removes a builder from the world. + * + * @param builder The builder to remove + */ + void removeBuilder(Builder builder); + + /** + * Removes a builder by their UUID. + * + * @param uuid The UUID of the builder to remove + */ + void removeBuilder(UUID uuid); + + /** + * Formats the list of builders for the {@code %builder%} placeholder. + * + * @param player The player to display the placeholders to + * @return The list of builders which have been added to the given world as a string + */ + String asPlaceholder(Player player); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/package-info.java new file mode 100644 index 0000000..26c9a35 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/builder/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces and classes for managing world builders and their associated permissions. + */ +package de.eintosti.buildsystem.api.world.builder; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/BuildWorldCreator.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/BuildWorldCreator.java new file mode 100644 index 0000000..10be0a3 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/BuildWorldCreator.java @@ -0,0 +1,154 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.creation; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.creation.generator.CustomGenerator; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.display.Folder; +import org.bukkit.Difficulty; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.generator.ChunkGenerator; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents a creator for a {@link BuildWorld}. + * + * @since 3.0.0 + */ +@NullMarked +public interface BuildWorldCreator { + + /** + * Sets the name of the world. + * + * @param name The world name + * @return The world creator object + */ + BuildWorldCreator setName(String name); + + /** + * Sets the creator of the world. + * + * @param creator The creator, may be {@code null} + * @return The world creator object + */ + BuildWorldCreator setCreator(@Nullable Builder creator); + + /** + * Sets the template which the world should be copied from. + *

    + * Only used if the world type is {@link BuildWorldType#TEMPLATE} + * + * @param template The template name, may be {@code null} if no template is used + * @return The creator object + */ + BuildWorldCreator setTemplate(@Nullable String template); + + /** + * Sets the type of the world. + * + * @param type The world type + * @return The world creator object + */ + BuildWorldCreator setType(BuildWorldType type); + + /** + * Sets the custom {@link ChunkGenerator} of the world. + * + * @param customGenerator The custom chunk generator + * @return The world creator object + */ + BuildWorldCreator setCustomGenerator(CustomGenerator customGenerator); + + /** + * Sets the folder in which the world should be created. + * + * @param folder The folder where the world should be created, may be {@code null} if not to be added to a folder + * @return The world creator object + */ + BuildWorldCreator setFolder(@Nullable Folder folder); + + /** + * Sets whether the world should be private or not. + * + * @param privateWorld Whether the world should be private + * @return The world creator object + */ + BuildWorldCreator setPrivate(boolean privateWorld); + + /** + * Sets the difficulty of the world. + * + * @param difficulty The difficulty + * @return The world creator object + */ + BuildWorldCreator setDifficulty(Difficulty difficulty); + + /** + * Sets the creation date of the world. + * + * @param creationDate The creation date in milliseconds since epoch + * @return The world creator object + */ + BuildWorldCreator setCreationDate(long creationDate); + + /** + * Creates and generates a new {@link BuildWorld} using the settings configured in this builder. + *

    + * This process includes creating the world files, registering the world with the plugin, and notifying the player of the progress. + * + * @param player The player who is creating the world + */ + void createWorld(Player player); + + /** + * Imports an existing world directory as a new {@link BuildWorld}. + * + * @param player The player who is importing the world + * @param teleport If true, the player will be teleported to the world after the import is finished + */ + void importWorld(Player player, boolean teleport); + + /** + * Generates the underlying Bukkit {@link World} and applies post-generation settings. Only generates the world if the world was not created in a newer Minecraft version that + * the server is running. + *

    + * Important: This method should only be called after the world has been created and registered with the plugin. + * + * @return The generated {@link World}, or {@code null} if generation failed + */ + @Nullable + default World generateBukkitWorld() { + return generateBukkitWorld(true); + } + + /** + * Generates the underlying Bukkit {@link World} and applies post-generation settings. + *

    + * Important: This method should only be called after the world has been created and registered with the plugin. + * + * @param checkVersion If true, verify that the world's data version is compatible + * @return The generated {@link World}, or {@code null} if generation failed + */ + @Nullable + World generateBukkitWorld(boolean checkVersion); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/CustomGenerator.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/CustomGenerator.java new file mode 100644 index 0000000..fa5d56a --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/CustomGenerator.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.creation.generator; + +import org.bukkit.World; +import org.bukkit.generator.ChunkGenerator; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents a custom chunk generator for a {@link World} which is provided by an external plugin. + * + * @since 3.0.0 + */ +@NullMarked +public interface CustomGenerator { + + /** + * Gets the name plugin providing the chunk generator. + * + * @return The name of the plugin + */ + String pluginName(); + + /** + * Gets the name of the chunk generator. + * + * @return The name of the chunk generator + */ + String chunkGeneratorName(); + + /** + * Gets the Bukkit {@link ChunkGenerator} within the providing plugin ({@link #pluginName()}:{@link #chunkGeneratorName()}). + * + * @return The chunk generator instance, or {@code null} if it could not be loaded or has not been not set + */ + @Nullable + ChunkGenerator chunkGenerator(); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/Generator.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/Generator.java new file mode 100644 index 0000000..69662c8 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/Generator.java @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.creation.generator; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.jspecify.annotations.NullMarked; + +/** + * Different kinds of world generators that can be used for a {@link BuildWorld}. + * + * @since 3.0.0 + */ +@NullMarked +public enum Generator { + + /** + * A normal world + */ + NORMAL, + + /** + * A flat world + */ + FLAT, + + /** + * A void world + */ + VOID, + + /** + * A custom world + */ + CUSTOM +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/package-info.java new file mode 100644 index 0000000..3aa9eb2 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/generator/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Classes relevant to world generation. + */ +package de.eintosti.buildsystem.api.world.creation.generator; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/package-info.java new file mode 100644 index 0000000..5537d17 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/creation/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Classes relevant to world generation. + */ +package de.eintosti.buildsystem.api.world.creation; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldStatus.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldStatus.java new file mode 100644 index 0000000..abf5f94 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldStatus.java @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.data; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.util.Locale; +import org.jspecify.annotations.NullMarked; + +/** + * Represents the various building statuses a {@link BuildWorld} can have. These statuses indicate the progression and accessibility of a world. + * + * @since 3.0.0 + */ +@NullMarked +public enum BuildWorldStatus { + + /** + * Represents a {@link BuildWorld} that has not yet been started or modified. This is typically the initial state for newly created worlds. + */ + NOT_STARTED(1), + + /** + * Represents a {@link BuildWorld} that is currently under construction. This status is automatically assigned when a block is placed or broken in the world. + */ + IN_PROGRESS(2), + + /** + * Represents a {@link BuildWorld} that is nearing completion. + */ + ALMOST_FINISHED(3), + + /** + * Represents a {@link BuildWorld} whose building phase has been completed. + */ + FINISHED(4), + + /** + * Represents an older {@link BuildWorld} that has been completed and is now archived. Blocks typically cannot be placed or broken in archived worlds. + */ + ARCHIVE(5), + + /** + * Represents a {@link BuildWorld} that is hidden from public view in the navigator. + */ + HIDDEN(6); + + private final int stage; + + BuildWorldStatus(int stage) { + this.stage = stage; + } + + /** + * Gets the permission required to change a world to this status. + * + * @return The permission string (e.g., "buildsystem.setstatus.notstarted") + */ + public String getPermission() { + return "buildsystem.setstatus." + name().toLowerCase(Locale.ROOT).replace("_", ""); + } + + /** + * Gets the development stage of the {@link BuildWorld}. A higher numerical value indicates a further developed or completed world. + * + * @return The integer representing the stage of development + */ + public int getStage() { + return stage; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldType.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldType.java new file mode 100644 index 0000000..6f4a17e --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/BuildWorldType.java @@ -0,0 +1,83 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.data; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.World.Environment; +import org.bukkit.generator.ChunkGenerator; +import org.jspecify.annotations.NullMarked; + +/** + * Represents the different types of {@link BuildWorld}s that can be created or managed by the BuildSystem plugin. Each type specifies unique characteristics for world generation + * and behavior. + * + * @since 3.0.0 + */ +@NullMarked +public enum BuildWorldType { + + /** + * A standard world type, equivalent to a default Minecraft overworld with {@link Environment#NORMAL}. + */ + NORMAL, + + /** + * A super-flat world, ideal for creative building without terrain obstacles. + */ + FLAT, + + /** + * A world type representing the Nether dimension, with {@link Environment#NETHER}. + */ + NETHER, + + /** + * A world type representing the End dimension, with {@link Environment#THE_END}. + */ + END, + + /** + * An empty world, containing no blocks except for a single platform at spawn. + */ + VOID, + + /** + * A world created as an identical copy of an existing template world. + */ + TEMPLATE, + + /** + * A world that, by default, can only be modified by its creator. + */ + PRIVATE, + + /** + * A world that was not originally created by the BuildSystem plugin but has been imported for management. + */ + IMPORTED, + + /** + * A world generated using a custom {@link ChunkGenerator}. + */ + CUSTOM, + + /** + * A world whose type could not be determined or is not recognized by the BuildSystem. + */ + UNKNOWN +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/Visibility.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/Visibility.java new file mode 100644 index 0000000..5c1dd4a --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/Visibility.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.data; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.jspecify.annotations.NullMarked; + +/** + * Defines the visibility settings for a {@link BuildWorld} within the BuildSystem. These settings determine how worlds are displayed and accessed in the world navigator. + * + * @since 3.0.0 + */ +@NullMarked +public enum Visibility { + + /** + * Indicates that a world is publicly accessible and displayed in the main world navigator. + */ + PUBLIC, + + /** + * Indicates that a world is private, typically only visible and accessible to its creator and designated builders. Private worlds are usually displayed in a separate, + * dedicated menu. + */ + PRIVATE, + + /** + * A special state indicating that the visibility setting of a world should be disregarded. This is useful for internal operations or specific contexts where visibility rules + * do not apply. + */ + IGNORE; + + /** + * Returns the appropriate {@link Visibility} enum based on whether a world is private. + * + * @param isPrivateWorld A boolean indicating if the world is private + * @return {@link #PRIVATE} if {@link WorldData#privateWorld()} is true, otherwise {@link #PUBLIC} + */ + public static Visibility matchVisibility(boolean isPrivateWorld) { + return isPrivateWorld ? PRIVATE : PUBLIC; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/WorldData.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/WorldData.java new file mode 100644 index 0000000..2aa07d4 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/WorldData.java @@ -0,0 +1,180 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.data; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.api.data.Type; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.backup.Backup; +import java.util.Map; +import org.bukkit.Difficulty; +import org.bukkit.Location; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Manages and provides access to various data points and settings for a {@link BuildWorld}. This interface allows for reading and modifying world-specific configurations. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldData { + + /** + * Retrieves a {@link Type} object representing the custom spawn location of the {@link BuildWorld}. The value is stored as a string in the format {@code x;y;z;yaw;pitch}. + * + * @return A {@link Type} containing the custom spawn string + * @see #getCustomSpawnLocation() + */ + Type customSpawn(); + + /** + * Gets the {@link BuildWorld}'s custom spawn as a {@link Location} object. + * + * @return The custom spawn as a location, or {@code null} if not set or invalid + */ + @Nullable + Location getCustomSpawnLocation(); + + /** + * Retrieves a {@link Type} object representing the permission required to enter the {@link BuildWorld}. Returns "-" if no specific permission is required. + * + * @return A {@link Type} containing the permission string + */ + Type permission(); + + /** + * Retrieves a {@link Type} object representing the project description of the {@link BuildWorld}. This typically provides a brief overview or purpose of the world. + * + * @return A {@link Type} containing the project description string + */ + Type project(); + + /** + * Retrieves a {@link Type} object representing the {@link Difficulty} of the {@link BuildWorld}. + * + * @return A {@link Type} containing the world's difficulty setting + */ + Type difficulty(); + + /** + * Retrieves a {@link Type} object representing the {@link XMaterial} used to display the {@link BuildWorld} in the navigator menus. + * + * @return A {@link Type} containing the material used for display + */ + Type material(); + + /** + * Retrieves a {@link Type} object representing the current {@link BuildWorldStatus} of the world. This indicates the building progression or state of the world. + * + * @return A {@link Type} containing the current build status + */ + Type status(); + + /** + * Retrieves a {@link Type} object indicating whether block breaking is allowed in the {@link BuildWorld}. + * + * @return A {@link Type} containing a boolean: {@code true} if allowed, otherwise {@code false} + */ + Type blockBreaking(); + + /** + * Retrieves a {@link Type} object indicating whether block interactions (e.g., opening doors, chests) are enabled in the {@link BuildWorld}. + * + * @return A {@link Type} containing a boolean: {@code true} if enabled, otherwise {@code false} + */ + Type blockInteractions(); + + /** + * Retrieves a {@link Type} object indicating whether block placement is allowed in the {@link BuildWorld}. + * + * @return A {@link Type} containing a boolean: {@code true} if allowed, otherwise {@code false} + */ + Type blockPlacement(); + + /** + * Retrieves a {@link Type} object indicating whether the "builders feature" is enabled in the {@link BuildWorld}. When enabled, only designated builders can modify the world. + * + * @return A {@link Type} containing a boolean: {@code true} if enabled, otherwise {@code false} + */ + Type buildersEnabled(); + + /** + * Retrieves a {@link Type} object indicating whether explosions are enabled in the {@link BuildWorld}. + * + * @return A {@link Type} containing a boolean: {@code true} if enabled, otherwise {@code false} + */ + Type explosions(); + + /** + * Retrieves a {@link Type} object indicating whether entities in the {@link BuildWorld} have artificial intelligence. + * + * @return A {@link Type} containing a boolean: {@code true} if enabled, otherwise {@code false} + */ + Type mobAi(); + + /** + * Retrieves a {@link Type} object indicating whether physics (e.g., gravity, fluid flow) is applied to blocks in the {@link BuildWorld}. + * + * @return A {@link Type} containing a boolean: {@code true} if enabled, otherwise {@code false} + */ + Type physics(); + + /** + * Retrieves a {@link Type} object indicating whether the {@link BuildWorld} is set to private visibility. A private world is typically only accessible to its creator and + * designated builders. + * + * @return A {@link Type} containing a boolean: {@code true} if private, otherwise {@code false} + */ + Type privateWorld(); + + /** + * Gets the number of seconds that have passed since that last {@link Backup} of the {@link BuildWorld} was created. + * + * @return The number of seconds since the last backup + */ + Type timeSinceBackup(); + + /** + * Retrieves a {@link Type} object representing the timestamp (in milliseconds since epoch) of the last time the {@link BuildWorld} was edited. + * + * @return A {@link Type} containing the last edited timestamp + */ + Type lastEdited(); + + /** + * Retrieves a {@link Type} object representing the timestamp (in milliseconds since epoch) of the last time the {@link BuildWorld} was loaded. + * + * @return A {@link Type} containing the last loaded timestamp + */ + Type lastLoaded(); + + /** + * Retrieves a {@link Type} object representing the timestamp (in milliseconds since epoch) of the last time the {@link BuildWorld} was unloaded. + * + * @return A {@link Type} containing the last unloaded timestamp + */ + Type lastUnloaded(); + + /** + * Gets a map of all configurable data points for the {@link BuildWorld}. + * + * @return An unmodifiable map where keys are data point names and values are their corresponding {@link Type} objects + */ + Map> getAllData(); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/package-info.java new file mode 100644 index 0000000..e96da9e --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/data/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces for data structures and enumerations related to {@link de.eintosti.buildsystem.api.world.BuildWorld} properties. This includes world status, type, + * visibility, and other configurable data points. + */ +package de.eintosti.buildsystem.api.world.data; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Displayable.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Displayable.java new file mode 100644 index 0000000..7176962 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Displayable.java @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.display; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.util.List; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; +import org.jspecify.annotations.NullMarked; + +/** + * Represents an object that can be displayed in an inventory. + * + * @since 3.0.0 + */ +@NullMarked +public interface Displayable { + + /** + * Gets the unique name of this displayable item. + * + * @return The name + */ + String getName(); + + /** + * Gets the name used to display this item in an inventory. + * + * @param player The player viewing the item + * @return The display name + */ + String getDisplayName(Player player); + + /** + * Gets the creation timestamp of the displayable. + * + * @return The number of milliseconds that have passed since {@code January 1, 1970 UTC}, until the displayable was created. + */ + long getCreation(); + + /** + * Gets the material to display this item with. + * + * @return The material + */ + XMaterial getIcon(); + + /** + * Sets the icon for this displayable item. + * + * @param material The material to set as the icon + */ + void setIcon(XMaterial material); + + /** + * Gets the lore of this displayable item. + * + * @param player The player viewing the item + * @return The lore + */ + List getLore(Player player); + + /** + * Converts this displayable to an {@link ItemStack} for display. + * + * @param player The player viewing the inventory + * @return The ItemStack representation + */ + default ItemStack asItemStack(Player player) { + ItemStack itemStack = getIcon().parseItem(); + if (itemStack == null) { + throw new IllegalStateException("Icon material " + getIcon() + " could not be parsed into an ItemStack."); + } + + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta == null) { + throw new IllegalStateException("ItemMeta for " + getIcon() + " is null. This should not happen."); + } + + itemMeta.setDisplayName(getDisplayName(player)); + itemMeta.setLore(getLore(player)); + itemMeta.addItemFlags(ItemFlag.values()); + + DisplayableType type = switch (this) { + case BuildWorld ignored -> DisplayableType.BUILD_WORLD; + case Folder ignored -> DisplayableType.FOLDER; + default -> throw new IllegalStateException("Unknown displayable type: " + this.getClass().getSimpleName()); + }; + + JavaPlugin plugin = JavaPlugin.getProvidingPlugin(getClass()); + PersistentDataContainer pdc = itemMeta.getPersistentDataContainer(); + pdc.set(new NamespacedKey(plugin, "displayable_type"), PersistentDataType.STRING, type.name()); + pdc.set(new NamespacedKey(plugin, "displayable_name"), PersistentDataType.STRING, getName()); + + itemStack.setItemMeta(itemMeta); + + return itemStack; + } + + /** + * Adds this displayable to an {@link Inventory} at the given slot. + * + * @param inventory The inventory to add the item to + * @param slot The slot in the inventory to add the item + * @param player The player viewing the inventory + */ + default void addToInventory(Inventory inventory, int slot, Player player) { + inventory.setItem(slot, asItemStack(player)); + } + + /** + * Represents the distinct types of items that can be displayed in an inventory within the BuildSystem. + */ + enum DisplayableType { + + /** + * Indicates that the displayable item is a {@link BuildWorld}. + */ + BUILD_WORLD, + + /** + * Indicates that the displayable item is a {@link Folder}. + */ + FOLDER + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Folder.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Folder.java new file mode 100644 index 0000000..191fbc8 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/Folder.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.display; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import java.util.List; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents a folder within the BuildSystem's world navigation structure. Folders can contain {@link BuildWorld}s and other nested folders, organizing them for easier access. + * + * @since 3.0.0 + */ +@NullMarked +public interface Folder extends Displayable { + + /** + * Gets the {@link Builder} who originally created this folder. + * + * @return The {@link Builder} instance representing the folder's creator + */ + Builder getCreator(); + + /** + * Gets the {@link NavigatorCategory} in which this folder is displayed. + * + * @return The {@link NavigatorCategory} of the folder + */ + NavigatorCategory getCategory(); + + /** + * Gets the parent {@link Folder} of this folder, if it is nested. + * + * @return The parent {@link Folder}, or {@code null} if this is a top-level folder + */ + @Nullable + Folder getParent(); + + /** + * Sets the parent {@link Folder} for this folder. Setting it to {@code null} will make this a top-level folder. + *

    + * The parent folder must belong to the same {@link NavigatorCategory} as this folder. If the categories differ, an {@link IllegalArgumentException} is thrown. + * + * @param parent The new parent {@link Folder}, or {@code null} to remove the current parent + * @throws IllegalArgumentException if the parent has a different {@link NavigatorCategory} + */ + void setParent(@Nullable Folder parent); + + /** + * Checks if this folder has a parent {@link Folder}. + * + * @return {@code true} if this folder is nested under another, {@code false} otherwise + */ + boolean hasParent(); + + /** + * Gets an unmodifiable list of UUIDs for all {@link BuildWorld}s contained directly within this folder. + * + * @return An {@link Unmodifiable} {@link List} of {@link BuildWorld} UUIDs + */ + @Unmodifiable + List getWorldUUIDs(); + + /** + * Checks if this folder contains the specified {@link BuildWorld}. + * + * @param buildWorld The {@link BuildWorld} to check for + * @return {@code true} if the folder contains the world, {@code false} otherwise + */ + boolean containsWorld(BuildWorld buildWorld); + + /** + * Checks if this folder contains the {@link BuildWorld} with the specified UUID. + * + * @param uuid The unique identifier of the {@link BuildWorld} to check for + * @return {@code true} if the folder contains the world, {@code false} otherwise + */ + boolean containsWorld(UUID uuid); + + /** + * Adds a {@link BuildWorld} to this folder. + * + * @param buildWorld The {@link BuildWorld} to add + */ + void addWorld(BuildWorld buildWorld); + + /** + * Removes a {@link BuildWorld} from this folder. + * + * @param buildWorld The {@link BuildWorld} to remove + */ + void removeWorld(BuildWorld buildWorld); + + /** + * Removes a {@link BuildWorld} with the specified UUID from this folder. + * + * @param uuid The unique identifier of the {@link BuildWorld} to remove + */ + void removeWorld(UUID uuid); + + /** + * Returns an unmodifiable list of all immediate subfolders contained within this folder. + *

    + * This includes only direct children—folders whose {@link #getParent()} is exactly this folder. Nested subfolders (i.e., deeper levels of the folder hierarchy) are not + * included. + * + * @return A list of immediate subfolders + */ + @Unmodifiable + List getSubFolders(); + + /** + * Gets the total number of {@link BuildWorld}s contained in this folder and all of its subfolders. + *

    + * This includes both the worlds directly assigned to this folder and those assigned to any nested subfolders. + * + * @return The total number of worlds in this folder and its subfolders + */ + int getWorldCount(); + + /** + * Gets the permission string required for players to access or view this folder. Returns "-" if no specific permission is required. + * + * @return The permission string, or "-" if none is set + */ + String getPermission(); + + /** + * Sets the permission string required for players to access or view this folder. Setting to "-" will remove any permission requirement. + * + * @param permission The permission string to set, or "-" to remove + */ + void setPermission(String permission); + + /** + * Gets the project name associated with this {@link Folder}. This can be used for categorization or informational purposes. + * + * @return The project name as a string + */ + String getProject(); + + /** + * Sets the project name for this {@link Folder}. + * + * @param project The new project name to set + */ + void setProject(String project); + + /** + * Checks if the given {@link Player} has permission to view this folder in the navigator. + * + * @param player The {@link Player} to check + * @return {@code true} if the player can view the folder, {@code false} otherwise + */ + boolean canView(Player player); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/NavigatorCategory.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/NavigatorCategory.java new file mode 100644 index 0000000..14b84ed --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/NavigatorCategory.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.display; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.WorldData; +import org.jspecify.annotations.NullMarked; + +/** + * Represents the different categories used to organize and display {@link BuildWorld}s in the navigator menus. Each category corresponds to a distinct filter or access level for + * worlds. + * + * @since 3.0.0 + */ +@NullMarked +public enum NavigatorCategory { + + /** + * Represents the category for public worlds. This navigator inventory contains all {@link BuildWorld}s that are still being built or are generally accessible. + */ + PUBLIC, + + /** + * Represents the category for archived worlds. This navigator inventory contains {@link BuildWorld}s that have been marked with {@link BuildWorldStatus#ARCHIVE}. These worlds + * are typically read-only and no longer actively built upon. + * + * @see BuildWorldStatus#ARCHIVE + */ + ARCHIVE, + + /** + * Represents the category for private worlds. This navigator inventory contains {@link BuildWorld}s that are set as private. These worlds can typically only be modified by + * their creator and explicitly added {@link Builder}s. + * + * @see WorldData#privateWorld() + */ + PRIVATE; + + /** + * Determines the appropriate {@link NavigatorCategory} for a given {@link BuildWorld} based on its properties. + *

    + * First checks if the world is private ({@link #PRIVATE}), then if it's archived ({@link #ARCHIVE}), otherwise it defaults to {@link #PUBLIC}. + * + * @param buildWorld The {@link BuildWorld} for which to determine the category + * @return The corresponding category + */ + public static NavigatorCategory of(BuildWorld buildWorld) { + WorldData worldData = buildWorld.getData(); + if (worldData.privateWorld().get()) { + return PRIVATE; + } else if (worldData.status().get() == BuildWorldStatus.ARCHIVE) { + return ARCHIVE; + } else { + return PUBLIC; + } + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/package-info.java new file mode 100644 index 0000000..5d485e1 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/display/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces and classes for managing world display and rendering. + */ +package de.eintosti.buildsystem.api.world.display; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/NavigatorType.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/NavigatorType.java new file mode 100644 index 0000000..09458f0 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/NavigatorType.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.navigator.settings; + +import org.bukkit.entity.ArmorStand; +import org.bukkit.inventory.Inventory; + +/** + * Represents the type of the navigator. + * + * @since 3.0.0 + */ +public enum NavigatorType { + + /** + * The old, {@link Inventory}-based navigator. + */ + OLD, + + /** + * The new, {@link ArmorStand}-based navigator. + */ + NEW +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldDisplay.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldDisplay.java new file mode 100644 index 0000000..a9a44ba --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldDisplay.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.navigator.settings; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.jspecify.annotations.NullMarked; + +/** + * Interface for managing the display settings of worlds in the navigator. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldDisplay { + + /** + * Gets the order in which the {@link BuildWorld}s are sorted. + * + * @return The world sort order + */ + WorldSort getWorldSort(); + + /** + * Sets the order in which the {@link BuildWorld}s are sorted. + * + * @param worldSort The world sort order + */ + void setWorldSort(WorldSort worldSort); + + /** + * Gets the filter which removed non-matching {@link BuildWorld}s from the navigator + * + * @return The world filter + */ + WorldFilter getWorldFilter(); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldFilter.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldFilter.java new file mode 100644 index 0000000..187c4e9 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldFilter.java @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.navigator.settings; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.util.function.Predicate; +import org.jspecify.annotations.NullMarked; + +/** + * Interface for a world filter that restricts which {@link BuildWorld}s are shown to a user in the navigator. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldFilter { + + /** + * Gets the current mode. + * + * @return The mode + */ + Mode getMode(); + + /** + * Sets the current mode. + * + * @param mode The mode + */ + void setMode(Mode mode); + + /** + * Gets the text which the filter is applied to. + * + * @return The text the filter is applied to + */ + String getText(); + + /** + * Sets the text which the filter is applied to. + * + * @param text The text + */ + void setText(String text); + + /** + * Applies the current filter to a {@link BuildWorld} to determine if it should be shown. + * + * @return A {@link Predicate} that tests if a {@link BuildWorld} matches the filter criteria + */ + Predicate apply(); + + /** + * Represents the different modes of filtering worlds in the navigator. + */ + @NullMarked + enum Mode { + + /** + * No filtering is applied. + */ + NONE, + + /** + * Worlds that name starts with the filter text. + * + * @see #getText() + */ + STARTS_WITH, + + /** + * Worlds that name contains the filter text. + * + * @see #getText() + */ + CONTAINS, + + /** + * Worlds that name matches the filter text. + * + * @see #getText() + */ + MATCHES; + + /** + * Gets the next filtering mode in the sequence. + * + * @return The next {@link Mode} in the enumeration + */ + public Mode getNext() { + return switch (this) { + case NONE -> STARTS_WITH; + case STARTS_WITH -> CONTAINS; + case CONTAINS -> MATCHES; + case MATCHES -> NONE; + }; + } + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldSort.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldSort.java new file mode 100644 index 0000000..f1632b4 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/WorldSort.java @@ -0,0 +1,150 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.navigator.settings; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.display.Displayable; +import de.eintosti.buildsystem.api.world.display.Folder; +import java.util.Comparator; +import java.util.Locale; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents the sorting options for worlds in the navigator. + * + * @since 3.0.0 + */ +@NullMarked +public enum WorldSort { + + /** + * Sort worlds by name in ascending order. + */ + NAME_A_TO_Z(Comparator.comparing(WorldSort::getNameSortKey)), + + /** + * Sort worlds by name in descending order. + */ + NAME_Z_TO_A(NAME_A_TO_Z.getComparator().reversed()), + + /** + * Sort worlds by project in ascending order. + */ + PROJECT_A_TO_Z(Comparator.comparing(WorldSort::getProjectSortKey)), + + /** + * Sort worlds by project in descending order. + */ + PROJECT_Z_TO_A(PROJECT_A_TO_Z.getComparator().reversed()), + + /** + * Sort worlds by status ({@link BuildWorldStatus#NOT_STARTED} -> {@link BuildWorldStatus#FINISHED}). + */ + STATUS_NOT_STARTED(Comparator.comparingInt(WorldSort::getStatusSortKey)), + + /** + * Sort worlds by status ({@link BuildWorldStatus#FINISHED} -> {@link BuildWorldStatus#NOT_STARTED}). + */ + STATUS_FINISHED(STATUS_NOT_STARTED.getComparator().reversed()), + + /** + * Sort worlds by creation date in ascending order (oldest first). + */ + OLDEST_FIRST(Comparator.comparingLong(Displayable::getCreation)), + + /** + * Sort worlds by creation date in descending order (newest first). + */ + NEWEST_FIRST(OLDEST_FIRST.getComparator().reversed()); + + private final Comparator comparator; + + WorldSort(Comparator comparator) { + this.comparator = comparator; + } + + /** + * Retrieves the name of a {@link Displayable} in lowercase for sorting purposes. + * + * @param displayable The {@link Displayable} item (e.g., {@link BuildWorld} or {@link Folder}) + * @return The lowercase name of the displayable + */ + private static String getNameSortKey(Displayable displayable) { + return displayable.getName().toLowerCase(Locale.ROOT); + } + + /** + * Retrieves the project name of a {@link Displayable} in lowercase for sorting purposes. If the displayable is a {@link BuildWorld}, its project name is returned. If it is a + * {@link Folder}, its project is returned. + * + * @param displayable The {@link Displayable} item (e.g., {@link BuildWorld} or {@link Folder}) + * @return The lowercase project name, or an empty string if not applicable + */ + private static String getProjectSortKey(Displayable displayable) { + return switch (displayable) { + case BuildWorld world -> world.getData().project().get().toLowerCase(Locale.ROOT); + case Folder folder -> folder.getProject().toLowerCase(Locale.ROOT); + default -> ""; + }; + } + + /** + * Retrieves the status stage of a {@link Displayable} for sorting purposes. If the displayable is a {@link BuildWorld}, its status stage is returned. Otherwise, + * {@link BuildWorldStatus#FINISHED} stage is returned. + * + * @param displayable The {@link Displayable} item (e.g., {@link BuildWorld} or {@link Folder}) + * @return The status stage integer + */ + private static int getStatusSortKey(Displayable displayable) { + if (displayable instanceof BuildWorld buildWorld) { + return buildWorld.getData().status().get().getStage(); + } + return BuildWorldStatus.FINISHED.getStage(); + } + + /** + * Matches a string to a {@link WorldSort} enum constant. + * + * @param type The string to match + * @return The matched {@link WorldSort} constant, or {@link WorldSort#NAME_A_TO_Z} if no match is found + */ + public static WorldSort matchWorldSort(@Nullable String type) { + if (type == null) { + return NAME_A_TO_Z; + } + + for (WorldSort value : values()) { + if (value.toString().equalsIgnoreCase(type)) { + return value; + } + } + + return NAME_A_TO_Z; + } + + /** + * Gets the pre-configured comparator for this sort order. + * + * @return The comparator used to sort {@link Displayable} items + */ + public Comparator getComparator() { + return this.comparator; + } +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/package-info.java new file mode 100644 index 0000000..eb8b1ff --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/navigator/settings/package-info.java @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces and enumerations for navigator-specific settings within the BuildSystem API. This includes options for how worlds are displayed, filtered, and sorted in the + * in-game navigator menus. + */ +package de.eintosti.buildsystem.api.world.navigator.settings; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/package-info.java new file mode 100644 index 0000000..58c0e01 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides interfaces and classes for managing world backups. + */ +package de.eintosti.buildsystem.api.world; \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldLoader.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldLoader.java new file mode 100644 index 0000000..271745e --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldLoader.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.util; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +/** + * Provides utilities for loading and managing {@link BuildWorld}s. This interface handles the process of making a world accessible on the server. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldLoader { + + /** + * Loads the world associated with this loader for a specific player. This typically involves teleporting the player to the world after it's loaded. + * + * @param player The {@link Player} for whom the world should be loaded and who will be teleported into it + */ + void loadForPlayer(Player player); + + /** + * Loads the world associated with this loader without teleporting any specific player. This is useful for background world loading or server-side operations. + */ + void load(); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldPermissions.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldPermissions.java new file mode 100644 index 0000000..b9b9b66 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldPermissions.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.util; + +import de.eintosti.buildsystem.api.data.Type; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Manages and checks permissions related to {@link BuildWorld}s within the BuildSystem. This interface handles permissions for actions such as entering, modifying, and executing + * commands in worlds. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldPermissions { + + /** + * Checks if the given {@link Player} is allowed to enter the world associated with these permissions. + *

    + * A player can enter if any of the following conditions are met: + *

      + *
    • They have the administrative permission ({@link #hasAdminPermission(Player)}).
    • + *
    • They can bypass the view permission ({@link #canBypassViewPermission(Player)}).
    • + *
    • They are either the world's creator or an assigned builder.
    • + *
    • The world is public (its permission is set to "{@code -}").
    • + *
    • They possess the specific permission defined in the world's data.
    • + *
    + * + * @param player The {@link Player} to check + * @return {@code true} if the player can enter the world, {@code false} otherwise + */ + boolean canEnter(Player player); + + /** + * Checks if a {@link Player} is allowed to perform a specific modification in the {@link BuildWorld}. + *

    + * Modifications might be disallowed due to: + *

      + *
    • The world having its {@link BuildWorldStatus} set to {@link BuildWorldStatus#ARCHIVE}.
    • + *
    • A world setting is enabled that specifically prohibits certain events (e.g., block placement/breaking).
    • + *
    • The world is configured to only allow designated {@link Builder}s, and the player is neither a builder nor the world's creator.
    • + *
    + *

    + * However, a player can bypass these restrictions if: + *

      + *
    • They have the administrative permission ({@link #hasAdminPermission(Player)}).
    • + *
    • They are in a "build mode" that allows them to bypass building restrictions ({@link #canBypassBuildRestriction(Player)}).
    • + *
    • They have the bypass permission for the check
    • + *
    + * + * @param player The player attempting to modify the world + * @param check The specific data type representing the modification to be checked + * @return {@code true} if the player is allowed to modify the world, {@code false} otherwise + */ + boolean canModify(Player player, Type check); + + /** + * Checks if the given {@link Player} is permitted to execute a specific command within the context of the current world. + *

    + * Permissions are handled as follows: + *

      + *
    • The world's creator can run the command if they have the base permission, optionally ending with {@code .self}.
    • + *
    • All other players require the permission {@code .other} to execute the command.
    • + *
    + * + * @param player The {@link Player} attempting to run the command + * @param permission The base permission string required for the command (e.g., "buildsystem.command.mycommand") + * @return {@code true} if the player is authorized to run the command, {@code false} otherwise + */ + boolean canPerformCommand(Player player, @Nullable String permission); + + /** + * Checks if the given {@link Player} possesses the administrative permission, typically "{@code buildsystem.admin}". Players with this permission can bypass many + * world-specific restrictions. + * + * @param player The {@link Player} to check + * @return {@code true} if the player has the administrative permission, {@code false} otherwise + */ + boolean hasAdminPermission(Player player); + + /** + * Checks if the player can bypass the permission required to view a private world in the navigator. This is separate from the `canEnter` permission and relates specifically to + * listing the world. + * + * @param player The {@link Player} to check + * @return {@code true} if the player can bypass the view permission, {@code false} otherwise + */ + boolean canBypassViewPermission(Player player); + + /** + * Checks if the given {@link Player} can bypass standard building restrictions due to being in a special "build mode" or having a bypass permission. This allows players to + * modify worlds even if general building is disabled. + * + * @param player The {@link Player} to check + * @return {@code true} if the player can bypass build restrictions, {@code false} otherwise + */ + boolean canBypassBuildRestriction(Player player); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldTeleporter.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldTeleporter.java new file mode 100644 index 0000000..856df33 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldTeleporter.java @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.util; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +/** + * Provides utilities for teleporting {@link Player}s to specific locations within a {@link BuildWorld}. This interface ensures safe and controlled player movement. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldTeleporter { + + /** + * Teleports the given {@link Player} to the designated spawn location of the world associated with this teleporter. If a custom spawn is not set, the player will be teleported + * to the world's default spawn. + * + * @param player The {@link Player} to teleport + */ + void teleport(Player player); +} diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldUnloader.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldUnloader.java new file mode 100644 index 0000000..e377655 --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/WorldUnloader.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api.world.util; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.jspecify.annotations.NullMarked; + +/** + * Provides utilities for managing the unloading process of a {@link BuildWorld}. This interface handles tasks such as initiating and cancelling unload procedures. + * + * @since 3.0.0 + */ +@NullMarked +public interface WorldUnloader { + + /** + * Manages the {@link BuildWorld}'s unload state. + *

    + * If world unloading is enabled in the config, the unload task is started. + */ + void manageUnload(); + + /** + * Starts a delayed task to unload the world, if world unloading is enabled in the config. + */ + void startUnloadTask(); + + /** + * Resets the world unload task. + */ + void resetUnloadTask(); + + /** + * Attempt to unload the world. + *

    + * If the world contains any players, is blacklisted for unloading or is the spawn world, the unload will be canceled. + */ + void unload(); + + /** + * Forces the unloading of the world, bypassing any checks or grace periods. + * + * @param save Whether the world should be saved before unloading + */ + void forceUnload(boolean save); +} \ No newline at end of file diff --git a/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/package-info.java b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/package-info.java new file mode 100644 index 0000000..a8ed32d --- /dev/null +++ b/buildsystem-api/src/main/java/de/eintosti/buildsystem/api/world/util/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides utility classes and interfaces for managing world-related operations. + */ +package de.eintosti.buildsystem.api.world.util; \ No newline at end of file diff --git a/buildsystem-core/build.gradle.kts b/buildsystem-core/build.gradle.kts new file mode 100644 index 0000000..e4f3ed6 --- /dev/null +++ b/buildsystem-core/build.gradle.kts @@ -0,0 +1,233 @@ +import com.github.jengelman.gradle.plugins.shadow.tasks.ShadowJar +import net.minecrell.pluginyml.bukkit.BukkitPluginDescription + +applyCoreConfiguration() + +plugins { + id("java-library") + id("com.gradleup.shadow") version "9.2.2" + id("de.eldoria.plugin-yml.bukkit") version "0.8.0" +} + +project.description = "Core" + +repositories { + maven { + name = "AuthLib" + url = uri("https://libraries.minecraft.net/") + } + maven { + name = "EngineHub" + url = uri("https://maven.enginehub.org/repo/") + } + maven { + name = "PlaceholderAPI" + url = uri("https://repo.extendedclip.com/content/repositories/placeholderapi/") + } + maven { + name = "Modrinth" + url = uri("https://api.modrinth.com/maven") + } + modrinthMavenWorkaround( + "axiom-paper-plugin", + "4.0.1-1.21.1", + "AxiomPaper-4.0.1-for-MC1.21.1.jar" + ) +} + +dependencies { + api(project(":buildsystem-api")) + + compileOnlyApi(libs.annotations) + compileOnlyApi(libs.jspecify) + + compileOnly(libs.spigot) + compileOnly(libs.authlib) + compileOnly(libs.luckperms) + compileOnly(libs.placeholderapi) + compileOnly(libs.worldedit) + compileOnly(libs.axiompaper) + + implementation(libs.paperlib) + implementation(libs.xseries) + implementation(libs.fastboard) + implementation(libs.nbt) { isTransitive = false } + implementation(libs.bstats) + implementation(libs.aws.core) // Unable to find the dependency at runtime, so we add it here + + library(libs.bundles.aws) + library(libs.bouncycastle) + library(libs.sftp) + library(libs.zip4j) +} + +tasks.named("assemble") { + dependsOn(tasks.named("shadowJar")) +} + +tasks.named("shadowJar") { + minimize() + archiveFileName.set("${rootProject.name}-${project.version}.jar") + + val shadePath = "de.eintosti.buildsystem.util.external" + relocate("io.papermc.lib", "$shadePath.paperlib") + relocate("com.cryptomorin.xseries", "$shadePath.xseries") + relocate("fr.mrmicky.fastboard", "$shadePath.fastboard") + relocate("dev.dewy.nbt", "$shadePath.nbt") + relocate("org.bstats", "$shadePath.bstats") +} + +tasks.processResources { + val props = mapOf("version" to project.version) + filesMatching("plugin.yml") { + expand(props) + } +} + +bukkit { + name = "BuildSystem" + version = "${project.version}" + description = "Powerful, easy to use system for builders" + author = "einTosti" + website = "https://buildsystem.eintosti.de" + + main = "de.eintosti.buildsystem.BuildSystemPlugin" + apiVersion = "1.13" + softDepend = listOf("LuckPerms", "PlaceholderAPI", "WorldEdit", "AxiomPaper") + + commands { + register("back") { + description = "Teleports you to your previous location." + usage = "/" + } + register("blocks") { + description = "Opens a menu with secret blocks." + usage = "/" + } + register("build") { + description = "Bypass build restrictions." + usage = "/ [player]" + } + register("buildsystem") { + description = "Overview of all plugin commands." + usage = "/" + } + register("config") { + description = "Reload the config." + usage = "/ reload" + } + register("day") { + description = "Set a world's time to daytime." + usage = "/ [world]" + } + register("explosions") { + description = "Toggle explosions within a world." + usage = "/ [world]" + } + register("gamemode") { + description = "Change your gamemode." + aliases = listOf("gm") + usage = "/ [player]" + } + register("night") { + description = "Set a world's time to nighttime." + usage = "/ [world]" + } + register("noai") { + description = "Disable all the entity AIs in a world." + usage = "/ [world]" + } + register("physics") { + description = "Toggle block physics." + usage = "/ [world]" + } + register("settings") { + description = "Manage user settings." + usage = "/" + } + register("setup") { + description = "Change the default items in used in the navigator." + usage = "/" + } + register("skull") { + description = "Receive a player's skull." + usage = "/ [name]" + } + register("spawn") { + description = "Teleport to the spawn." + usage = "/" + } + register("speed") { + description = "Change your flying/walking speed." + aliases = listOf("s") + usage = "/ [1-5]" + } + register("top") { + description = "Teleports you to the highest location." + usage = "/" + } + register("worlds") { + description = "Open the world menu." + aliases = listOf("w") + usage = "/" + } + } + permissions { + register("buildsystem.help") { + children = listOf("buildsystem.help.buildsystem", "buildsystem.help.worlds") + description = "Permission for help commands." + default = BukkitPluginDescription.Permission.Default.TRUE + } + register("buildsystem.navigator") { + description = "Open the worlds navigator." + default = BukkitPluginDescription.Permission.Default.TRUE + } + register("buildsystem.navigator.item") { + description = "Receive and use the navigator." + default = BukkitPluginDescription.Permission.Default.TRUE + } + register("buildsystem.create") { + children = listOf( + "buildsystem.create.private", + "buildsystem.create.type.normal", + "buildsystem.create.type.flat", + "buildsystem.create.type.nether", + "buildsystem.create.type.end", + "buildsystem.create.type.void" + ) + description = "Permission for creating world types." + default = BukkitPluginDescription.Permission.Default.TRUE + } + register("buildsystem.setstatus") { + children = listOf( + "buildsystem.setstatus.hidden", + "buildsystem.setstatus.archive", + "buildsystem.setstatus.finished", + "buildsystem.setstatus.almostfinished", + "buildsystem.setstatus.inprogress", + "buildsystem.setstatus.notstarted" + ) + description = "Permission for setting world status." + default = BukkitPluginDescription.Permission.Default.OP + } + register("buildsystem.gamemode") { + children = listOf("buildsystem.gamemode.survival", "buildsystem.gamemode.creative", "buildsystem.gamemode.adventure", "buildsystem.gamemode.spectator") + description = "Permission for changing own gamemode." + default = BukkitPluginDescription.Permission.Default.OP + } + register("buildsystem.gamemode.other") { + children = + listOf("buildsystem.gamemode.survival.other", "buildsystem.gamemode.creative.other", "buildsystem.gamemode.adventure.other", "buildsystem.gamemode.spectator.other") + description = "Permission for changing other player's gamemode." + default = BukkitPluginDescription.Permission.Default.OP + } + register("buildsystem.physics.message") { + description = "Receive the message that physics are disabled in a world." + default = BukkitPluginDescription.Permission.Default.TRUE + } + register("buildsystem.updates") { + description = "Receive update messages." + default = BukkitPluginDescription.Permission.Default.OP + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java new file mode 100644 index 0000000..50d03df --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/BuildSystemPlugin.java @@ -0,0 +1,460 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem; + +import de.eintosti.buildsystem.api.BuildSystem; +import de.eintosti.buildsystem.api.BuildSystemApi; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.navigator.settings.NavigatorType; +import de.eintosti.buildsystem.command.BackCommand; +import de.eintosti.buildsystem.command.BlocksCommand; +import de.eintosti.buildsystem.command.BuildCommand; +import de.eintosti.buildsystem.command.BuildSystemCommand; +import de.eintosti.buildsystem.command.ConfigCommand; +import de.eintosti.buildsystem.command.ExplosionsCommand; +import de.eintosti.buildsystem.command.GamemodeCommand; +import de.eintosti.buildsystem.command.NoAICommand; +import de.eintosti.buildsystem.command.PhysicsCommand; +import de.eintosti.buildsystem.command.SettingsCommand; +import de.eintosti.buildsystem.command.SetupCommand; +import de.eintosti.buildsystem.command.SkullCommand; +import de.eintosti.buildsystem.command.SpawnCommand; +import de.eintosti.buildsystem.command.SpeedCommand; +import de.eintosti.buildsystem.command.TimeCommand; +import de.eintosti.buildsystem.command.TopCommand; +import de.eintosti.buildsystem.command.WorldsCommand; +import de.eintosti.buildsystem.command.tabcomplete.BuildTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.ConfigTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.EmptyTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.GamemodeTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.PhysicsTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.SpawnTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.SpeedTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.TimeTabCompleter; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.config.Config.Folder; +import de.eintosti.buildsystem.config.Config.Settings.Archive; +import de.eintosti.buildsystem.config.Config.Settings.Builder; +import de.eintosti.buildsystem.config.Config.World; +import de.eintosti.buildsystem.config.Config.World.Unload; +import de.eintosti.buildsystem.config.migration.ConfigMigrationManager; +import de.eintosti.buildsystem.expansion.luckperms.LuckPermsExpansion; +import de.eintosti.buildsystem.expansion.placeholderapi.PlaceholderApiExpansion; +import de.eintosti.buildsystem.listener.AsyncPlayerChatListener; +import de.eintosti.buildsystem.listener.AsyncPlayerPreLoginListener; +import de.eintosti.buildsystem.listener.BlockPhysicsListener; +import de.eintosti.buildsystem.listener.BuildModePreventationListener; +import de.eintosti.buildsystem.listener.BuildWorldResetUnloadListener; +import de.eintosti.buildsystem.listener.EditSessionListener; +import de.eintosti.buildsystem.listener.EntityDamageListener; +import de.eintosti.buildsystem.listener.EntitySpawnListener; +import de.eintosti.buildsystem.listener.FoodLevelChangeListener; +import de.eintosti.buildsystem.listener.InventoryCreativeListener; +import de.eintosti.buildsystem.listener.InventoryListener; +import de.eintosti.buildsystem.listener.NavigatorListener; +import de.eintosti.buildsystem.listener.PlayerChangedWorldListener; +import de.eintosti.buildsystem.listener.PlayerCommandPreprocessListener; +import de.eintosti.buildsystem.listener.PlayerInventoryClearListener; +import de.eintosti.buildsystem.listener.PlayerJoinListener; +import de.eintosti.buildsystem.listener.PlayerMoveListener; +import de.eintosti.buildsystem.listener.PlayerQuitListener; +import de.eintosti.buildsystem.listener.PlayerRespawnListener; +import de.eintosti.buildsystem.listener.PlayerTeleportListener; +import de.eintosti.buildsystem.listener.SettingsInteractListener; +import de.eintosti.buildsystem.listener.SignChangeListener; +import de.eintosti.buildsystem.listener.WeatherChangeListener; +import de.eintosti.buildsystem.listener.WorldManipulateByAxiomListener; +import de.eintosti.buildsystem.listener.WorldManipulateListener; +import de.eintosti.buildsystem.player.LogoutLocationImpl; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.player.customblock.CustomBlockManager; +import de.eintosti.buildsystem.player.settings.NoClipManager; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.util.UpdateChecker; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.world.SpawnManager; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.backup.BackupService; +import de.eintosti.buildsystem.world.display.CustomizableIcons; +import de.eintosti.buildsystem.world.navigator.ArmorStandManager; +import java.io.File; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.Callable; +import org.bstats.bukkit.Metrics; +import org.bstats.charts.AdvancedPie; +import org.bstats.charts.SimplePie; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.plugin.PluginManager; +import org.bukkit.plugin.ServicePriority; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; + +public class BuildSystemPlugin extends JavaPlugin { + + public static final int SPIGOT_ID = 60441; + public static final int METRICS_ID = 7427; + public static final String ADMIN_PERMISSION = "buildsystem.admin"; + + private static BuildSystemPlugin instance; + + private ArmorStandManager armorStandManager; + private CustomBlockManager customBlockManager; + private InventoryManager inventoryManager; + private PlayerServiceImpl playerService; + private NoClipManager noClipManager; + private SettingsManager settingsManager; + private SpawnManager spawnManager; + private WorldServiceImpl worldService; + private BackupService backupService; + private CustomizableIcons customizableIcons; + + private LuckPermsExpansion luckPermsExpansion; + private PlaceholderApiExpansion placeholderApiExpansion; + + private BuildSystemApi api; + + private BukkitTask configSaveTask; + + @Override + public void onLoad() { + instance = this; + + new ConfigMigrationManager(this).migrate(); + this.getConfig().options().copyDefaults(true); + this.saveConfig(); + Config.load(); + + Messages.createMessageFile(); + createTemplateFolder(); + } + + @Override + public void onEnable() { + initClasses(); + + registerCommands(); + registerTabCompleters(); + registerListeners(); + registerExpansions(); + + performUpdateCheck(); + + this.api = new BuildSystemApi(this); + this.api.register(); + getServer().getServicesManager().register(BuildSystem.class, api, this, ServicePriority.Normal); + + Bukkit.getOnlinePlayers().forEach(pl -> { + BuildPlayer buildPlayer = playerService.getPlayerStorage().createBuildPlayer(pl); + Settings settings = buildPlayer.getSettings(); + noClipManager.startNoClip(pl, settings); + settingsManager.displayScoreboard(pl); + }); + + registerStats(); + + this.configSaveTask = Bukkit.getScheduler().runTaskTimer(this, this::saveBuildConfig, 6000L, 6000L); + + Bukkit.getConsoleSender().sendMessage( + "%sBuildSystem » Plugin %senabled%s!".formatted(ChatColor.RESET, ChatColor.GREEN, ChatColor.RESET) + ); + } + + @Override + public void onDisable() { + Bukkit.getOnlinePlayers().forEach(pl -> { + BuildPlayer buildPlayer = playerService.getPlayerStorage().getBuildPlayer(pl); + buildPlayer.getCachedValues().resetCachedValues(pl); + buildPlayer.setLogoutLocation(new LogoutLocationImpl(pl.getWorld().getName(), pl.getLocation())); + + settingsManager.hideScoreboard(pl); + noClipManager.stopNoClip(pl.getUniqueId()); + playerService.closeNewNavigator(pl); + }); + + this.backupService.getStorage().close(); + + reloadConfigData(false); + saveConfig(); + saveBuildConfig(); + + if (this.configSaveTask != null) { + this.configSaveTask.cancel(); + } + + unregisterExpansions(); + this.api.unregister(); + + Bukkit.getConsoleSender().sendMessage( + "%sBuildSystem » Plugin %sdisabled%s!".formatted(ChatColor.RESET, ChatColor.RED, ChatColor.RESET) + ); + + instance = null; + } + + public static BuildSystemPlugin get() { + if (instance == null) { + throw new IllegalStateException("BuildSystemPlugin instance is not initialized. Make sure the plugin is enabled."); + } + return instance; + } + + private void initClasses() { + this.customizableIcons = new CustomizableIcons(this); + + this.inventoryManager = new InventoryManager(); + this.armorStandManager = new ArmorStandManager(); + this.customBlockManager = new CustomBlockManager(this); + (this.playerService = new PlayerServiceImpl(this)).init(); + this.noClipManager = new NoClipManager(this); + (this.worldService = new WorldServiceImpl(this)).init(); + this.backupService = new BackupService(this); + this.settingsManager = new SettingsManager(this); + this.spawnManager = new SpawnManager(this); + } + + private void registerCommands() { + new BackCommand(this); + new BlocksCommand(this); + new BuildCommand(this); + new BuildSystemCommand(this); + new ConfigCommand(this); + new ExplosionsCommand(this); + new GamemodeCommand(this); + new NoAICommand(this); + new PhysicsCommand(this); + new SettingsCommand(this); + new SetupCommand(this); + new SkullCommand(this); + new SpawnCommand(this); + new SpeedCommand(this); + new TimeCommand(this); + new TopCommand(this); + new WorldsCommand(this); + } + + private void registerTabCompleters() { + new BuildTabCompleter(this); + new ConfigTabCompleter(this); + new EmptyTabCompleter(this); + new GamemodeTabCompleter(this); + new PhysicsTabCompleter(this); + new SpawnTabCompleter(this); + new SpeedTabCompleter(this); + new TimeTabCompleter(this); + new WorldsTabCompleter(this); + } + + private void registerListeners() { + new AsyncPlayerChatListener(this); + new AsyncPlayerPreLoginListener(this); + new BlockPhysicsListener(this); + new CustomBlockManager(this); + new BuildModePreventationListener(this); + new BuildWorldResetUnloadListener(this); + new EntitySpawnListener(this); + new FoodLevelChangeListener(this); + new InventoryCreativeListener(this); + new InventoryListener(this); + new NavigatorListener(this); + new PlayerChangedWorldListener(this); + new EntityDamageListener(this); + new PlayerCommandPreprocessListener(this); + new PlayerInventoryClearListener(this); + new PlayerJoinListener(this); + new PlayerMoveListener(this); + new PlayerQuitListener(this); + new PlayerRespawnListener(this); + new PlayerTeleportListener(this); + new SettingsInteractListener(this); + new SignChangeListener(this); + new WeatherChangeListener(this); + new WorldManipulateListener(this); + } + + private void registerStats() { + Metrics metrics = new Metrics(this, METRICS_ID); + metrics.addCustomChart(new SimplePie("archive_vanish", () -> String.valueOf(Archive.vanish))); + metrics.addCustomChart(new SimplePie("block_world_edit", () -> String.valueOf(Builder.blockWorldEditNonBuilder))); + metrics.addCustomChart(new SimplePie("join_quit_messages", () -> String.valueOf(Config.Messages.joinQuitMessages))); + metrics.addCustomChart(new SimplePie("lock_weather", () -> String.valueOf(World.lockWeather))); + metrics.addCustomChart(new SimplePie("scoreboard", () -> String.valueOf(Config.Settings.scoreboard))); + metrics.addCustomChart(new SimplePie("update_checker", () -> String.valueOf(Config.Settings.updateChecker))); + metrics.addCustomChart(new SimplePie("unload_worlds", () -> String.valueOf(Unload.enabled))); + metrics.addCustomChart(new AdvancedPie("navigator_type", new Callable<>() { + @Override + public Map call() { + Map valueMap = new HashMap<>(); + valueMap.put("Old", getPlayersWithNavigator(NavigatorType.OLD)); + valueMap.put("New", getPlayersWithNavigator(NavigatorType.NEW)); + return valueMap; + } + + private int getPlayersWithNavigator(NavigatorType navigatorType) { + return (int) playerService.getPlayerStorage().getBuildPlayers().stream() + .filter(buildPlayer -> buildPlayer.getSettings().getNavigatorType() == navigatorType) + .count(); + } + })); + metrics.addCustomChart(new SimplePie("folder_override_permissions", () -> String.valueOf(Folder.overridePermissions))); + metrics.addCustomChart(new SimplePie("folder_override_projects", () -> String.valueOf(Folder.overrideProjects))); + } + + private void registerExpansions() { + PluginManager pluginManager = Bukkit.getPluginManager(); + + if (pluginManager.getPlugin("PlaceholderAPI") != null) { + this.placeholderApiExpansion = new PlaceholderApiExpansion(this); + this.placeholderApiExpansion.register(); + } + + if (pluginManager.getPlugin("LuckPerms") != null) { + this.luckPermsExpansion = new LuckPermsExpansion(this); + this.luckPermsExpansion.registerAll(); + } + + if (pluginManager.getPlugin("AxiomPaper") != null) { + new WorldManipulateByAxiomListener(this); + } + + boolean isWorldEdit = pluginManager.getPlugin("WorldEdit") != null + || pluginManager.getPlugin("FastAsyncWorldEdit") != null; + if (isWorldEdit && Builder.blockWorldEditNonBuilder) { + new EditSessionListener(this); + } + } + + private void unregisterExpansions() { + if (this.placeholderApiExpansion != null) { + this.placeholderApiExpansion.unregister(); + } + + if (this.luckPermsExpansion != null) { + this.luckPermsExpansion.unregisterAll(); + } + } + + private void performUpdateCheck() { + if (!Config.Settings.updateChecker) { + return; + } + + UpdateChecker.init(this, SPIGOT_ID).requestUpdateCheck().whenComplete((result, e) -> { + if (result.requiresUpdate()) { + Bukkit.getConsoleSender().sendMessage( + ChatColor.YELLOW + "[BuildSystem] Great! a new update is available: " + + ChatColor.GREEN + "v" + result.getNewestVersion() + ); + Bukkit.getConsoleSender().sendMessage( + ChatColor.YELLOW + " ➥ Your current version: " + + ChatColor.RED + this.getDescription().getVersion() + ); + return; + } + + UpdateChecker.UpdateReason reason = result.getReason(); + switch (reason) { + case COULD_NOT_CONNECT, INVALID_JSON, UNAUTHORIZED_QUERY, UNKNOWN_ERROR, UNSUPPORTED_VERSION_SCHEME -> Bukkit.getConsoleSender().sendMessage( + ChatColor.RED + "[BuildSystem] Could not check for a new version of BuildSystem. Reason: " + reason + ); + } + } + ); + } + + private void createTemplateFolder() { + File templateFolder = new File(getDataFolder() + File.separator + "templates"); + if (templateFolder.mkdirs()) { + getLogger().info("Created \"templates\" folder"); + } + } + + private void saveBuildConfig() { + worldService.save(); + playerService.save(); + spawnManager.save(); + } + + /** + * Reloads the config and config data. + * + * @param init Whether the plugin should reinitialize classes + */ + public void reloadConfigData(boolean init) { + for (Player pl : Bukkit.getOnlinePlayers()) { + getSettingsManager().hideScoreboard(pl); + } + + reloadConfig(); + Config.load(); + + if (init) { + worldService.getWorldStorage().getBuildWorlds().forEach(buildWorld -> buildWorld.getUnloader().manageUnload()); + + if (Config.Settings.scoreboard) { + getSettingsManager().displayScoreboard(); + } else { + getSettingsManager().hideScoreboards(); + } + } + } + + public ArmorStandManager getArmorStandManager() { + return armorStandManager; + } + + public CustomBlockManager getCustomBlockManager() { + return customBlockManager; + } + + public InventoryManager getInventoryManager() { + return inventoryManager; + } + + public PlayerServiceImpl getPlayerService() { + return playerService; + } + + public NoClipManager getNoClipManager() { + return noClipManager; + } + + public SettingsManager getSettingsManager() { + return settingsManager; + } + + public SpawnManager getSpawnManager() { + return spawnManager; + } + + public WorldServiceImpl getWorldService() { + return worldService; + } + + public BackupService getBackupService() { + return backupService; + } + + public CustomizableIcons getCustomizableIcons() { + return customizableIcons; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/Messages.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/Messages.java new file mode 100644 index 0000000..b0463a1 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/Messages.java @@ -0,0 +1,1103 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem; + +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.util.color.ColorAPI; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import java.util.function.Function; +import java.util.logging.Level; +import me.clip.placeholderapi.PlaceholderAPI; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class Messages { + + private static final BuildSystemPlugin PLUGIN = BuildSystemPlugin.get(); + private static final Map MESSAGES = new HashMap<>(); + private static final boolean PLACEHOLDER_API_ENABLED = Bukkit.getPluginManager().getPlugin("PlaceholderAPI") != null; + + @Nullable + private static YamlConfiguration config = null; + + public static void createMessageFile() { + File file = new File(PLUGIN.getDataFolder(), "messages.yml"); + try { + if (file.createNewFile()) { + PLUGIN.getLogger().info("Created file: " + file.getName()); + } + } catch (IOException e) { + PLUGIN.getLogger().log(Level.SEVERE, "Could not create file: " + file.getName(), e); + } + + config = YamlConfiguration.loadConfiguration(file); + StringBuilder sb = new StringBuilder(); + + addSpacer(sb, "# ██████╗ ██╗ ██╗██╗██╗ ██████╗ ███████╗██╗ ██╗███████╗████████╗███████╗███╗ ███╗"); + addSpacer(sb, "# ██╔══██╗██║ ██║██║██║ ██╔══██╗██╔════╝╚██╗ ██╔╝██╔════╝╚══██╔══╝██╔════╝████╗ ████║"); + addSpacer(sb, "# ██████╔╝██║ ██║██║██║ ██║ ██║███████╗ ╚████╔╝ ███████╗ ██║ █████╗ ██╔████╔██║"); + addSpacer(sb, "# ██╔══██╗██║ ██║██║██║ ██║ ██║╚════██║ ╚██╔╝ ╚════██║ ██║ ██╔══╝ ██║╚██╔╝██║"); + addSpacer(sb, "# ██████╔╝╚██████╔╝██║███████╗██████╔╝███████║ ██║ ███████║ ██║ ███████╗██║ ╚═╝ ██║"); + addSpacer(sb, "# ╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚══════╝ ╚═╝ ╚══════╝ ╚═╝ ╚══════╝╚═╝ ╚═╝"); + addSpacer(sb, ""); + addSpacer(sb, ""); + addSpacer(sb, "# ---------"); + addSpacer(sb, "# Messages"); + addSpacer(sb, "# ---------"); + setMessage(config, sb, "prefix", "&8▎ &bBuildSystem &8»"); + setMessage(config, sb, "player_join", "&7[&a+&7] &a%player%"); + setMessage(config, sb, "player_quit", "&7[&c-&7] &c%player%"); + setMessage(config, sb, "loading_world", "&7Loading &b%world%&7..."); + setMessage(config, sb, "world_not_loaded", "&cWorld is not loaded!"); + setMessage(config, sb, "enter_world_name", "&7Enter &bWorld Name"); + setMessage(config, sb, "enter_folder_name", "&7Enter &bFolder Name"); + setMessage(config, sb, "enter_generator_name", "&7Enter &bGenerator Name"); + setMessage(config, sb, "enter_world_creator", "&7Enter &bWorld Creator"); + setMessage(config, sb, "enter_world_permission", "&7Enter &bPermission"); + setMessage(config, sb, "enter_world_project", "&7Enter &bProject"); + setMessage(config, sb, "enter_player_name", "&7Enter &bPlayer Name"); + setMessage(config, sb, "cancel_subtitle", "&7Type &ccancel &7to cancel"); + setMessage(config, sb, "input_cancelled", "%prefix% &cInput cancelled!"); + setMessage(config, sb, "update_available", Arrays.asList( + "%prefix% &7Great! A new update is available &8[&bv%new_version%&8]", + " &8➥ &7Your current version: &bv%current_version%" + )); + setMessage(config, sb, "command_archive_world", "%prefix% &cYou can't use that command here!"); + setMessage(config, sb, "command_not_builder", "%prefix% &cOnly builders can use that command!"); + addSpacer(sb, ""); + addSpacer(sb, ""); + addSpacer(sb, "# ---------"); + addSpacer(sb, "# Scoreboard"); + addSpacer(sb, "# ---------"); + setMessage(config, sb, "title", "&b&lBuildSystem"); + setMessage(config, sb, "body", Arrays.asList( + "&7&m &8", + "&7World:", + " &b%world%", + " ", + "&7Status:", + " %status%", + "&7&m &7" + )); + addSpacer(sb, ""); + addSpacer(sb, ""); + addSpacer(sb, "# ---------"); + addSpacer(sb, "# Commands"); + addSpacer(sb, "# ---------"); + setMessage(config, sb, "sender_not_player", "You have to be a player to use this command!"); + setMessage(config, sb, "no_permissions", "%prefix% &cNot enough permissions."); + addSpacer(sb, ""); + addSpacer(sb, "# /back"); + setMessage(config, sb, "back_usage", "%prefix% &7Usage: &b/back"); + setMessage(config, sb, "back_teleported", "%prefix% &7You were teleported to your &bprevious location&7."); + setMessage(config, sb, "back_failed", "%prefix% &cNo previous location was found."); + addSpacer(sb, ""); + addSpacer(sb, "# /build"); + setMessage(config, sb, "build_usage", "%prefix% &7Usage: &b/build [player]"); + setMessage(config, sb, "build_player_not_found", "%prefix% &cThat player was not found."); + setMessage(config, sb, "build_activated_self", "%prefix% &7Build mode was &aactivated&7."); + setMessage(config, sb, "build_activated_other_sender", "%prefix% &7Build mode &8[&7for %target%&8] &7was &aactivated&7."); + setMessage(config, sb, "build_activated_other_target", "%prefix% &7Build mode was &aactivated &8[&7by %sender%&8]&7."); + setMessage(config, sb, "build_deactivated_self", "%prefix% &7Build mode was &cdeactivated&7."); + setMessage(config, sb, "build_deactivated_other_sender", "%prefix% &7Build mode &8[&7for %target%&8] &7was &cdeactivated&7."); + setMessage(config, sb, "build_deactivated_other_target", "%prefix% &7Build mode was &cdeactivated &8[&7by %sender%&8]&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /buildsystem"); + setMessage(config, sb, "buildsystem_usage", "%prefix% &7Usage: &b/buildsystem [page]"); + setMessage(config, sb, "buildsystem_invalid_page", "%prefix% &cInvalid page."); + setMessage(config, sb, "buildsystem_title_with_page", "%prefix% &7&nBuildSystem Help:&8 (&7%page%/%max%&8)"); + setMessage(config, sb, "buildsystem_permission", "&7&nPermission&8: &b%permission%"); + setMessage(config, sb, "buildsystem_back", "&7Teleport to your previous location."); + setMessage(config, sb, "buildsystem_blocks", "&7Opens a menu with secret blocks."); + setMessage(config, sb, "buildsystem_build", "&7Puts you into 'build mode'."); + setMessage(config, sb, "buildsystem_config", "&7Reload the config."); + setMessage(config, sb, "buildsystem_day", "&7Set a world's time to daytime."); + setMessage(config, sb, "buildsystem_explosions", "&7Toggle explosions."); + setMessage(config, sb, "buildsystem_gamemode", "&7Change your gamemode."); + setMessage(config, sb, "buildsystem_night", "&7Set a world's time to nighttime."); + setMessage(config, sb, "buildsystem_noai", "&7Toggle entity AIs."); + setMessage(config, sb, "buildsystem_physics", "&7Toggle block physics."); + setMessage(config, sb, "buildsystem_settings", "&7Manage user settings."); + setMessage(config, sb, "buildsystem_setup", "&7Change the default items when creating worlds."); + setMessage(config, sb, "buildsystem_skull", "&7Receive a player or custom skull."); + setMessage(config, sb, "buildsystem_speed", "&7Change your flying/walking speed."); + setMessage(config, sb, "buildsystem_spawn", "&7Teleport to the spawn."); + setMessage(config, sb, "buildsystem_top", "&7Teleport to the the highest location above you."); + setMessage(config, sb, "buildsystem_worlds", "&7An overview of all &o/worlds &7commands."); + addSpacer(sb, ""); + addSpacer(sb, "# /config"); + setMessage(config, sb, "config_usage", "%prefix% &7Usage: &b/config reload"); + setMessage(config, sb, "config_reloaded", "%prefix% &7The config was reloaded."); + addSpacer(sb, ""); + addSpacer(sb, "# /explosions"); + setMessage(config, sb, "explosions_usage", "%prefix% &7Usage: &b/explosions "); + setMessage(config, sb, "explosions_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "explosions_world_not_imported", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "explosions_activated", "%prefix% &7Explosions in &b%world% &7were &aactivated&7."); + setMessage(config, sb, "explosions_deactivated", "%prefix% &7Explosions in &b%world% &7were &cdeactivated&7."); + setMessage(config, sb, "explosions_deactivated_in_world", "%prefix% &7Explosions in &b%world% &7are currently &cdeactivated&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /gamemode"); + setMessage(config, sb, "gamemode_usage", "%prefix% &7Usage: &b/gamemode [player]"); + setMessage(config, sb, "gamemode_player_not_found", "%prefix% &cThat player was not found."); + setMessage(config, sb, "gamemode_survival", "Survival"); + setMessage(config, sb, "gamemode_creative", "Creative"); + setMessage(config, sb, "gamemode_adventure", "Adventure"); + setMessage(config, sb, "gamemode_spectator", "Spectator"); + setMessage(config, sb, "gamemode_set_self", "%prefix% &7Your gamemode was set to &b%gamemode%&7."); + setMessage(config, sb, "gamemode_set_other", "%prefix% &b%target%&7's gamemode was set to &b%gamemode%&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /physics"); + setMessage(config, sb, "physics_usage", "%prefix% &7Usage: &b/physics "); + setMessage(config, sb, "physics_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "physics_world_not_imported", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "physics_activated", "%prefix% &7Physics in &b%world% &7were &aactivated&7."); + setMessage(config, sb, "physics_activated_all", "%prefix% &7Physics in &ball worlds &7were &aactivated&7."); + setMessage(config, sb, "physics_deactivated", "%prefix% &7Physics in &b%world% &7were &cdeactivated&7."); + setMessage(config, sb, "physics_deactivated_in_world", "%prefix% &7Physics in &b%world% &7are currently &cdeactivated&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /noai"); + setMessage(config, sb, "noai_usage", "%prefix% &7Usage: &b/noai "); + setMessage(config, sb, "noai_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "noai_world_not_imported", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "noai_deactivated", "%prefix% &7Entity AIs in &b%world% &7were &aactivated&7."); + setMessage(config, sb, "noai_activated", "%prefix% &7Entity AIs in &b%world% &7were &cdeactivated&7."); + setMessage(config, sb, "noai_activated_in_world", "%prefix% &7Entity AIs in &b%world% &7are currently &cdeactivated&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /skull"); + setMessage(config, sb, "skull_usage", "%prefix% &7Usage: &b/skull [name]"); + setMessage(config, sb, "skull_player_received", "%prefix% &7You received the skull of &b%player%&7."); + setMessage(config, sb, "skull_custom_received", "%prefix% &7You received a &bcustom skull&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /spawn"); + setMessage(config, sb, "spawn_usage", "%prefix% &7Usage: &b/spawn"); + setMessage(config, sb, "spawn_admin", "%prefix% &7Usage: &b/spawn [set/remove]"); + setMessage(config, sb, "spawn_teleported", "%prefix% &7You were teleported to the &bspawn&7."); + setMessage(config, sb, "spawn_unavailable", "%prefix% &cThere isn't a spawn to teleport to."); + setMessage(config, sb, "spawn_world_not_imported", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "spawn_set", "%prefix% &7Spawn set to &b%x% %y% %z% &7in &b%world%&7."); + setMessage(config, sb, "spawn_remove", "%prefix% &7The spawn was removed."); + addSpacer(sb, ""); + addSpacer(sb, "# /speed"); + setMessage(config, sb, "speed_usage", "%prefix% &7Usage: &b/speed [1-5]"); + setMessage(config, sb, "speed_set_flying", "%prefix% &7Your flying speed was set to &b%speed%&7."); + setMessage(config, sb, "speed_set_walking", "%prefix% &7Your walking speed was set to &b%speed%&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /day"); + setMessage(config, sb, "day_usage", "%prefix% &7Usage: &b/day [world]"); + setMessage(config, sb, "day_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "day_set", "%prefix% &7It is now day in &b%world%&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /night"); + setMessage(config, sb, "night_usage", "%prefix% &7Usage: &b/night [world]"); + setMessage(config, sb, "night_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "night_set", "%prefix% &7It is now night in &b%world%&7."); + addSpacer(sb, ""); + addSpacer(sb, "# /top"); + setMessage(config, sb, "top_usage", "%prefix% &7Usage: &b/top"); + setMessage(config, sb, "top_teleported", "%prefix% &7You were teleported to the &btop&7."); + setMessage(config, sb, "top_failed", "%prefix% &cNo higher location was found."); + addSpacer(sb, ""); + addSpacer(sb, "# /worlds"); + setMessage(config, sb, "worlds_addbuilder_usage", "%prefix% &7Usage: &b/worlds addBuilder [player]"); + setMessage(config, sb, "worlds_addbuilder_unknown_world", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "worlds_addbuilder_player_not_found", "%prefix% &cThat player was not found."); + setMessage(config, sb, "worlds_addbuilder_already_creator", "%prefix% &cYou are already the creator."); + setMessage(config, sb, "worlds_addbuilder_already_added", "%prefix% &cThis player is already a builder."); + setMessage(config, sb, "worlds_addbuilder_added", "%prefix% &b%builder% &7was &aadded &7as a builder."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_backup_usage", "%prefix% &7Usage: &b/worlds backup [create]"); + setMessage(config, sb, "worlds_backup_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_backup_world_not_imported", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "worlds_backup_created", "%prefix% &7Successfully &acreated &7a backup of &b%world%&7."); + setMessage(config, sb, "worlds_backup_failed", "%prefix% &cUnable to create a backup of %world%."); + setMessage(config, sb, "worlds_backup_restoration_in_progress", "%prefix% &7&oThe world you are in is being restored..."); + setMessage(config, sb, "worlds_backup_restoration_successful", "%prefix% &7The world has been successfully reset to the state from &a%timestamp%&7."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_builders_usage", "%prefix% &7Usage: &b/worlds builders "); + setMessage(config, sb, "worlds_builders_unknown_world", "%prefix% &cUnknown world."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_world_name", "World name"); + setMessage(config, sb, "worlds_world_exists", "%prefix% &cThis world already exists."); + setMessage(config, sb, "worlds_world_creation_invalid_characters", "%prefix% &7&oRemoved invalid characters from world name."); + setMessage(config, sb, "worlds_world_creation_name_bank", "%prefix% &cThe world name cannot be blank."); + setMessage(config, sb, "worlds_world_creation_started", "%prefix% &7The creation of &b%world% &8(&7Type: &f%type%&8) &7has started..."); + setMessage(config, sb, "worlds_template_creation_started", "%prefix% &7The creation of &b%world% &8(&7Template: &f%template%&8) &7has started..."); + setMessage(config, sb, "worlds_creation_finished", "%prefix% &7The world was &asuccessfully &7created."); + setMessage(config, sb, "worlds_template_does_not_exist", "%prefix% &cThis template does not exist."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_unknown_command", "%prefix% &7Unknown command: &b/worlds help"); + setMessage(config, sb, "worlds_navigator_open", "%prefix% &cYou have already opened the navigator!"); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_delete_usage", "%prefix% &7Usage: &b/worlds delete "); + setMessage(config, sb, "worlds_delete_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_delete_unknown_directory", "%prefix% &cError while deleting world: Directory not found!"); + setMessage(config, sb, "worlds_delete_forbidden", "%prefix% &cYou are not allowed to delete this world."); + setMessage(config, sb, "worlds_delete_canceled", "%prefix% &7The deletion of &b%world% &7was canceled."); + setMessage(config, sb, "worlds_delete_error", "%prefix% &7cThe deletion of %world% failed."); + setMessage(config, sb, "worlds_delete_started", "%prefix% &7The deletion of &b%world% &7has started..."); + setMessage(config, sb, "worlds_delete_finished", "%prefix% &7The world was &asuccessfully &7deleted."); + setMessage(config, sb, "worlds_delete_players_world", "%prefix% &7&oThe world you were in was deleted."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_edit_usage", "%prefix% &7Usage: &b/worlds edit "); + setMessage(config, sb, "worlds_edit_unknown_world", "%prefix% &cUnknown world."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_folder_usage", "%prefix% &7Usage: &b/worlds folder []"); + setMessage(config, sb, "worlds_folder_unknown_folder", "%prefix% &cUnknown folder."); + setMessage(config, sb, "worlds_folder_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_folder_world_already_in_folder", "%prefix% &c%world% is already contained within %folder%."); + setMessage(config, sb, "worlds_folder_world_already_in_another_folder", "%prefix% &c%world% is already contained within another folder."); + setMessage(config, sb, "worlds_folder_world_category_mismatch", "%prefix% &cThe folder's category (%folder_category%) does not match the world's category (%world_category%)."); + setMessage(config, sb, "worlds_folder_world_not_in_folder", "%prefix% &c%world% is not contained within %folder%."); + setMessage(config, sb, "worlds_folder_world_added_to_folder", "%prefix% &b%world% &7was &aadded &7to &b%folder%&7."); + setMessage(config, sb, "worlds_folder_world_removed_from_folder", "%prefix% &b%world% &7was &cremoved &7from &b%folder%&7."); + setMessage(config, sb, "worlds_folder_exists", "%prefix% &cThis folder already exists."); + setMessage(config, sb, "worlds_folder_creation_invalid_characters", "%prefix% &7&oRemoved invalid characters from folder name."); + setMessage(config, sb, "worlds_folder_creation_name_bank", "%prefix% &cThe folder name cannot be blank."); + setMessage(config, sb, "worlds_folder_created", "%prefix% &7The folder &b%folder% &7was &asuccessfully &7created."); + setMessage(config, sb, "worlds_folder_not_empty", "%prefix% &cThe folder %folder% is not empty."); + setMessage(config, sb, "worlds_folder_deleted", "%prefix% &7The folder &b%folder% &7was &asuccessfully &7deleted."); + setMessage(config, sb, "worlds_folder_permission_set", "%prefix% &b%folder%&7's permission was successfully changed."); + setMessage(config, sb, "worlds_folder_project_set", "%prefix% &b%folder%&7's project was successfully changed."); + setMessage(config, sb, "worlds_folder_item_set", "%prefix% &b%folder%&7's item was successfully changed."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_help_usage", "%prefix% &7Usage: &b/worlds help [page]"); + setMessage(config, sb, "worlds_help_invalid_page", "%prefix% &cInvalid page."); + setMessage(config, sb, "worlds_help_title_with_page", "%prefix% &7&nWorlds Help:&8 (&7%page%/%max%&8)"); + setMessage(config, sb, "worlds_help_permission", "&7&nPermission&8: &b%permission%"); + setMessage(config, sb, "worlds_help_help", "&7Shows the list of all subcommands."); + setMessage(config, sb, "worlds_help_info", "&7Shows information about a world."); + setMessage(config, sb, "worlds_help_item", "&7Receive the 'World Navigator'."); + setMessage(config, sb, "worlds_help_tp", "&7Teleport to another world."); + setMessage(config, sb, "worlds_help_edit", "&7Opens the world editor."); + setMessage(config, sb, "worlds_help_addbuilder", "&7Add a builder to a world."); + setMessage(config, sb, "worlds_help_removebuilder", "&7Remove a builder from a &7world."); + setMessage(config, sb, "worlds_help_builders", "&7Opens a world's list of builders."); + setMessage(config, sb, "worlds_help_rename", "&7Rename an existing world."); + setMessage(config, sb, "worlds_help_setitem", "&7Set a world's item."); + setMessage(config, sb, "worlds_help_setcreator", "&7Set a world's creator."); + setMessage(config, sb, "worlds_help_setproject", "&7Set a world's project."); + setMessage(config, sb, "worlds_help_setpermission", "&7Set a world's permission."); + setMessage(config, sb, "worlds_help_setstatus", "&7Set a world's status."); + setMessage(config, sb, "worlds_help_setspawn", "&7Set a world's spawnpoint."); + setMessage(config, sb, "worlds_help_removespawn", "&7Removes a world's spawnpoint."); + setMessage(config, sb, "worlds_help_delete", "&7Delete a world."); + setMessage(config, sb, "worlds_help_import", "&7Import a world."); + setMessage(config, sb, "worlds_help_importall", "&7Import all worlds at once."); + setMessage(config, sb, "worlds_help_unimport", "&7Unimport a world."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_import_usage", "%prefix% &7Usage: &b/worlds import [-g | -c ]"); + setMessage(config, sb, "worlds_import_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_import_world_is_imported", "%prefix% &cThis world is already imported."); + setMessage(config, sb, "worlds_import_unknown_generator", "%prefix% &cUnknown generator."); + setMessage(config, sb, "worlds_import_player_not_found", "%prefix% &cThat player was not found."); + setMessage(config, sb, "worlds_import_newer_version", "%prefix% &b%world% &7was created in a &cnewer version &7of Minecraft. Unable to import."); + setMessage(config, sb, "worlds_import_started", "%prefix% &7The import of &b%world% &7has started..."); + setMessage(config, sb, "worlds_import_invalid_character", "%prefix% &7Unable to import &c%world%&7.\n" + + "%prefix% &7&oName contains invalid character: &c%char%"); + setMessage(config, sb, "worlds_import_finished", "%prefix% &7The world was &asuccessfully &7imported."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_importall_usage", "%prefix% &7Usage: &b/worlds importall [-g | -c ]"); + setMessage(config, sb, "worlds_importall_no_worlds", "%prefix% &cNo worlds were found."); + setMessage(config, sb, "worlds_importall_started", "%prefix% &7Beginning import of &b%amount% &7worlds..."); + setMessage(config, sb, "worlds_importall_delay", "%prefix% &8➥ &7Delay between each world: &b%delay%s&7."); + setMessage(config, sb, "worlds_importall_already_started", "%prefix% &cAll worlds are already being imported."); + setMessage(config, sb, "worlds_importall_player_not_found", "%prefix% &cThat player was not found."); + setMessage(config, sb, "worlds_importall_invalid_character", "%prefix% &c✘ &7&o%world% &7contains invalid character &8(&c%char%&8)"); + setMessage(config, sb, "worlds_importall_world_already_imported", "%prefix% &c✘ &7World already imported: &b%world%"); + setMessage(config, sb, "worlds_importall_newer_version", "%prefix% &c✘ &b%world% &7was created in a &cnewer version &7of Minecraft"); + setMessage(config, sb, "worlds_importall_world_imported", "%prefix% &a✔ &7World imported: &b%world%"); + setMessage(config, sb, "worlds_importall_finished", "%prefix% &7All worlds have been &asuccessfully &7imported."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_info_usage", "%prefix% &7Usage: &b/worlds info [world]"); + setMessage(config, sb, "worlds_info_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "world_info", Arrays.asList( + "&7&m-------------------------------------", + "%prefix% &7&nWorld info:&b %world%", + " ", + " &8- &7UUID: &b%uuid%", + " &8- &7Creator: &b%creator%", + " &8- &7Type: &b%type%", + " &8- &7Private: &b%private%", + " &8- &7Builders enabled: &b%builders_enabled%", + " &8- &7Builders: &b%builders%", + " &8- &7Item: &b%item%", + " &8- &7Status: &b%status%", + " &8- &7Project: &b%project%", + " &8- &7Permission: &b%permission%", + " &8- &7Time: &b%time%", + " &8- &7Creation date: &b%creation%", + " &8- &7Physics: &b%physics%", + " &8- &7Explosions: &b%explosions%", + " &8- &7Block breaking: &b%block_breaking%", + " &8- &7Block placement: &b%block_placement%", + " &8- &7MobAI: &b%mobai%", + " &8- &7Custom spawn: &b%custom_spawn%", + "&7&m-------------------------------------")); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_item_receive", "%prefix% &7You received the &bNavigator&7."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_removebuilder_usage", "%prefix% &7Usage: &b/worlds removeBuilder [player]"); + setMessage(config, sb, "worlds_removebuilder_error", "%prefix% &cError: Please try again!"); + setMessage(config, sb, "worlds_removebuilder_unknown_world", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "worlds_removebuilder_player_not_found", "%prefix% &cThat player was not found."); + setMessage(config, sb, "worlds_removebuilder_not_yourself", "%prefix% &cYou cannot remove yourself as creator."); + setMessage(config, sb, "worlds_removebuilder_not_builder", "%prefix% &cThis player is not a builder."); + setMessage(config, sb, "worlds_removebuilder_removed", "%prefix% &b%builder% &7was &cremoved &7as a builder."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_rename_usage", "%prefix% &7Usage: &b/worlds rename "); + setMessage(config, sb, "worlds_rename_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_rename_error", "%prefix% &cPlease try again."); + setMessage(config, sb, "worlds_rename_same_name", "%prefix% &cThis is the world's current name."); + setMessage(config, sb, "worlds_rename_set", "%prefix% &b%oldName% &7was successfully renamed to &b%newName%&7."); + setMessage(config, sb, "worlds_rename_players_world", "%prefix% &7&oThe world you are in is being renamed..."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_setitem_usage", "%prefix% &7Usage: &b/worlds setItem "); + setMessage(config, sb, "worlds_setitem_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_setitem_hand_empty", "%prefix% &cYou do not have an item in your hand."); + setMessage(config, sb, "worlds_setitem_set", "%prefix% &b%world%&7's item was successfully changed."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_setcreator_usage", "%prefix% &7Usage: &b/worlds setCreator "); + setMessage(config, sb, "worlds_setcreator_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_setcreator_error", "%prefix% &cPlease try again."); + setMessage(config, sb, "worlds_setcreator_set", "%prefix% &b%world%&7's creator was successfully changed."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_setproject_usage", "%prefix% &7Usage: &b/worlds setProject "); + setMessage(config, sb, "worlds_setproject_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_setproject_error", "%prefix% &cPlease try again."); + setMessage(config, sb, "worlds_setproject_set", "%prefix% &b%world%&7's project was successfully changed."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_setstatus_usage", "%prefix% &7Usage: &b/worlds setStatus "); + setMessage(config, sb, "worlds_setstatus_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_setstatus_set", "%prefix% &b%world%&7's status was was changed to: %status%&7."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_setpermission_usage", "%prefix% &7Usage: &b/worlds setPermission "); + setMessage(config, sb, "worlds_setpermission_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_setpermission_error", "%prefix% &cPlease try again."); + setMessage(config, sb, "worlds_setpermission_set", "%prefix% &b%world%&7's permission was successfully changed."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_setspawn_world_not_imported", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "worlds_setspawn_world_spawn_set", "%prefix% &b%world%&7's spawnpoint was set."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_removespawn_world_not_imported", "%prefix% &cWorld must be imported » /worlds import "); + setMessage(config, sb, "worlds_removespawn_world_spawn_removed", "%prefix% &b%world%&7's spawnpoint was removed."); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_tp_usage", "%prefix% &7Usage: &b/worlds tp "); + setMessage(config, sb, "worlds_tp_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_tp_world_not_imported", "%prefix% &cWorld must be imported: /worlds import "); + setMessage(config, sb, "worlds_tp_entry_forbidden", "%prefix% &cYou are not allowed to enter this world!"); + addSpacer(sb, ""); + setMessage(config, sb, "worlds_unimport_usage", "%prefix% &7Usage: &b/worlds unimport "); + setMessage(config, sb, "worlds_unimport_unknown_world", "%prefix% &cUnknown world."); + setMessage(config, sb, "worlds_unimport_players_world", "%prefix% &7&oThe world you were in was unimported."); + setMessage(config, sb, "worlds_unimport_finished", "%prefix% &b%world% &7has been &aunimported&7."); + addSpacer(sb, ""); + addSpacer(sb, ""); + addSpacer(sb, "# ---------"); + addSpacer(sb, "# Items"); + addSpacer(sb, "# ---------"); + setMessage(config, sb, "navigator_item", "&b&lNavigator"); + setMessage(config, sb, "barrier_item", "&c&lClose Inventory"); + setMessage(config, sb, "custom_skull_item", "&bCustom Skull"); + addSpacer(sb, ""); + addSpacer(sb, ""); + addSpacer(sb, "# ---------"); + addSpacer(sb, "# GUIs"); + addSpacer(sb, "# ---------"); + addSpacer(sb, "# Multi-page inventory"); + setMessage(config, sb, "gui_previous_page", "&b« &7Previous Page"); + setMessage(config, sb, "gui_next_page", "&7Next Page &b»"); + addSpacer(sb, ""); + addSpacer(sb, "# Old Navigator"); + setMessage(config, sb, "old_navigator_title", "&3» &8Navigator"); + setMessage(config, sb, "old_navigator_world_navigator", "&aWorld Navigator"); + setMessage(config, sb, "old_navigator_world_archive", "&6World Archive"); + setMessage(config, sb, "old_navigator_private_worlds", "&bPrivate Worlds"); + setMessage(config, sb, "old_navigator_settings", "&cSettings"); + addSpacer(sb, ""); + addSpacer(sb, "# New Navigator"); + setMessage(config, sb, "new_navigator_world_navigator", "&a&lWORLD NAVIGATOR"); + setMessage(config, sb, "new_navigator_world_archive", "&6&lWORLD ARCHIVE"); + setMessage(config, sb, "new_navigator_private_worlds", "&b&lPRIVATE WORLDS"); + addSpacer(sb, ""); + addSpacer(sb, "# World Navigator"); + setMessage(config, sb, "world_navigator_title", "&3» &8World Navigator"); + setMessage(config, sb, "world_navigator_no_worlds", "&c&nNo worlds available"); + setMessage(config, sb, "world_navigator_create_world", "&bCreate a Public World"); + setMessage(config, sb, "world_navigator_create_folder", "&bCreate a Folder"); + setMessage(config, sb, "world_item_title", "&3&l%world%"); + setMessage(config, sb, "world_item_lore_normal", Arrays.asList( + "&7Status&8: %status%", + "", + "&7Creator&8: &b%creator%", + "&7Project&8: &b%project%", + "&7Permission&8: &b%permission%", + "", + "&7Builders&8:", + "%builders%" + )); + setMessage(config, sb, "world_item_lore_edit", Arrays.asList( + "&7Status&8: %status%", + "", + "&7Creator&8: &b%creator%", + "&7Project&8: &b%project%", + "&7Permission&8: &b%permission%", + "", + "&7Builders&8:", + "%builders%", + "", + "&8- &7&oLeft click&8: &7Teleport", + "&8- &7&oRight click&8: &7Edit" + )); + setMessage(config, sb, "folder_title", "&3» &8%folder%"); + setMessage(config, sb, "folder_item_title", "&3&l%folder%"); + setMessage(config, sb, "folder_item_lore", Arrays.asList( + "&7Project&8: &b%project%", + "&7Permission&8: &b%permission%", + "", + "&7Worlds&8: &b%worlds%" + )); + setMessage(config, sb, "world_item_builders_builder_template", "&b%builder%&7, "); + setMessage(config, sb, "world_sort_title", "&bSort"); + setMessage(config, sb, "world_sort_name_az", "&8» &7&oName (A-Z)"); + setMessage(config, sb, "world_sort_name_za", "&8» &7&oName (Z-A)"); + setMessage(config, sb, "world_sort_project_az", "&8» &7&oProject (A-Z)"); + setMessage(config, sb, "world_sort_project_za", "&8» &7&oProject (Z-A)"); + setMessage(config, sb, "world_sort_status_not_started", "&8» &7&oNot Started &8&o➡ &7&oFinished"); + setMessage(config, sb, "world_sort_status_finished", "&8» &7&oFinished &8&o➡ &7&oNot Started"); + setMessage(config, sb, "world_sort_date_newest", "&8» &7&oCreation date (Newest)"); + setMessage(config, sb, "world_sort_date_oldest", "&8» &7&oCreation date (Oldest)"); + setMessage(config, sb, "world_filter_title", "&bFilter"); + setMessage(config, sb, "world_filter_mode_none", "&8» &7&oNone"); + setMessage(config, sb, "world_filter_mode_starts_with", "&8» &7&oStarts with: &b&o%text%"); + setMessage(config, sb, "world_filter_mode_contains", "&8» &7&oContains: &b&o%text%"); + setMessage(config, sb, "world_filter_mode_matches", "&8» &7&oMatches: &b&o%text%"); + setMessage(config, sb, "world_filter_lore", Arrays.asList( + "", + "&8- &7&oLeft click&8: &7Change text", + "&8- &7&oRight click&8: &7Change mode", + "&8- &7&oShift click&8: &7Reset to default" + )); + addSpacer(sb, ""); + addSpacer(sb, "# World Archive"); + setMessage(config, sb, "archive_title", "&3» &8World Archive"); + setMessage(config, sb, "archive_no_worlds", "&c&nNo worlds available"); + addSpacer(sb, ""); + addSpacer(sb, "# Private Worlds"); + setMessage(config, sb, "private_title", "&3» &8Private Worlds"); + setMessage(config, sb, "private_no_worlds", "&c&nNo worlds available"); + setMessage(config, sb, "private_create_world", "&bCreate a Private World"); + addSpacer(sb, ""); + addSpacer(sb, "# World Backups"); + setMessage(config, sb, "backups_title", "&3» &8World Backups"); + setMessage(config, sb, "backups_information_name", "&aInformation"); + setMessage(config, sb, "backups_information_lore", Arrays.asList( + "", + "&7A backup is automatically created", + "&7every &a%interval% minutes &7in which a", + "&7builder is present in the world.", + "", + "&7Next backup in: &a%remaining%" + )); + setMessage(config, sb, "backups_backup_name", "&a%timestamp%"); + setMessage(config, sb, "restore_backup_title", "&3» &8Restore Backup"); + setMessage(config, sb, "restore_backup_confirm_name", "&aConfirm"); + setMessage(config, sb, "restore_backup_confirm_lore", Arrays.asList( + "", + "&7Are you sure you to &arestore &7to the", + "&7backup from &f%timestamp%&7?", + "", + "&c&nWarning&c: &7This action &cCANNOT &7be undone." + )); + setMessage(config, sb, "restore_backup_cancel_name", "&cCancel"); + addSpacer(sb, ""); + addSpacer(sb, "# Setup"); + setMessage(config, sb, "setup_title", "&3» &8Setup"); + setMessage(config, sb, "setup_default_item_name", "&bDefault Item"); + setMessage(config, sb, "setup_default_item_lore", Arrays.asList( + "&7The item which a world", + "&7has by default when created.", + "", + "&7&nTo change&7:", + "&8» &7&oDrag new item onto old one" + )); + setMessage(config, sb, "setup_status_item_name", "&bStatus Item"); + setMessage(config, sb, "setup_status_item_name_lore", Arrays.asList( + "&7The item which is shown when", + "&7you change a world's status.", + "", + "&7&nTo change&7:", + "&8» &7&oDrag new item onto old one" + )); + setMessage(config, sb, "setup_normal_world", "&bNormal World"); + setMessage(config, sb, "setup_flat_world", "&aFlat World"); + setMessage(config, sb, "setup_nether_world", "&cNether World"); + setMessage(config, sb, "setup_end_world", "&eEnd World"); + setMessage(config, sb, "setup_void_world", "&fEmpty World"); + setMessage(config, sb, "setup_imported_world", "&7Imported World"); + addSpacer(sb, ""); + addSpacer(sb, "# Create World"); + setMessage(config, sb, "create_title", "&3» &8Create World"); + setMessage(config, sb, "create_predefined_worlds", "&6Predefined Worlds"); + setMessage(config, sb, "create_templates", "&6Templates"); + setMessage(config, sb, "create_generators", "&6Generators"); + setMessage(config, sb, "create_no_templates", "&c&nNo templates available"); + setMessage(config, sb, "create_normal_world", "&bNormal World"); + setMessage(config, sb, "create_flat_world", "&aFlat World"); + setMessage(config, sb, "create_nether_world", "&cNether World"); + setMessage(config, sb, "create_end_world", "&eEnd World"); + setMessage(config, sb, "create_void_world", "&fEmpty World"); + setMessage(config, sb, "create_template", "&e%template%"); + setMessage(config, sb, "create_generators_create_world", "&eCreate world with generator"); + addSpacer(sb, ""); + addSpacer(sb, "# World Type"); + setMessage(config, sb, "type_normal", "Normal"); + setMessage(config, sb, "type_flat", "Flat"); + setMessage(config, sb, "type_nether", "Nether"); + setMessage(config, sb, "type_end", "End"); + setMessage(config, sb, "type_void", "Void"); + setMessage(config, sb, "type_custom", "Custom"); + setMessage(config, sb, "type_template", "Template"); + setMessage(config, sb, "type_private", "Private"); + setMessage(config, sb, "type_imported", "Imported"); + addSpacer(sb, ""); + addSpacer(sb, "# World Status"); + setMessage(config, sb, "status_title", "&8Status &7» &3%world%"); + setMessage(config, sb, "status_not_started", "&cNot Started"); + setMessage(config, sb, "status_in_progress", "&6In Progress"); + setMessage(config, sb, "status_almost_finished", "&aAlmost Finished"); + setMessage(config, sb, "status_finished", "&2Finished"); + setMessage(config, sb, "status_archive", "&9Archive"); + setMessage(config, sb, "status_hidden", "&fHidden"); + addSpacer(sb, ""); + addSpacer(sb, "# World Difficulty"); + setMessage(config, sb, "difficulty_peaceful", "&fPeaceful"); + setMessage(config, sb, "difficulty_easy", "&aEasy"); + setMessage(config, sb, "difficulty_normal", "&6Normal"); + setMessage(config, sb, "difficulty_hard", "&cHard"); + addSpacer(sb, ""); + addSpacer(sb, "# Delete World"); + setMessage(config, sb, "delete_title", "&3» &8Delete World"); + setMessage(config, sb, "delete_world_name", "&e%world%"); + setMessage(config, sb, "delete_world_name_lore", Arrays.asList( + "", + "&c&nWarning&c: &7&oOnce a world is", + "&7&odeleted it is lost forever", + "&7&oand cannot be recovered!" + )); + setMessage(config, sb, "delete_world_cancel", "&cCancel"); + setMessage(config, sb, "delete_world_confirm", "&aConfirm"); + addSpacer(sb, ""); + addSpacer(sb, "# World Editor"); + setMessage(config, sb, "worldeditor_title", "&3» &8World Editor"); + setMessage(config, sb, "worldeditor_world_item", "&3&l%world%"); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_blockbreaking_item", "&bBlock Breaking"); + setMessage(config, sb, "worldeditor_blockbreaking_lore", Arrays.asList( + "&7&oToggle whether or not blocks", + "&7&oare able to be broken." + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_blockplacement_item", "&bBlock Placement"); + setMessage(config, sb, "worldeditor_blockplacement_lore", Arrays.asList( + "&7&oToggle whether or not blocks", + "&7&oare able to be placed." + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_physics_item", "&bBlock Physics"); + setMessage(config, sb, "worldeditor_physics_lore", Arrays.asList( + "&7&oToggle whether or not block", + "&7&ophysics are activated." + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_time_item", "&bTime"); + setMessage(config, sb, "worldeditor_time_lore", Arrays.asList( + "&7&oAlter the time of day", + "&7&oin the world.", + "", + "&7&nCurrently&7: %time%" + )); + setMessage(config, sb, "worldeditor_time_lore_sunrise", "&6Sunrise"); + setMessage(config, sb, "worldeditor_time_lore_noon", "&eNoon"); + setMessage(config, sb, "worldeditor_time_lore_night", "&9Night"); + setMessage(config, sb, "worldeditor_time_lore_unknown", "&fUnknown"); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_explosions_item", "&bExplosions"); + setMessage(config, sb, "worldeditor_explosions_lore", Arrays.asList( + "&7&oToggle whether or not", + "&7&oexplosions are activated." + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_butcher_item", "&bButcher"); + setMessage(config, sb, "worldeditor_butcher_lore", Collections.singletonList("&7&oKill all the mobs in the world.")); + setMessage(config, sb, "worldeditor_butcher_removed", "%prefix% &b%amount% &7mobs were removed."); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_builders_item", "&bBuilders"); + setMessage(config, sb, "worldeditor_builders_lore", Arrays.asList( + "&7&oManage which players can", + "&7&obuild in the world.", + "", + "&8- &7&oLeft click&8: &7Toggle feature", + "&8- &7&oRight click&8: &7Manage builders")); + setMessage(config, sb, "worldeditor_builders_not_creator_item", "&c&mBuilders"); + setMessage(config, sb, "worldeditor_builders_not_creator_lore", Arrays.asList( + "&7&oYou are not the creator", + "&7&oof this world." + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_builders_title", "&3» &8Builders"); + setMessage(config, sb, "worldeditor_builders_creator_item", "&7&nCreator&7:"); + setMessage(config, sb, "worldeditor_builders_creator_lore", "&8» &b%creator%"); + setMessage(config, sb, "worldeditor_builders_no_creator_item", "&cWorld has no creator!"); + setMessage(config, sb, "worldeditor_builders_builder_item", "&b%builder%"); + setMessage(config, sb, "worldeditor_builders_builder_lore", Collections.singletonList("&8- &7&oShift click&8: &7Remove")); + setMessage(config, sb, "worldeditor_builders_add_builder_item", "&bAdd builder"); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_gamerules_item", "&bGamerules"); + setMessage(config, sb, "worldeditor_gamerules_lore", Collections.singletonList("&7&oManage the world's gamerules")); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_gamerules_title", "&3» &8Gamerules"); + setMessage(config, sb, "worldeditor_gamerules_boolean_enabled", Collections.singletonList("&7&nCurrently&7: &atrue")); + setMessage(config, sb, "worldeditor_gamerules_boolean_disabled", Collections.singletonList("&7&nCurrently&7: &cfalse")); + setMessage(config, sb, "worldeditor_gamerules_integer", Arrays.asList( + "&7&nCurrently&7: &e%value%", + "", + "&8- &7&oLeft Click&8: &7decrease by 1", + "&8- &7&oShift &7+ &7&oLeft Click&8: &7decrease by 10", + "&8- &7&oRight Click&8: &7increase by 1", + "&8- &7&oShift &7+ &7&oRight Click&8: &7increase by 10")); + setMessage(config, sb, "worldsettings_gamerule_", Arrays.asList( + "", + "&c&nWarning&c: &7&oOnce a world is", + "&7&odeleted it is lost forever", + "&7&oand cannot be recovered!" + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_visibility_item", "&bVisibility"); + setMessage(config, sb, "worldeditor_visibility_lore_public", Arrays.asList( + "&7&oChange the world's visibility", + "", + "&7&nCurrently&7: &bPublic")); + setMessage(config, sb, "worldeditor_visibility_lore_private", Arrays.asList( + "&7&oChange the world's visibility", + "", + "&7&nCurrently&7: &bPrivate" + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_mobai_item", "&bMob AI"); + setMessage(config, sb, "worldeditor_mobai_lore", Collections.singletonList("&7&oToggle whether mobs have an AI.")); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_blockinteractions_item", "&bBlock Interactions"); + setMessage(config, sb, "worldeditor_blockinteractions_lore", Arrays.asList( + "&7&oToggle whether interactions", + "&7&owith blocks are cancelled." + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_difficulty_item", "&bDifficulty"); + setMessage(config, sb, "worldeditor_difficulty_lore", Arrays.asList( + "&7&oChange the world's difficulty.", + "", + "&7&nCurrently&7: %difficulty%" + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_status_item", "&bStatus"); + setMessage(config, sb, "worldeditor_status_lore", Arrays.asList( + "&7&oChange the world's status.", + "", + "&7&nCurrently&7: %status%" + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_project_item", "&bProject"); + setMessage(config, sb, "worldeditor_project_lore", Arrays.asList( + "&7&oChange the world's project.", + "", + "&7&nCurrently&7: &b%project%" + )); + addSpacer(sb, ""); + setMessage(config, sb, "worldeditor_permission_item", "&bPermission"); + setMessage(config, sb, "worldeditor_permission_lore", Arrays.asList( + "&7&oChange the world's permission.", + "", + "&7&nCurrently&7: &b%permission%" + )); + addSpacer(sb, ""); + addSpacer(sb, "# Settings"); + setMessage(config, sb, "settings_title", "&3» &8Settings"); + addSpacer(sb, ""); + setMessage(config, sb, "settings_change_design_item", "&bChange Design"); + setMessage(config, sb, "settings_change_design_lore", Arrays.asList( + "&7&oSelect which colour the", + "&7&oglass panes should have." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_clear_inventory_item", "&bClear Inventory"); + setMessage(config, sb, "settings_clear_inventory_lore", Arrays.asList( + "&7&oWhen enabled, a player's", + "&7&oinventory is cleared on join." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_disableinteract_item", "&bDisable Block Interactions"); + setMessage(config, sb, "settings_disableinteract_lore", Arrays.asList( + "&7&oWhen enabled, interactions with", + "&7&ocertain blocks are disabled." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_hideplayers_item", "&bHide Players"); + setMessage(config, sb, "settings_hideplayers_lore", Arrays.asList( + "&7&oWhen enabled, all online", + "&7&oplayers will be hidden." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_instantplacesigns_item", "&bInstant Place Signs"); + setMessage(config, sb, "settings_instantplacesigns_lore", Arrays.asList( + "&7&oWhen enabled, signs are placed", + "&7&owithout opening the text input." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_keep_navigator_item", "&bKeep Navigator"); + setMessage(config, sb, "settings_keep_navigator_lore", Arrays.asList( + "&7&oWhen enabled, the navigator", + "&7&owill remain in your inventory", + "&7&oeven when you clear it." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_new_navigator_item", "&bNew Navigator"); + setMessage(config, sb, "settings_new_navigator_lore", Arrays.asList( + "&7&oA new and improved navigator", + "&7&owhich is no longer a GUI." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_nightvision_item", "&bNightvision"); + setMessage(config, sb, "settings_nightvision_lore", Arrays.asList( + "&7&oWhen enabled, you will", + "&7&oreceive permanent night vision." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_no_clip_item", "&bNoClip"); + setMessage(config, sb, "settings_no_clip_lore", Arrays.asList( + "&7&oWhen flying against a wall, you", + "&7&owill be put into spectator mode." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_open_trapdoors_item", "&bOpen Trapdoors"); + setMessage(config, sb, "settings_open_trapdoors_lore", Arrays.asList( + "&7&oWhen right clicking iron (trap-)doors,", + "&7&othey will be opened/closed." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_placeplants_item", "&bPlace Plants"); + setMessage(config, sb, "settings_placeplants_lore", Arrays.asList( + "&7&oWhen enabled, you can place", + "&7&oplants on every kind of block." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_scoreboard_item", "&bScoreboard"); + setMessage(config, sb, "settings_scoreboard_lore", Arrays.asList( + "&7&oA scoreboard which provides", + "&7&oyou with useful information." + )); + setMessage(config, sb, "settings_scoreboard_disabled_item", "&c&mScoreboard"); + setMessage(config, sb, "settings_scoreboard_disabled_lore", Arrays.asList( + "&7&oThe scoreboard has been", + "&7&odisabled in the config." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_slab_breaking_item", "&bSlab breaking"); + setMessage(config, sb, "settings_slab_breaking_lore", Arrays.asList( + "&7&oWhen breaking double slabs,", + "&7&oonly one half will be broken." + )); + addSpacer(sb, ""); + setMessage(config, sb, "settings_spawnteleport_item", "&bSpawn Teleport"); + setMessage(config, sb, "settings_spawnteleport_lore", Arrays.asList( + "&7&oWhen enabled, you will be", + "&7&oteleported to the spawn", + "&7&oif it has been set." + )); + addSpacer(sb, ""); + addSpacer(sb, "# Change Design"); + setMessage(config, sb, "design_title", "&3» &8Change Design"); + setMessage(config, sb, "design_red", "Red"); + setMessage(config, sb, "design_orange", "Orange"); + setMessage(config, sb, "design_yellow", "Yellow"); + setMessage(config, sb, "design_pink", "Pink"); + setMessage(config, sb, "design_magenta", "Magenta"); + setMessage(config, sb, "design_purple", "Purple"); + setMessage(config, sb, "design_brown", "Brown"); + setMessage(config, sb, "design_lime", "Lime"); + setMessage(config, sb, "design_green", "Green"); + setMessage(config, sb, "design_blue", "Blue"); + setMessage(config, sb, "design_aqua", "Aqua"); + setMessage(config, sb, "design_light_blue", "Light Blue"); + setMessage(config, sb, "design_white", "White"); + setMessage(config, sb, "design_grey", "Grey"); + setMessage(config, sb, "design_dark_grey", "Dark Grey"); + setMessage(config, sb, "design_black", "Black"); + addSpacer(sb, ""); + addSpacer(sb, "# Speed"); + setMessage(config, sb, "speed_title", "&3» &8Speed"); + setMessage(config, sb, "speed_1", "&b1"); + setMessage(config, sb, "speed_2", "&b2"); + setMessage(config, sb, "speed_3", "&b3"); + setMessage(config, sb, "speed_4", "&b4"); + setMessage(config, sb, "speed_5", "&b5"); + addSpacer(sb, ""); + addSpacer(sb, "# Secret Blocks"); + setMessage(config, sb, "blocks_title", "&3» &8Secret Blocks"); + setMessage(config, sb, "blocks_full_oak_barch", "&bFull Oak Bark"); + setMessage(config, sb, "blocks_full_spruce_barch", "&bFull Spruce Bark"); + setMessage(config, sb, "blocks_full_birch_barch", "&bFull Birch Bark"); + setMessage(config, sb, "blocks_full_jungle_barch", "&bFull Jungle Bark"); + setMessage(config, sb, "blocks_full_acacia_barch", "&bFull Acacia Bark"); + setMessage(config, sb, "blocks_full_dark_oak_barch", "&bFull Dark Oak Bark"); + setMessage(config, sb, "blocks_red_mushroom", "&bRed Mushroom"); + setMessage(config, sb, "blocks_brown_mushroom", "&bBrown Mushroom"); + setMessage(config, sb, "blocks_full_mushroom_stem", "&bFull Mushroom Stem"); + setMessage(config, sb, "blocks_mushroom_stem", "&bMushroom Stem"); + setMessage(config, sb, "blocks_mushroom_block", "&bMushroom Block"); + setMessage(config, sb, "blocks_smooth_stone", "&bSmooth Stone"); + setMessage(config, sb, "blocks_double_stone_slab", "&bDouble Stone Slab"); + setMessage(config, sb, "blocks_smooth_sandstone", "&bSmooth Sandstone"); + setMessage(config, sb, "blocks_smooth_red_sandstone", "&bSmooth Red Sandstone"); + setMessage(config, sb, "blocks_powered_redstone_lamp", "&bPowered Redstone Lamp"); + setMessage(config, sb, "blocks_burning_furnace", "&bBurning Furnace"); + setMessage(config, sb, "blocks_piston_head", "&bPiston Head"); + setMessage(config, sb, "blocks_command_block", "&bCommand Block"); + setMessage(config, sb, "blocks_barrier", "&bBarrier"); + setMessage(config, sb, "blocks_invisible_item_frame", "&bInvisible Item Frame"); + setMessage(config, sb, "blocks_mob_spawner", "&bMob Spawner"); + setMessage(config, sb, "blocks_nether_portal", "&bNether Portal"); + setMessage(config, sb, "blocks_end_portal", "&bEnd Portal"); + setMessage(config, sb, "blocks_dragon_egg", "&bDragon Egg"); + setMessage(config, sb, "blocks_debug_stick", "&bDebug Stick"); + + try ( + FileOutputStream fileStream = new FileOutputStream(file); + OutputStreamWriter writer = new OutputStreamWriter(fileStream, StandardCharsets.UTF_8) + ) { + writer.write(sb.toString()); + } catch (IOException e) { + PLUGIN.getLogger().log(Level.SEVERE, "Could not write messages.yml file", e); + } + + loadMessages(); + } + + private static void addSpacer(StringBuilder stringBuilder, String value) { + stringBuilder.append(value).append("\n"); + } + + private static void setMessage(YamlConfiguration config, StringBuilder stringBuilder, String key, String defaultValue) { + String value = config.getString(key, defaultValue); + stringBuilder.append(key).append(": \"").append(value).append("\"").append("\n"); + } + + private static void setMessage(YamlConfiguration config, StringBuilder stringBuilder, String key, List defaultValues) { + List values = config.getStringList(key); + if (values.isEmpty()) { + values = defaultValues; + } + + stringBuilder.append(key).append(":\n"); + for (String value : values) { + stringBuilder.append(" - \"").append(value).append("\"").append("\n"); + } + } + + private static void loadMessages() { + if (config == null) { + throw new IllegalStateException("Messages have not been initialized yet"); + } + + ConfigurationSection messagesSection = config.getConfigurationSection(""); + if (messagesSection == null) { + throw new IllegalStateException("Messages section is null in the configuration file"); + } + + messagesSection.getKeys(false).forEach(message -> { + if (config.isList(message)) { + MESSAGES.put(message, String.join("\n", config.getStringList(message))); + } else { + MESSAGES.put(message, Objects.requireNonNull(config.getString(message), "Message key '%s' is null".formatted(message))); + } + }); + } + + public static void reloadMessages() { + MESSAGES.clear(); + createMessageFile(); + } + + private static void checkIfKeyPresent(String key) { + if (!MESSAGES.containsKey(key)) { + Bukkit.getConsoleSender().sendMessage( + ChatColor.RED + "[BuildSystem] Could not find message with key: " + key + ); + createMessageFile(); + } + } + + private static String getPrefix() { + return MESSAGES.get("prefix"); + } + + public static void sendPermissionError(CommandSender sender) { + Messages.sendMessage(sender, "no_permissions"); + } + + @SafeVarargs + public static void sendMessage(CommandSender sender, String key, Entry... placeholders) { + String message = getString(key, sender, placeholders); + if (!message.isEmpty()) { + sender.sendMessage(message); + } + } + + /** + * Gets a message using the given key. + * + * @param key The key of the message + * @param sender The sender to send the message to + * @param placeholders The placeholders which are to be injected + * @return The message uniquely identified by the given key + */ + @SafeVarargs + public static String getString(String key, CommandSender sender, Entry... placeholders) { + checkIfKeyPresent(key); + + String message = MESSAGES.get(key).replace("%prefix%", getPrefix()); + String injectedPlaceholders = replacePlaceholders(message, placeholders); + + if (PLACEHOLDER_API_ENABLED && sender instanceof Player player) { // Sender is not a player if the message is sent to console + injectedPlaceholders = PlaceholderAPI.setPlaceholders(player, injectedPlaceholders); + } + + return ColorAPI.process(injectedPlaceholders); + } + + /** + * Gets a list of messages using the given key and injects the same placeholders into each line. + * + * @param key The key of the message + * @param player The player to parse the placeholders against + * @param placeholders The placeholders which are to be injected into all lines + * @return A list of messages using the given key + */ + @SafeVarargs + public static List getStringList(String key, @Nullable Player player, Entry... placeholders) { + return getStringList(key, player, (line) -> placeholders); + } + + /** + * Gets a list of messages using the given key and injects placeholders into each line. + * + * @param key The key of the message + * @param player The player to parse the placeholders against + * @param placeholders The function which gets the placeholders to be injected into a given line + * @return A list of messages using the given key + */ + @Unmodifiable + public static List getStringList(String key, @Nullable Player player, Function[]> placeholders) { + String message = MESSAGES.get(key).replace("%prefix%", getPrefix()); + return Arrays.stream(message.split("\n")) + .map(line -> replacePlaceholders(line, placeholders.apply(line))) + .map(line -> PLACEHOLDER_API_ENABLED && player != null + ? PlaceholderAPI.setPlaceholders(player, line) + : line + ) + .map(ColorAPI::process) + .toList(); + } + + /** + * Gets the message key for the {@link BuildWorldStatus}'s display name. + * + * @return The type's display name message key + */ + public static String getMessageKey(BuildWorldStatus status) { + return switch (status) { + case NOT_STARTED -> "status_not_started"; + case IN_PROGRESS -> "status_in_progress"; + case ALMOST_FINISHED -> "status_almost_finished"; + case FINISHED -> "status_finished"; + case ARCHIVE -> "status_archive"; + case HIDDEN -> "status_hidden"; + }; + } + + /** + * Get the message key for the {@link BuildWorldType}'s display name. + * + * @return The type's display name message key, or {@code null} for {@link BuildWorldType#IMPORTED} and {@link BuildWorldType#UNKNOWN} + */ + @Nullable + public static String getMessageKey(BuildWorldType type) { + return switch (type) { + case NORMAL -> "type_normal"; + case FLAT -> "type_flat"; + case NETHER -> "type_nether"; + case END -> "type_end"; + case VOID -> "type_void"; + case TEMPLATE -> "type_template"; + case PRIVATE -> "type_private"; + case IMPORTED -> "type_imported"; + case CUSTOM -> "type_custom"; + case UNKNOWN -> null; // No message key for unknown worlds + }; + } + + @SafeVarargs + private static String replacePlaceholders(String query, Entry... placeholders) { + if (placeholders.length == 0) { + return query; + } + + return Arrays.stream(placeholders) + .map(entry -> (Function) data -> data.replaceAll(entry.getKey(), String.valueOf(entry.getValue()))) + .reduce(Function.identity(), Function::andThen) + .apply(query); + } + + public static String formatDate(long millis) { + return millis > 0 + ? new SimpleDateFormat(Config.Messages.dateFormat).format(millis) + : "-"; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/api/BuildSystemApi.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/api/BuildSystemApi.java new file mode 100644 index 0000000..de4d6b0 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/api/BuildSystemApi.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.api; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.PlayerService; +import de.eintosti.buildsystem.api.world.WorldService; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuildSystemApi implements BuildSystem { + + private final BuildSystemPlugin plugin; + + public BuildSystemApi(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public WorldService getWorldService() { + return plugin.getWorldService(); + } + + @Override + public PlayerService getPlayerService() { + return plugin.getPlayerService(); + } + + public void register() { + BuildSystemProvider.register(this); + } + + public void unregister() { + BuildSystemProvider.unregister(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BackCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BackCommand.java new file mode 100644 index 0000000..07b7c52 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BackCommand.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.storage.PlayerStorage; +import io.papermc.lib.PaperLib; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BackCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + private final PlayerStorage playerStorage; + + public BackCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.playerStorage = plugin.getPlayerService().getPlayerStorage(); + plugin.getCommand("back").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.back")) { + Messages.sendPermissionError(player); + return true; + } + + if (args.length == 0) { + teleportBack(player); + } else { + Messages.sendMessage(player, "back_usage"); + } + + return true; + } + + private void teleportBack(Player player) { + BuildPlayer buildPlayer = playerStorage.getBuildPlayer(player); + Location previousLocation = buildPlayer.getPreviousLocation(); + if (previousLocation == null) { + Messages.sendMessage(player, "back_failed"); + return; + } + + PaperLib.teleportAsync(player, previousLocation) + .whenComplete((completed, throwable) -> { + if (!completed) { + return; + } + XSound.ENTITY_ZOMBIE_INFECT.play(player); + Messages.sendMessage(player, "back_teleported"); + buildPlayer.setPreviousLocation(null); + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BlocksCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BlocksCommand.java new file mode 100644 index 0000000..0afdbe6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BlocksCommand.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.player.customblock.CustomBlockInventory; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BlocksCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public BlocksCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("blocks").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.blocks")) { + Messages.sendPermissionError(player); + return true; + } + + XSound.BLOCK_CHEST_OPEN.play(player); + new CustomBlockInventory(plugin).openInventory(player); + return true; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildCommand.java new file mode 100644 index 0000000..a0c7329 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildCommand.java @@ -0,0 +1,140 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.event.world.PlayerBuildModeToggleEvent; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.CachedValues; +import de.eintosti.buildsystem.api.player.PlayerService; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuildCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + private final PlayerService playerService; + + public BuildCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.playerService = plugin.getPlayerService(); + plugin.getCommand("build").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.build")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0 -> { + toggleBuildMode(player, player); + } + + case 1 -> { + if (!player.hasPermission("buildsystem.build.other")) { + Messages.sendPermissionError(player); + return true; + } + + Player target = Bukkit.getPlayer(args[0]); + if (target == null) { + Messages.sendMessage(player, "build_player_not_found"); + return true; + } + + toggleBuildMode(target, player); + } + + default -> { + Messages.sendMessage(player, "build_usage"); + } + } + + return true; + } + + /** + * Toggles the build mode for a target player. + * + * @param target The player whose build mode is being changed + * @param sender The player who initiated the action, may be the target player themselves + */ + private void toggleBuildMode(Player target, Player sender) { + UUID targetUuid = target.getUniqueId(); + boolean isEnteringBuildMode = !playerService.getBuildModePlayers().contains(targetUuid); + + PlayerBuildModeToggleEvent toggleEvent = new PlayerBuildModeToggleEvent(target, isEnteringBuildMode, sender); + Bukkit.getServer().getPluginManager().callEvent(toggleEvent); + if (toggleEvent.isCancelled()) { + return; + } + + BuildPlayer buildPlayer = playerService.getPlayerStorage().getBuildPlayer(target); + CachedValues cachedValues = buildPlayer.getCachedValues(); + + if (isEnteringBuildMode) { + playerService.getBuildModePlayers().add(targetUuid); + cachedValues.saveGameMode(target.getGameMode()); + cachedValues.saveInventory(target.getInventory().getContents()); + target.setGameMode(GameMode.CREATIVE); + + XSound.ENTITY_EXPERIENCE_ORB_PICKUP.play(target); + if (sender.equals(target)) { + Messages.sendMessage(target, "build_activated_self"); + } else { + XSound.ENTITY_EXPERIENCE_ORB_PICKUP.play(sender); + Messages.sendMessage(sender, "build_activated_other_sender", Map.entry("%target%", target.getName())); + Messages.sendMessage(target, "build_activated_other_target", Map.entry("%sender%", sender.getName())); + } + } else { + if (!playerService.getBuildModePlayers().remove(targetUuid)) { + return; + } + + cachedValues.resetGameModeIfPresent(target); + cachedValues.resetInventoryIfPresent(target); + + XSound.ENTITY_EXPERIENCE_ORB_PICKUP.play(target); + if (sender.equals(target)) { + Messages.sendMessage(target, "build_deactivated_self"); + } else { + XSound.ENTITY_EXPERIENCE_ORB_PICKUP.play(sender); + Messages.sendMessage(sender, "build_deactivated_other_sender", Map.entry("%target%", target.getName())); + Messages.sendMessage(target, "build_deactivated_other_target", Map.entry("%sender%", sender.getName())); + } + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildSystemCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildSystemCommand.java new file mode 100644 index 0000000..17c1088 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/BuildSystemCommand.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import com.google.common.collect.Lists; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import java.util.List; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuildSystemCommand extends PagedCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public BuildSystemCommand(BuildSystemPlugin plugin) { + super("buildsystem_title_with_page", "buildsystem_permission"); + + this.plugin = plugin; + plugin.getCommand("buildsystem").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.help.buildsystem")) { + Messages.sendPermissionError(player); + return true; + } + + if (args.length == 0) { + sendMessage(player, 1); + } else if (args.length == 1) { + try { + int page = Integer.parseInt(args[0]); + sendMessage(player, page); + } catch (NumberFormatException e) { + Messages.sendMessage(player, "buildsystem_invalid_page"); + } + } else { + Messages.sendMessage(player, "buildsystem_usage"); + } + return true; + } + + @Override + protected List getCommands(Player player) { + List commands = Lists.newArrayList( + createComponent(player, "/back", "buildsystem_back", "/back", "buildsystem.back"), + createComponent(player, "/blocks", "buildsystem_blocks", "/blocks", "buildsystem.blocks"), + createComponent(player, "/build [player]", "buildsystem_build", "/build", "buildsystem.build"), + createComponent(player, "/config reload", "buildsystem_config", "/config reload", "buildsystem.config"), + createComponent(player, "/day [world]", "buildsystem_day", "/day", "buildsystem.day"), + createComponent(player, "/explosions [world]", "buildsystem_explosions", "/explosions", "buildsystem.explosions"), + createComponent(player, "/gm [player]", "buildsystem_gamemode", "/gm ", "buildsystem.gamemode"), + createComponent(player, "/night [world]", "buildsystem_night", "/night", "buildsystem.night"), + createComponent(player, "/noai [world]", "buildsystem_noai", "/noai", "buildsystem.noai"), + createComponent(player, "/physics [world]", "buildsystem_physics", "/physics", "buildsystem.physics"), + createComponent(player, "/settings", "buildsystem_settings", "/settings", "buildsystem.settings"), + createComponent(player, "/setup", "buildsystem_setup", "/setup", "buildsystem.setup"), + createComponent(player, "/skull [player/id]", "buildsystem_skull", "/skull", "buildsystem.skull"), + createComponent(player, "/spawn", "buildsystem_spawn", "/spawn", "-"), + createComponent(player, "/speed <1-5>", "buildsystem_speed", "/speed ", "buildsystem.speed"), + createComponent(player, "/top", "buildsystem_top", "/top", "buildsystem.top"), + createComponent(player, "/worlds help", "buildsystem_worlds", "/worlds help", "-") + ); + commands.removeIf(textComponent -> textComponent.getText().isEmpty()); + return commands; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ConfigCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ConfigCommand.java new file mode 100644 index 0000000..8ee1e4b --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ConfigCommand.java @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import java.util.Locale; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ConfigCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public ConfigCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("config").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!sender.hasPermission("buildsystem.config")) { + Messages.sendPermissionError(sender); + return true; + } + + if (args.length != 1) { + Messages.sendMessage(sender, "config_usage"); + return true; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "rl": + case "reload": + Messages.reloadMessages(); + plugin.reloadConfigData(true); + Messages.sendMessage(sender, "config_reloaded"); + break; + default: + Messages.sendMessage(sender, "config_usage"); + break; + } + + return true; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ExplosionsCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ExplosionsCommand.java new file mode 100644 index 0000000..546ed73 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/ExplosionsCommand.java @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class ExplosionsCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + private final WorldStorageImpl worldStorage; + + public ExplosionsCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getCommand("explosions").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + String worldName = args.length == 0 ? player.getWorld().getName() : args[0]; + BuildWorld buildWorld = worldStorage.getBuildWorld(worldName); + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, "buildsystem.explosions")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0 -> toggleExplosions(player, player.getWorld()); + case 1 -> toggleExplosions(player, Bukkit.getWorld(args[0])); + default -> Messages.sendMessage(player, "explosions_usage"); + } + + return true; + } + + private void toggleExplosions(Player player, @Nullable World world) { + if (world == null) { + Messages.sendMessage(player, "explosions_unknown_world"); + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(world); + if (buildWorld == null) { + Messages.sendMessage(player, "explosions_world_not_imported"); + return; + } + + WorldData worldData = buildWorld.getData(); + if (!worldData.explosions().get()) { + worldData.explosions().set(true); + Messages.sendMessage(player, "explosions_activated", Map.entry("%world%", buildWorld.getName())); + } else { + worldData.explosions().set(false); + Messages.sendMessage(player, "explosions_deactivated", Map.entry("%world%", buildWorld.getName())); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/GamemodeCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/GamemodeCommand.java new file mode 100644 index 0000000..2ed57e2 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/GamemodeCommand.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import java.util.Locale; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class GamemodeCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public GamemodeCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("gamemode").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (args.length == 0) { + sendUsageMessage(player); + return true; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "survival": + case "s": + case "0": + setGamemode(player, args, GameMode.SURVIVAL, Messages.getString("gamemode_survival", player)); + break; + case "creative": + case "c": + case "1": + setGamemode(player, args, GameMode.CREATIVE, Messages.getString("gamemode_creative", player)); + break; + case "adventure": + case "a": + case "2": + setGamemode(player, args, GameMode.ADVENTURE, Messages.getString("gamemode_adventure", player)); + break; + case "spectator": + case "sp": + case "3": + setGamemode(player, args, GameMode.SPECTATOR, Messages.getString("gamemode_spectator", player)); + break; + default: + sendUsageMessage(player); + break; + } + + return true; + } + + private void setGamemode(Player player, String[] args, GameMode gameMode, String gameModeName) { + switch (args.length) { + case 1 -> this.setPlayerGamemode(player, gameMode, gameModeName); + case 2 -> this.setTargetGamemode(player, args, gameMode, gameModeName); + default -> this.sendUsageMessage(player); + } + } + + private void sendUsageMessage(Player player) { + Messages.sendMessage(player, "gamemode_usage"); + } + + private void setPlayerGamemode(Player player, GameMode gameMode, String gameModeName) { + if (!player.hasPermission("buildsystem.gamemode.%s".formatted(gameMode.name().toLowerCase(Locale.ROOT)))) { + Messages.sendPermissionError(player); + return; + } + + player.setGameMode(gameMode); + Messages.sendMessage(player, "gamemode_set_self", Map.entry("%gamemode%", gameModeName)); + } + + private void setTargetGamemode(Player player, String[] args, GameMode gameMode, String gameModeName) { + if (!player.hasPermission("buildsystem.gamemode.%s.other".formatted(gameMode.name().toLowerCase(Locale.ROOT)))) { + Messages.sendPermissionError(player); + return; + } + + Player target = Bukkit.getPlayerExact(args[1]); + if (target == null) { + Messages.sendMessage(player, "gamemode_player_not_found"); + return; + } + + target.setGameMode(gameMode); + Messages.sendMessage(target, "gamemode_set_self", Map.entry("%gamemode%", gameModeName)); + Messages.sendMessage(player, "gamemode_set_other", + Map.entry("%target%", target.getName()), + Map.entry("%gamemode%", gameModeName) + ); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/NoAICommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/NoAICommand.java new file mode 100644 index 0000000..c0edaec --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/NoAICommand.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class NoAICommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + private final WorldStorageImpl worldStorage; + + public NoAICommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getCommand("noai").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + String worldName = args.length == 0 ? player.getWorld().getName() : args[0]; + BuildWorld buildWorld = worldStorage.getBuildWorld(worldName); + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, "buildsystem.noai")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0 -> toggleAI(player, player.getWorld()); + case 1 -> toggleAI(player, Bukkit.getWorld(args[0])); + default -> Messages.sendMessage(player, "noai_usage"); + } + + return true; + } + + private void toggleAI(Player player, @Nullable World world) { + if (world == null) { + Messages.sendMessage(player, "noai_unknown_world"); + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(world); + if (buildWorld == null) { + Messages.sendMessage(player, "noai_world_not_imported"); + return; + } + + WorldData worldData = buildWorld.getData(); + if (worldData.mobAi().get()) { + worldData.mobAi().set(false); + Messages.sendMessage(player, "noai_activated", Map.entry("%world%", buildWorld.getName())); + } else { + worldData.mobAi().set(true); + Messages.sendMessage(player, "noai_deactivated", Map.entry("%world%", buildWorld.getName())); + } + + boolean hasAi = worldData.mobAi().get(); + world.getLivingEntities().forEach(entity -> entity.setAI(hasAi)); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PagedCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PagedCommand.java new file mode 100644 index 0000000..20f6d6d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PagedCommand.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.Messages; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import net.md_5.bungee.api.chat.ClickEvent; +import net.md_5.bungee.api.chat.HoverEvent; +import net.md_5.bungee.api.chat.TextComponent; +import net.md_5.bungee.api.chat.hover.content.Text; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public abstract class PagedCommand { + + private static final int MAX_COMMANDS_PER_PAGE = 7; + + private final String title, permissionTemplate; + + public PagedCommand(String title, String permissionTemplate) { + this.title = title; + this.permissionTemplate = permissionTemplate; + } + + protected void sendMessage(Player player, int pageNum) { + List commands = getCommands(player); + int numPages = (int) Math.ceil((double) commands.size() / MAX_COMMANDS_PER_PAGE); + + List page = createPage(commands, numPages, pageNum); + page.add(0, new TextComponent("§7§m----------------------------------------------------")); + page.add(1, new TextComponent(Messages.getString(this.title, player) + .replace("%page%", String.valueOf(pageNum)) + .replace("%max%", String.valueOf(numPages)) + .concat("\n")) + ); + page.add(new TextComponent("§7§m----------------------------------------------------")); + page.forEach(line -> player.spigot().sendMessage(line)); + } + + private List createPage(List commands, int numPages, int page) { + List> pages = new ArrayList<>(numPages); + IntStream.range(0, numPages).forEach(i -> pages.add(new ArrayList<>())); + + int currentPage = 0; + int commandsInPage = 0; + for (TextComponent command : commands) { + pages.get(currentPage).add(command); + commandsInPage++; + + if (commandsInPage >= MAX_COMMANDS_PER_PAGE) { + currentPage++; + commandsInPage = 0; + } + } + + return pages.get(page - 1); + } + + protected abstract List getCommands(Player player); + + @Contract("_, _, _, _, _-> new") + protected TextComponent createComponent(Player player, String command, String commandDescriptionKey, String suggest, String permission) { + if (command.isEmpty()) { + return new TextComponent(); + } + + TextComponent commandComponent = new TextComponent("§b" + command); + TextComponent textComponent = new TextComponent(" §8» " + Messages.getString(commandDescriptionKey, player)); + + commandComponent.setClickEvent(new ClickEvent(ClickEvent.Action.SUGGEST_COMMAND, suggest)); + commandComponent.setHoverEvent(new HoverEvent(HoverEvent.Action.SHOW_TEXT, + new Text(Messages.getString(this.permissionTemplate, player, Map.entry("%permission%", permission))) + )); + commandComponent.addExtra(textComponent); + return commandComponent; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PhysicsCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PhysicsCommand.java new file mode 100644 index 0000000..b4911e9 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/PhysicsCommand.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class PhysicsCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + private final WorldStorageImpl worldStorage; + + public PhysicsCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getCommand("physics").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + String worldName = args.length == 0 ? player.getWorld().getName() : args[0]; + BuildWorld buildWorld = worldStorage.getBuildWorld(worldName); + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, "buildsystem.physics")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0 -> { + togglePhysics(player, player.getWorld()); + } + case 1 -> { + // TODO: Check each world for permission individually? + if (args[0].equalsIgnoreCase("all") && !worldStorage.worldExists("all")) { + worldStorage.getBuildWorlds().forEach(world -> world.getData().physics().set(true)); + Messages.sendMessage(player, "physics_activated_all"); + } else { + togglePhysics(player, Bukkit.getWorld(args[0])); + } + } + default -> { + Messages.sendMessage(player, "physics_usage"); + } + } + return true; + } + + private void togglePhysics(Player player, @Nullable World world) { + if (world == null) { + Messages.sendMessage(player, "physics_unknown_world"); + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(world.getName()); + if (buildWorld == null) { + Messages.sendMessage(player, "physics_world_not_imported"); + return; + } + + WorldData worldData = buildWorld.getData(); + if (!worldData.physics().get()) { + worldData.physics().set(true); + Messages.sendMessage(player, "physics_activated", Map.entry("%world%", buildWorld.getName())); + } else { + worldData.physics().set(false); + Messages.sendMessage(player, "physics_deactivated", Map.entry("%world%", buildWorld.getName())); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SettingsCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SettingsCommand.java new file mode 100644 index 0000000..eddac67 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SettingsCommand.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.player.settings.SettingsInventory; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SettingsCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public SettingsCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("settings").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.settings")) { + Messages.sendPermissionError(player); + return true; + } + + new SettingsInventory(plugin).openInventory(player); + return true; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SetupCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SetupCommand.java new file mode 100644 index 0000000..ea9de10 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SetupCommand.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.world.modification.SetupInventory; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SetupCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public SetupCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("setup").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.setup")) { + Messages.sendPermissionError(player); + return true; + } + + new SetupInventory(plugin).openInventory(player); + return true; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SkullCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SkullCommand.java new file mode 100644 index 0000000..c6d5eb3 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SkullCommand.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import java.util.Map; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SkullCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public SkullCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("skull").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.skull")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0 -> { + addSkull(player, "§b" + player.getName(), Profileable.of(player)); + Messages.sendMessage(player, "skull_player_received", Map.entry("%player%", player.getName())); + } + case 1 -> { + String identifier = args[0]; + if (identifier.length() > 16) { + addSkull(player, Messages.getString("custom_skull_item", player), Profileable.detect(identifier)); + Messages.sendMessage(player, "skull_custom_received"); + } else { + addSkull(player, "§b" + identifier, Profileable.detect(identifier)); + Messages.sendMessage(player, "skull_player_received", Map.entry("%player%", identifier)); + } + } + default -> { + Messages.sendMessage(player, "skull_usage"); + } + } + return true; + } + + private void addSkull(Player player, String displayName, Profileable profileable) { + player.getInventory().addItem(InventoryUtils.createSkull(displayName, profileable)); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpawnCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpawnCommand.java new file mode 100644 index 0000000..d21fbf0 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpawnCommand.java @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.world.SpawnManager; +import java.util.Locale; +import java.util.Map; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SpawnCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + private final SpawnManager spawnManager; + private final WorldStorageImpl worldStorage; + + public SpawnCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.spawnManager = plugin.getSpawnManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getCommand("spawn").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + switch (args.length) { + case 0 -> { + if (!spawnManager.teleport(player)) { + Messages.sendMessage(player, "spawn_unavailable"); + } else if (Config.Messages.spawnTeleportMessage) { + Messages.sendMessage(player, "spawn_teleported"); + } + } + + case 1 -> { + if (!player.hasPermission("buildsystem.spawn")) { + Messages.sendMessage(player, "spawn_usage"); + return true; + } + + switch (args[0].toLowerCase(Locale.ROOT)) { + case "set" -> { + Location playerLocation = player.getLocation(); + World bukkitWorld = playerLocation.getWorld(); + if (bukkitWorld == null) { + Messages.sendMessage(player, "spawn_world_not_imported"); + return true; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(bukkitWorld); + if (buildWorld == null) { + Messages.sendMessage(player, "spawn_world_not_imported"); + return true; + } + + spawnManager.set(playerLocation, buildWorld.getName()); + Messages.sendMessage(player, "spawn_set", + Map.entry("%x%", round(playerLocation.getX())), + Map.entry("%y%", round(playerLocation.getY())), + Map.entry("%z%", round(playerLocation.getZ())), + Map.entry("%world%", playerLocation.getWorld().getName()) + ); + } + case "remove" -> { + spawnManager.remove(); + Messages.sendMessage(player, "spawn_remove"); + } + default -> { + Messages.sendMessage(player, "spawn_admin"); + } + } + } + + default -> { + String key = player.hasPermission("buildsystem.spawn") ? "spawn_admin" : "spawn_usage"; + Messages.sendMessage(player, key); + } + } + return true; + } + + private String round(double value) { + int scale = (int) Math.pow(10, 2); + return String.valueOf((double) Math.round(value * scale) / scale); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpeedCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpeedCommand.java new file mode 100644 index 0000000..db01196 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/SpeedCommand.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.player.settings.SpeedInventory; +import java.util.Map; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SpeedCommand implements CommandExecutor { + + private static final float INVALID_SPEED = -1.0f; + + private final BuildSystemPlugin plugin; + + public SpeedCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("speed").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.speed")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0: + new SpeedInventory(plugin).openInventory(player); + break; + case 1: + String speedString = args[0]; + float speed = switch (speedString) { + case "1" -> 0.2f; + case "2" -> 0.4f; + case "3" -> 0.6f; + case "4" -> 0.8f; + case "5" -> 1.0f; + default -> INVALID_SPEED; + }; + + if (speed == INVALID_SPEED) { + Messages.sendMessage(player, "speed_usage"); + return true; + } + + setSpeed(player, speed, speedString); + break; + default: + Messages.sendMessage(player, "speed_usage"); + break; + } + + return true; + } + + private void setSpeed(Player player, float speed, String speedString) { + if (player.isFlying()) { + player.setFlySpeed(speed - 0.1f); + Messages.sendMessage(player, "speed_set_flying", Map.entry("%speed%", speedString)); + } else { + player.setWalkSpeed(speed); + Messages.sendMessage(player, "speed_set_walking", Map.entry("%speed%", speedString)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TimeCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TimeCommand.java new file mode 100644 index 0000000..015cc83 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TimeCommand.java @@ -0,0 +1,99 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.util.WorldPermissions; +import de.eintosti.buildsystem.config.Config.World.Default.Time; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Locale; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class TimeCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public TimeCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("day").setExecutor(this); + plugin.getCommand("night").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + String worldName = args.length == 0 ? player.getWorld().getName() : args[0]; + World world = Bukkit.getWorld(worldName); + if (world == null) { + Messages.sendMessage(player, "day_unknown_world"); + return true; + } + + BuildWorld buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(world); + WorldPermissions permissions = WorldPermissionsImpl.of(buildWorld); + + switch (label.toLowerCase(Locale.ROOT)) { + case "day" -> { + if (!permissions.canPerformCommand(player, "buildsystem.day")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0, 1 -> { + world.setTime(Time.noon); + Messages.sendMessage(player, "day_set", Map.entry("%world%", world.getName())); + } + default -> Messages.sendMessage(player, "day_usage"); + } + } + + case "night" -> { + if (!permissions.canPerformCommand(player, "buildsystem.night")) { + Messages.sendPermissionError(player); + return true; + } + + switch (args.length) { + case 0, 1 -> { + world.setTime(Time.night); + Messages.sendMessage(player, "night_set", Map.entry("%world%", world.getName())); + } + default -> { + Messages.sendMessage(player, "night_usage"); + } + } + } + } + return true; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TopCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TopCommand.java new file mode 100644 index 0000000..d10c978 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/TopCommand.java @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.world.util.WorldTeleporterImpl; +import io.papermc.lib.PaperLib; +import org.bukkit.Location; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class TopCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public TopCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("top").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (!player.hasPermission("buildsystem.top")) { + Messages.sendPermissionError(player); + return true; + } + + if (args.length != 0) { + Messages.sendMessage(player, "top_usage"); + return true; + } + + sendToTop(player); + return true; + } + + private void sendToTop(Player player) { + Location playerLocation = player.getLocation(); + Location blockLocation = player.getWorld() + .getHighestBlockAt(playerLocation.getBlockX(), playerLocation.getBlockZ()) + .getLocation(); + + boolean failed = !WorldTeleporterImpl.isSafeLocation(blockLocation) || blockLocation.getBlock().getY() < playerLocation.getBlock().getY(); + if (failed) { + Messages.sendMessage(player, "top_failed"); + return; + } + + PaperLib.teleportAsync(player, blockLocation.add(0.5, 1, 0.5)) + .whenComplete((completed, throwable) -> { + if (!completed) { + return; + } + XSound.ENTITY_ZOMBIE_INFECT.play(player); + Messages.sendMessage(player, "top_teleported"); + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java new file mode 100644 index 0000000..4580c78 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/WorldsCommand.java @@ -0,0 +1,125 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.AddBuilderSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.ArchiveSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.BackupsSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.BuildersSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.DeleteSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.EditSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.FolderSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.HelpSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.ImportAllSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.ImportSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.InfoSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.ItemSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.PrivateSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.PublicSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.RemoveBuilderSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.RemoveSpawnSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.RenameSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.SetCreatorSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.SetItemSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.SetPermissionSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.SetProjectSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.SetSpawnSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.SetStatusSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.TeleportSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.UnimportSubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter; +import de.eintosti.buildsystem.world.navigator.inventory.NavigatorInventory; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WorldsCommand implements CommandExecutor { + + private final BuildSystemPlugin plugin; + + public WorldsCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getCommand("worlds").setExecutor(this); + } + + @Override + public boolean onCommand(CommandSender sender, Command cmd, String label, String[] args) { + if (!(sender instanceof Player player)) { + plugin.getLogger().warning(Messages.getString("sender_not_player", sender)); + return true; + } + + if (args.length == 0) { + if (!player.hasPermission("buildsystem.navigator")) { + Messages.sendPermissionError(player); + return true; + } + + new NavigatorInventory(plugin).openInventory(player); + XSound.BLOCK_CHEST_OPEN.play(player); + return true; + } + + WorldsTabCompleter.WorldsArgument argument = WorldsTabCompleter.WorldsArgument.matchArgument(args[0]); + if (argument == null) { + Messages.sendMessage(player, "worlds_unknown_command"); + return true; + } + + // Most commands use the structure /worlds <...> which is why we assume that args[1] is the world name + // Make sure to change if this is not the case for any specific command + String worldName = args.length >= 2 ? args[1] : player.getWorld().getName(); + + SubCommand subCommand = switch (argument) { + case ARCHIVE -> new ArchiveSubCommand(plugin); + case ADD_BUILDER -> new AddBuilderSubCommand(plugin, player.getWorld().getName()); + case BACKUP -> new BackupsSubCommand(plugin); + case BUILDERS -> new BuildersSubCommand(plugin, worldName); + case DELETE -> new DeleteSubCommand(plugin, worldName); + case EDIT -> new EditSubCommand(plugin, worldName); + case FOLDER -> new FolderSubCommand(plugin); + case HELP -> new HelpSubCommand(); + case IMPORT_ALL -> new ImportAllSubCommand(plugin); + case IMPORT -> new ImportSubCommand(plugin, worldName); + case INFO -> new InfoSubCommand(plugin, worldName); + case ITEM -> new ItemSubCommand(); + case PRIVATE -> new PrivateSubCommand(plugin); + case PUBLIC -> new PublicSubCommand(plugin); + case REMOVE_BUILDER -> new RemoveBuilderSubCommand(plugin, player.getWorld().getName()); + case REMOVE_SPAWN -> new RemoveSpawnSubCommand(plugin); + case RENAME -> new RenameSubCommand(plugin, worldName); + case SET_CREATOR -> new SetCreatorSubCommand(plugin, worldName); + case SET_ITEM -> new SetItemSubCommand(plugin, worldName); + case SET_PERMISSION -> new SetPermissionSubCommand(plugin, worldName); + case SET_PROJECT -> new SetProjectSubCommand(plugin, worldName); + case SET_SPAWN -> new SetSpawnSubCommand(plugin); + case SET_STATUS -> new SetStatusSubCommand(plugin, worldName); + case TP -> new TeleportSubCommand(plugin); + case UNIMPORT -> new UnimportSubCommand(plugin, worldName); + }; + subCommand.execute(player, args); + return true; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/Argument.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/Argument.java new file mode 100644 index 0000000..dc20449 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/Argument.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public interface Argument { + + /** + * The name of the command argument. + * + * @return The command argument name + */ + String getName(); + + /** + * The permission required to run the command. + * + * @return The permission required to run the command + */ + @Nullable + String getPermission(); +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/SubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/SubCommand.java new file mode 100644 index 0000000..f81ed60 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/SubCommand.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand; + +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface SubCommand { + + void execute(Player player, String[] args); + + Argument getArgument(); + + default boolean hasPermission(Player player) { + String permission = getArgument().getPermission(); + return permission == null || player.hasPermission(permission); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/AddBuilderSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/AddBuilderSubCommand.java new file mode 100644 index 0000000..5ca866d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/AddBuilderSubCommand.java @@ -0,0 +1,130 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.util.WorldPermissions; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.world.builder.BuilderInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class AddBuilderSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + private final WorldPermissions permissions; + + public AddBuilderSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + this.permissions = WorldPermissionsImpl.of(buildWorld); + } + + @Override + public void execute(Player player, String[] args) { + if (!permissions.canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_addbuilder_unknown_world"); + return; + } + + switch (args.length) { + case 1 -> getAddBuilderInput(player, buildWorld, true); + case 2 -> addBuilder(player, buildWorld, args[1], true); + default -> Messages.sendMessage(player, "worlds_addbuilder_usage"); + } + } + + private void addBuilder(Player player, BuildWorld buildWorld, String builderName, boolean closeInventory) { + Player builderPlayer = Bukkit.getPlayerExact(builderName); + Builder builder; + UUID builderId; + + if (builderPlayer == null) { + builderId = UUIDFetcher.getUUID(builderName); + if (builderId == null) { + Messages.sendMessage(player, "worlds_addbuilder_player_not_found"); + player.closeInventory(); + return; + } + builder = Builder.of(builderId, builderName); + } else { + builder = Builder.of(builderPlayer); + builderId = builderPlayer.getUniqueId(); + } + + Builders builders = buildWorld.getBuilders(); + if (builderId.equals(player.getUniqueId()) && builders.isCreator(player)) { + Messages.sendMessage(player, "worlds_addbuilder_already_creator"); + player.closeInventory(); + return; + } + + if (builders.isBuilder(builderId)) { + Messages.sendMessage(player, "worlds_addbuilder_already_added"); + player.closeInventory(); + return; + } + + builders.addBuilder(builder); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + Messages.sendMessage(player, "worlds_addbuilder_added", + Map.entry("%builder%", builderName) + ); + + if (closeInventory) { + player.closeInventory(); + } else { + new BuilderInventory(plugin).openInventory(buildWorld, player); + } + } + + public void getAddBuilderInput(Player player, BuildWorld buildWorld, boolean closeInventory) { + new PlayerChatInput(plugin, player, "enter_player_name", input -> { + String builderName = input.trim(); + addBuilder(player, buildWorld, builderName, closeInventory); + }); + } + + @Override + public Argument getArgument() { + return WorldsArgument.ADD_BUILDER; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ArchiveSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ArchiveSubCommand.java new file mode 100644 index 0000000..9cfae86 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ArchiveSubCommand.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.navigator.inventory.ArchivedWorldsInventory; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ArchiveSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public ArchiveSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + new ArchivedWorldsInventory(plugin, player).openInventory(); + } + + @Override + public Argument getArgument() { + return WorldsArgument.ARCHIVE; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BackupsSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BackupsSubCommand.java new file mode 100644 index 0000000..f54c096 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BackupsSubCommand.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.backup.BackupsInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import java.util.Map.Entry; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BackupsSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public BackupsSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + BuildWorld buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(player.getWorld().getName()); + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_backup_world_not_imported"); + return; + } + + switch (args.length) { + case 1 -> { + XSound.BLOCK_CHEST_OPEN.play(player); + new BackupsInventory(plugin).openBackupsInventory(player, buildWorld); + } + case 2 -> { + if (args[1].equalsIgnoreCase("create")) { + if (!player.hasPermission(getArgument().getPermission() + ".create")) { + Messages.sendPermissionError(player); + return; + } + + Entry worldNamePlaceholder = Map.entry("%world%", buildWorld.getName()); + plugin.getBackupService().backup(buildWorld, + () -> Messages.sendMessage(player, "worlds_backup_created", worldNamePlaceholder), + () -> Messages.sendMessage(player, "worlds_backup_failed", worldNamePlaceholder) + ); + } else { + Messages.sendMessage(player, "worlds_backup_usage"); + } + } + default -> { + Messages.sendMessage(player, "worlds_backup_usage"); + } + } + } + + @Override + public Argument getArgument() { + return WorldsArgument.BACKUP; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BuildersSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BuildersSubCommand.java new file mode 100644 index 0000000..73c149f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/BuildersSubCommand.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.builder.BuilderInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class BuildersSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public BuildersSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_builders_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_builders_unknown_world"); + return; + } + + new BuilderInventory(plugin).openInventory(buildWorld, player); + } + + @Override + public Argument getArgument() { + return WorldsArgument.BUILDERS; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DeleteSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DeleteSubCommand.java new file mode 100644 index 0000000..b05c563 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/DeleteSubCommand.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.config.Config.World; +import de.eintosti.buildsystem.world.modification.DeleteInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class DeleteSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public DeleteSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_delete_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_delete_unknown_world"); + return; + } + + if (World.deletionBlacklist.contains(buildWorld.getName().toLowerCase())) { + Messages.sendMessage(player, "worlds_delete_forbidden"); + return; + } + + new DeleteInventory(plugin).openInventory(player, buildWorld); + } + + @Override + public Argument getArgument() { + return WorldsArgument.DELETE; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/EditSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/EditSubCommand.java new file mode 100644 index 0000000..a5cda2e --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/EditSubCommand.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.modification.EditInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class EditSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public EditSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_edit_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_edit_unknown_world"); + return; + } + + if (buildWorld.isLoaded()) { + XSound.BLOCK_CHEST_OPEN.play(player); + new EditInventory(plugin).openInventory(player, buildWorld); + } else { + XSound.ENTITY_ZOMBIE_BREAK_WOODEN_DOOR.play(player); + player.sendTitle(" ", Messages.getString("world_not_loaded", player), 5, 70, 20); + } + } + + @Override + public Argument getArgument() { + return WorldsArgument.EDIT; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/FolderSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/FolderSubCommand.java new file mode 100644 index 0000000..486420a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/FolderSubCommand.java @@ -0,0 +1,241 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.storage.FolderStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Objects; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class FolderSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + private final WorldServiceImpl worldService; + private final FolderStorage folderStorage; + + public FolderSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.worldService = plugin.getWorldService(); + this.folderStorage = worldService.getFolderStorage(); + } + + @Override + public void execute(Player player, String[] args) { + if (args.length < 2) { + Messages.sendMessage(player, "worlds_folder_usage"); + return; + } + + String folderName = args[1]; + String operation = args.length > 2 ? args[2].toLowerCase(Locale.ROOT) : ""; + + Folder folder = this.folderStorage.getFolder(folderName); + if (folder == null) { + Messages.sendMessage(player, "worlds_folder_unknown_folder"); + return; + } + + switch (operation) { + case "add": + case "remove": { + if (args.length != 4) { + Messages.sendMessage(player, "worlds_folder_usage"); + return; + } + handleWorldFolderOperation(player, folder, operation, args[3]); + break; + } + + case "setpermission": { + handlePermissionInput(player, folder); + break; + } + + case "setproject": { + handleProjectInput(player, folder); + break; + } + + case "setitem": { + handleIconChange(player, folder); + break; + } + + case "delete": { + handleDeletion(player, folder); + break; + } + + default: { + Messages.sendMessage(player, "worlds_folder_usage"); + break; + } + } + } + + private void handleWorldFolderOperation(Player player, Folder folder, String operation, String worldName) { + BuildWorld buildWorld = worldService.getWorldStorage().getBuildWorld(worldName); + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_folder_unknown_world"); + return; + } + + Entry folderPlaceholder = Map.entry("%folder%", folder.getName()); + Entry worldPlaceholder = Map.entry("%world%", buildWorld.getName()); + + switch (operation) { + case "add": + if (!player.hasPermission("buildsystem.folder.add")) { + Messages.sendPermissionError(player); + return; + } + + if (folder.containsWorld(buildWorld)) { + Messages.sendMessage(player, "worlds_folder_world_already_in_folder", folderPlaceholder, worldPlaceholder); + return; + } + + if (buildWorld.isAssignedToFolder()) { + Messages.sendMessage(player, "worlds_folder_world_already_in_another_folder", worldPlaceholder); + return; + } + + if (folder.getCategory() != NavigatorCategory.of(buildWorld)) { + Messages.sendMessage(player, "worlds_folder_world_category_mismatch", + Map.entry("%folder_category%", folder.getCategory().name()), + Map.entry("%world_category%", NavigatorCategory.of(buildWorld).name()) + ); + return; + } + + folder.addWorld(buildWorld); + Messages.sendMessage(player, "worlds_folder_world_added_to_folder", folderPlaceholder, worldPlaceholder); + break; + + case "remove": + if (!player.hasPermission("buildsystem.folder.remove")) { + Messages.sendPermissionError(player); + return; + } + + if (!folder.containsWorld(buildWorld)) { + Messages.sendMessage(player, "worlds_folder_world_not_in_folder", folderPlaceholder, worldPlaceholder); + return; + } + + folder.removeWorld(buildWorld); + Messages.sendMessage(player, "worlds_folder_world_removed_from_folder", folderPlaceholder, worldPlaceholder); + break; + } + } + + private void handlePermissionInput(Player player, Folder folder) { + if (!player.hasPermission("buildsystem.folder.setpermission")) { + Messages.sendPermissionError(player); + return; + } + + new PlayerChatInput(this.plugin, player, "enter_world_permission", input -> { + folder.setPermission(input.trim()); + + XSound.ENTITY_PLAYER_LEVELUP.play(player); + Messages.sendMessage(player, "worlds_folder_permission_set", + Map.entry("%folder%", folder.getName()) + ); + }); + } + + private void handleProjectInput(Player player, Folder folder) { + if (!player.hasPermission("buildsystem.folder.setproject")) { + Messages.sendPermissionError(player); + return; + } + + new PlayerChatInput(this.plugin, player, "enter_world_project", input -> { + folder.setProject(input.trim()); + + XSound.ENTITY_PLAYER_LEVELUP.play(player); + Messages.sendMessage(player, "worlds_folder_project_set", + Map.entry("%folder%", folder.getName()) + ); + }); + } + + private void handleIconChange(Player player, Folder folder) { + if (!player.hasPermission("buildsystem.folder.setitem")) { + Messages.sendPermissionError(player); + return; + } + + ItemStack itemStack = player.getInventory().getItemInMainHand(); + if (itemStack.getType() == Material.AIR) { + Messages.sendMessage(player, "worlds_setitem_hand_empty"); + return; + } + + folder.setIcon(XMaterial.matchXMaterial(itemStack)); + Messages.sendMessage(player, "worlds_folder_item_set", + Map.entry("%folder%", folder.getName()) + ); + } + + private void handleDeletion(Player player, Folder folder) { + if (!player.hasPermission("buildsystem.folder.delete")) { + Messages.sendPermissionError(player); + return; + } + + boolean hasWorlds = folder.getWorldCount() > 0; + boolean hasSubFolders = this.folderStorage.getFolders().stream().anyMatch(f -> Objects.equals(f.getParent(), folder)); + if (hasWorlds || hasSubFolders) { + Messages.sendMessage(player, "worlds_folder_not_empty", + Map.entry("%folder%", folder.getName()) + ); + return; + } + + this.folderStorage.removeFolder(folder); + Messages.sendMessage(player, "worlds_folder_deleted", + Map.entry("%folder%", folder.getName()) + ); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + } + + @Override + public Argument getArgument() { + return WorldsArgument.FOLDER; + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java new file mode 100644 index 0000000..6d9d330 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/HelpSubCommand.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.google.common.collect.Lists; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.command.PagedCommand; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import java.util.List; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class HelpSubCommand extends PagedCommand implements SubCommand { + + public HelpSubCommand() { + super("worlds_help_title_with_page", "worlds_help_permission"); + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + if (args.length == 1) { + sendMessage(player, 1); + } else if (args.length == 2) { + try { + int page = Integer.parseInt(args[1]); + sendMessage(player, page); + } catch (NumberFormatException e) { + Messages.sendMessage(player, "worlds_help_invalid_page"); + } + } else { + Messages.sendMessage(player, "worlds_help_usage"); + } + } + + @Override + protected List getCommands(Player player) { + List commands = Lists.newArrayList( + createComponent(player, "/worlds help ", "worlds_help_help", "/worlds help", "-"), + createComponent(player, "/worlds info", "worlds_help_info", "/worlds info", "buildsystem.info"), + createComponent(player, "/worlds item", "worlds_help_item", "/worlds item", "buildsystem.navigator.item"), + createComponent(player, "/worlds tp ", "worlds_help_tp", "/worlds tp ", "buildsystem.worldtp"), + createComponent(player, "/worlds edit ", "worlds_help_edit", "/worlds edit ", "buildsystem.edit"), + createComponent(player, "/worlds addBuilder ", "worlds_help_addbuilder", "/worlds addBuilder ", "buildsystem.addbuilder"), + createComponent(player, "/worlds removeBuilder ", "worlds_help_removebuilder", "/worlds removeBuilder ", "buildsystem.removebuilder"), + createComponent(player, "/worlds builders ", "worlds_help_builders", "/worlds builders ", "buildsystem.builders"), + createComponent(player, "/worlds rename ", "worlds_help_rename", "/worlds rename ", "buildsystem.rename"), + createComponent(player, "/worlds setItem ", "worlds_help_setitem", "/worlds setItem ", "buildsystem.setitem"), + createComponent(player, "/worlds setCreator ", "worlds_help_setcreator", "/worlds setCreator ", "buildsystem.setcreator"), + createComponent(player, "/worlds setProject ", "worlds_help_setproject", "/worlds setProject ", "buildsystem.setproject"), + createComponent(player, "/worlds setPermission ", "worlds_help_setpermission", "/worlds setPermission ", "buildsystem.setpermission"), + createComponent(player, "/worlds setStatus ", "worlds_help_setstatus", "/worlds setStatus ", "buildsystem.setstatus"), + createComponent(player, "/worlds setSpawn", "worlds_help_setspawn", "/worlds setSpawn", "buildsystem.setspawn"), + createComponent(player, "/worlds removeSpawn", "worlds_help_removespawn", "/worlds removeSpawn", "buildsystem.removespawn"), + createComponent(player, "/worlds delete ", "worlds_help_delete", "/worlds delete ", "buildsystem.delete"), + createComponent(player, "/worlds import ", "worlds_help_import", "/worlds import ", "buildsystem.import"), + createComponent(player, "/worlds importAll", "worlds_help_importall", "/worlds importAll", "buildsystem.import.all"), + createComponent(player, "/worlds unimport", "worlds_help_unimport", "/worlds unimport", "buildsystem.unimport") + ); + commands.removeIf(textComponent -> textComponent.getText().isEmpty()); + return commands; + } + + @Override + public Argument getArgument() { + return WorldsArgument.HELP; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportAllSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportAllSubCommand.java new file mode 100644 index 0000000..005055c --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportAllSubCommand.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.creation.generator.Generator; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.ArgumentParser; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import java.io.File; +import java.util.Locale; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ImportAllSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public ImportAllSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + if (args.length != 1) { + Messages.sendMessage(player, "worlds_importall_usage"); + return; + } + + WorldServiceImpl worldService = plugin.getWorldService(); + if (worldService.isImportingAllWorlds()) { + Messages.sendMessage(player, "worlds_importall_already_started"); + return; + } + + File worldContainer = Bukkit.getWorldContainer(); + String[] directories = worldContainer.list((dir, name) -> { + File worldFolder = new File(dir, name); + if (!worldFolder.isDirectory()) { + return false; + } + + if (!new File(worldFolder, "level.dat").exists()) { + return false; + } + + return !worldService.getWorldStorage().worldExists(name); + }); + + if (directories == null || directories.length == 0) { + Messages.sendMessage(player, "worlds_importall_no_worlds"); + return; + } + + ArgumentParser parser = new ArgumentParser(args); + Generator generator = Generator.VOID; + Builder creator = null; + + if (parser.isArgument("g")) { + String generatorArg = parser.getValue("g"); + if (generatorArg == null) { + Messages.sendMessage(player, "worlds_importall_usage"); + return; + } + try { + generator = Generator.valueOf(generatorArg.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + } + } + + if (parser.isArgument("c")) { + String creatorArg = parser.getValue("c"); + if (creatorArg == null) { + Messages.sendMessage(player, "worlds_importall_usage"); + return; + } + UUID creatorId = UUIDFetcher.getUUID(creatorArg); + if (creatorId == null) { + Messages.sendMessage(player, "worlds_importall_player_not_found"); + return; + } + creator = Builder.of(creatorId, creatorArg); + } + + worldService.importWorlds(player, directories, generator, creator); + } + + @Override + public Argument getArgument() { + return WorldsArgument.IMPORT_ALL; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportSubCommand.java new file mode 100644 index 0000000..cad6227 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ImportSubCommand.java @@ -0,0 +1,147 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.creation.generator.Generator; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.ArgumentParser; +import de.eintosti.buildsystem.util.StringCleaner; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import java.io.File; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ImportSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + private final String worldName; + + public ImportSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.worldName = worldName; + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + if (args.length < 2) { + Messages.sendMessage(player, "worlds_import_usage"); + return; + } + + WorldServiceImpl worldService = plugin.getWorldService(); + if (worldService.getWorldStorage().worldExists(worldName)) { + Messages.sendMessage(player, "worlds_import_world_is_imported"); + return; + } + + File worldFolder = new File(Bukkit.getWorldContainer(), args[1]); + File levelFile = new File(worldFolder, "level.dat"); + if (!worldFolder.isDirectory() || !levelFile.exists()) { + Messages.sendMessage(player, "worlds_import_unknown_world"); + return; + } + + String invalidChar = StringCleaner.firstInvalidChar(worldName); + if (invalidChar != null) { + Messages.sendMessage(player, "worlds_import_invalid_character", + Map.entry("%world%", worldName), + Map.entry("%char%", invalidChar) + ); + return; + } + + Builder creator = null; + Generator generator = Generator.VOID; + String generatorName = generator.name(); + BuildWorldType worldType = BuildWorldType.IMPORTED; + + if (args.length != 2) { + ArgumentParser parser = new ArgumentParser(args); + + if (parser.isArgument("g")) { + String generatorArg = parser.getValue("g"); + if (generatorArg == null) { + Messages.sendMessage(player, "worlds_import_usage"); + return; + } + try { + generator = Generator.valueOf(generatorArg.toUpperCase(Locale.ROOT)); + generatorName = generator.name(); + } catch (IllegalArgumentException ignored) { + generator = Generator.CUSTOM; + generatorName = generatorArg; + } + } + + if (parser.isArgument("c")) { + String creatorArg = parser.getValue("c"); + if (creatorArg == null) { + Messages.sendMessage(player, "worlds_import_usage"); + return; + } + UUID creatorId = UUIDFetcher.getUUID(creatorArg); + if (creatorId == null) { + Messages.sendMessage(player, "worlds_import_player_not_found"); + return; + } + creator = Builder.of(creatorId, creatorArg); + } + + if (parser.isArgument("t")) { + String worldTypeArg = parser.getValue("t"); + if (worldTypeArg == null) { + Messages.sendMessage(player, "worlds_import_usage"); + return; + } + try { + worldType = BuildWorldType.valueOf(worldTypeArg.toUpperCase(Locale.ROOT)); + } catch (IllegalArgumentException ignored) { + } + } + } + + Messages.sendMessage(player, "worlds_import_started", + Map.entry("%world%", worldName) + ); + if (worldService.importWorld(player, worldName, creator, worldType, generator, generatorName, true)) { + Messages.sendMessage(player, "worlds_import_finished"); + } + } + + @Override + public Argument getArgument() { + return WorldsArgument.IMPORT; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/InfoSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/InfoSubCommand.java new file mode 100644 index 0000000..41134bb --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/InfoSubCommand.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class InfoSubCommand implements SubCommand { + + @Nullable + private final BuildWorld buildWorld; + + public InfoSubCommand(BuildSystemPlugin plugin, String worldName) { + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_info_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_info_unknown_world"); + return; + } + + //TODO: Print information about the custom generator? + Builders builders = buildWorld.getBuilders(); + WorldData worldData = buildWorld.getData(); + Messages.sendMessage(player, "world_info", + Map.entry("%world%", buildWorld.getName()), + Map.entry("%uuid%", buildWorld.getUniqueId().toString()), + Map.entry("%creator%", getCreator(builders)), + Map.entry("%item%", worldData.material().get().name()), + Map.entry("%type%", Messages.getString(Messages.getMessageKey(buildWorld.getType()), player)), + Map.entry("%private%", worldData.privateWorld().get()), + Map.entry("%builders_enabled%", worldData.buildersEnabled().get()), + Map.entry("%builders%", builders.asPlaceholder(player)), + Map.entry("%block_breaking%", worldData.blockBreaking().get()), + Map.entry("%block_placement%", worldData.blockPlacement().get()), + Map.entry("%status%", Messages.getString(Messages.getMessageKey(worldData.status().get()), player)), + Map.entry("%project%", worldData.project().get()), + Map.entry("%permission%", worldData.permission().get()), + Map.entry("%time%", buildWorld.getWorldTime()), + Map.entry("%creation%", Messages.formatDate(buildWorld.getCreation())), + Map.entry("%physics%", worldData.physics().get()), + Map.entry("%explosions%", worldData.explosions().get()), + Map.entry("%mobai%", worldData.mobAi().get()), + Map.entry("%custom_spawn%", getCustomSpawn(buildWorld)), + Map.entry("%lastedited%", Messages.formatDate(worldData.lastEdited().get())), + Map.entry("%lastloaded%", Messages.formatDate(worldData.lastLoaded().get())), + Map.entry("%lastunloaded%", Messages.formatDate(worldData.lastUnloaded().get())) + ); + } + + private String getCreator(Builders builders) { + Builder creator = builders.getCreator(); + if (creator == null) { + return "-"; + } + return creator.getName(); + } + + private String getCustomSpawn(BuildWorld buildWorld) { + WorldData worldData = buildWorld.getData(); + Location spawn = worldData.getCustomSpawnLocation(); + if (spawn == null) { + return "-"; + } + return "XYZ: " + round(spawn.getX()) + " / " + round(spawn.getY()) + " / " + round(spawn.getZ()); + } + + private double round(double value) { + int scale = (int) Math.pow(10, 2); + return (double) Math.round(value * scale) / scale; + } + + @Override + public Argument getArgument() { + return WorldsArgument.INFO; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ItemSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ItemSubCommand.java new file mode 100644 index 0000000..49453c4 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/ItemSubCommand.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ItemSubCommand implements SubCommand { + + public ItemSubCommand() { + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + player.getInventory().addItem(InventoryUtils.createNavigatorItem(player)); + Messages.sendMessage(player, "worlds_item_receive"); + } + + @Override + public Argument getArgument() { + return WorldsArgument.ITEM; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PrivateSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PrivateSubCommand.java new file mode 100644 index 0000000..80bc7bc --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PrivateSubCommand.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.navigator.inventory.PrivateWorldsInventory; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PrivateSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public PrivateSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + new PrivateWorldsInventory(plugin, player).openInventory(); + } + + @Override + public Argument getArgument() { + return WorldsArgument.PRIVATE; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PublicSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PublicSubCommand.java new file mode 100644 index 0000000..6163fd4 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/PublicSubCommand.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.navigator.inventory.PublicWorldsInventory; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PublicSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public PublicSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + new PublicWorldsInventory(plugin, player).openInventory(); + } + + @Override + public Argument getArgument() { + return WorldsArgument.PUBLIC; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveBuilderSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveBuilderSubCommand.java new file mode 100644 index 0000000..2582bbb --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveBuilderSubCommand.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.util.WorldPermissions; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class RemoveBuilderSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + private final WorldPermissions permissions; + + public RemoveBuilderSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + this.permissions = WorldPermissionsImpl.of(buildWorld); + } + + @Override + public void execute(Player player, String[] args) { + if (!permissions.canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_removebuilder_unknown_world"); + return; + } + + switch (args.length) { + case 1 -> getRemoveBuilderInput(player, buildWorld); + case 2 -> removeBuilder(player, buildWorld, args[1]); + default -> Messages.sendMessage(player, "worlds_removebuilder_usage"); + } + } + + private void removeBuilder(Player player, BuildWorld buildWorld, String builderName) { + Player builderPlayer = Bukkit.getPlayerExact(builderName); + UUID builderId; + + if (builderPlayer == null) { + builderId = UUIDFetcher.getUUID(builderName); + if (builderId == null) { + Messages.sendMessage(player, "worlds_removebuilder_player_not_found"); + player.closeInventory(); + return; + } + } else { + builderId = builderPlayer.getUniqueId(); + } + + Builders builders = buildWorld.getBuilders(); + if (builderId.equals(player.getUniqueId()) && builders.isCreator(player)) { + Messages.sendMessage(player, "worlds_removebuilder_not_yourself"); + player.closeInventory(); + return; + } + + if (!builders.isBuilder(builderId)) { + Messages.sendMessage(player, "worlds_removebuilder_not_builder"); + player.closeInventory(); + return; + } + + builders.removeBuilder(builderId); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + Messages.sendMessage(player, "worlds_removebuilder_removed", Map.entry("%builder%", builderName)); + + player.closeInventory(); + } + + private void getRemoveBuilderInput(Player player, BuildWorld buildWorld) { + new PlayerChatInput(plugin, player, "enter_player_name", input -> { + String builderName = input.trim(); + removeBuilder(player, buildWorld, builderName); + }); + } + + @Override + public Argument getArgument() { + return WorldsArgument.REMOVE_BUILDER; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveSpawnSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveSpawnSubCommand.java new file mode 100644 index 0000000..ab5a04a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RemoveSpawnSubCommand.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class RemoveSpawnSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public RemoveSpawnSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + BuildWorld buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(player.getWorld()); + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_removespawn_world_not_imported"); + return; + } + + buildWorld.getData().customSpawn().set(""); + Messages.sendMessage(player, "worlds_removespawn_world_spawn_removed", + Map.entry("%world%", buildWorld.getName()) + ); + } + + @Override + public Argument getArgument() { + return WorldsArgument.REMOVE_SPAWN; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RenameSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RenameSubCommand.java new file mode 100644 index 0000000..98ce5d4 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/RenameSubCommand.java @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class RenameSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public RenameSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_rename_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_rename_unknown_world"); + return; + } + + new PlayerChatInput(plugin, player, "enter_world_name", input -> { + player.closeInventory(); + plugin.getWorldService().renameWorld(player, buildWorld, input.trim()); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + }); + } + + @Override + public Argument getArgument() { + return WorldsArgument.RENAME; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetCreatorSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetCreatorSubCommand.java new file mode 100644 index 0000000..6902d5f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetCreatorSubCommand.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SetCreatorSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public SetCreatorSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_setcreator_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_setcreator_unknown_world"); + return; + } + + new PlayerChatInput(plugin, player, "enter_world_creator", input -> { + String creatorName = input.trim(); + Builder creator = null; + if (!creatorName.equalsIgnoreCase("-")) { + creator = Builder.of(UUIDFetcher.getUUID(creatorName), creatorName); + } + buildWorld.getBuilders().setCreator(creator); + + plugin.getPlayerService().forceUpdateSidebar(buildWorld); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + Messages.sendMessage(player, "worlds_setcreator_set", + Map.entry("%world%", buildWorld.getName()) + ); + player.closeInventory(); + }); + } + + @Override + public Argument getArgument() { + return WorldsArgument.SET_CREATOR; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java new file mode 100644 index 0000000..9d857a6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetItemSubCommand.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SetItemSubCommand implements SubCommand { + + @Nullable + private final BuildWorld buildWorld; + + public SetItemSubCommand(BuildSystemPlugin plugin, String worldName) { + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_setitem_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_setitem_unknown_world"); + return; + } + + ItemStack itemStack = player.getInventory().getItemInMainHand(); + if (itemStack.getType() == Material.AIR) { + Messages.sendMessage(player, "worlds_setitem_hand_empty"); + return; + } + + buildWorld.getData().material().set(XMaterial.matchXMaterial(itemStack)); + Messages.sendMessage(player, "worlds_setitem_set", + Map.entry("%world%", buildWorld.getName()) + ); + } + + @Override + public Argument getArgument() { + return WorldsArgument.SET_ITEM; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetPermissionSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetPermissionSubCommand.java new file mode 100644 index 0000000..107af8c --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetPermissionSubCommand.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.world.modification.EditInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SetPermissionSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public SetPermissionSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_setpermission_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_setpermission_unknown_world"); + return; + } + + getPermissionInput(player, buildWorld, true); + } + + public void getPermissionInput(Player player, BuildWorld buildWorld, boolean closeInventory) { + new PlayerChatInput(plugin, player, "enter_world_permission", input -> { + buildWorld.getData().permission().set(input.trim()); + plugin.getPlayerService().forceUpdateSidebar(buildWorld); + + XSound.ENTITY_PLAYER_LEVELUP.play(player); + Messages.sendMessage(player, "worlds_setpermission_set", + Map.entry("%world%", buildWorld.getName()) + ); + + if (closeInventory) { + player.closeInventory(); + } else { + new EditInventory(plugin).openInventory(player, buildWorld); + } + }); + } + + @Override + public Argument getArgument() { + return WorldsArgument.SET_PERMISSION; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetProjectSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetProjectSubCommand.java new file mode 100644 index 0000000..3bc64f5 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetProjectSubCommand.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.world.modification.EditInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SetProjectSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public SetProjectSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_setproject_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_setproject_unknown_world"); + return; + } + + getProjectInput(player, buildWorld, true); + } + + public void getProjectInput(Player player, BuildWorld buildWorld, boolean closeInventory) { + new PlayerChatInput(plugin, player, "enter_world_project", input -> { + buildWorld.getData().project().set(input.trim()); + plugin.getPlayerService().forceUpdateSidebar(buildWorld); + + XSound.ENTITY_PLAYER_LEVELUP.play(player); + Messages.sendMessage(player, "worlds_setproject_set", + Map.entry("%world%", buildWorld.getName()) + ); + + if (closeInventory) { + player.closeInventory(); + } else { + new EditInventory(plugin).openInventory(player, buildWorld); + } + }); + } + + @Override + public Argument getArgument() { + return WorldsArgument.SET_PROJECT; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetSpawnSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetSpawnSubCommand.java new file mode 100644 index 0000000..6c0ea6d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetSpawnSubCommand.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SetSpawnSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public SetSpawnSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + BuildWorld buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(player.getWorld()); + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_setspawn_world_not_imported"); + return; + } + + Location playerLocation = player.getLocation(); + buildWorld.getData().customSpawn().set("%s;%s;%s;%s;%s".formatted( + playerLocation.getX(), playerLocation.getY(), playerLocation.getZ(), playerLocation.getYaw(), playerLocation.getPitch() + )); + Messages.sendMessage(player, "worlds_setspawn_world_spawn_set", + Map.entry("%world%", buildWorld.getName()) + ); + } + + @Override + public Argument getArgument() { + return WorldsArgument.SET_SPAWN; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetStatusSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetStatusSubCommand.java new file mode 100644 index 0000000..fffe422 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/SetStatusSubCommand.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.data.StatusInventory; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SetStatusSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public SetStatusSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_setstatus_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_setstatus_unknown_world"); + return; + } + + new StatusInventory(plugin).openInventory(player, buildWorld); + } + + @Override + public Argument getArgument() { + return WorldsArgument.SET_STATUS; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/TeleportSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/TeleportSubCommand.java new file mode 100644 index 0000000..e03a0f1 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/TeleportSubCommand.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class TeleportSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + public TeleportSubCommand(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + @Override + public void execute(Player player, String[] args) { + if (!hasPermission(player)) { + Messages.sendPermissionError(player); + return; + } + + if (args.length != 2) { + Messages.sendMessage(player, "worlds_tp_usage"); + return; + } + + BuildWorld buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(args[1]); + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_tp_unknown_world"); + return; + } + + World bukkitWorld = Bukkit.getServer().getWorld(args[1]); + if (buildWorld.isLoaded() && bukkitWorld == null) { + Messages.sendMessage(player, "worlds_tp_unknown_world"); + return; + } + + if (!buildWorld.getPermissions().canEnter(player)) { + Messages.sendMessage(player, "worlds_tp_entry_forbidden"); + return; + } + + buildWorld.getTeleporter().teleport(player); + } + + @Override + public Argument getArgument() { + return WorldsArgument.TP; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/UnimportSubCommand.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/UnimportSubCommand.java new file mode 100644 index 0000000..7a854da --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/subcommand/worlds/UnimportSubCommand.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.subcommand.worlds; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.command.subcommand.SubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.Map; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class UnimportSubCommand implements SubCommand { + + private final BuildSystemPlugin plugin; + + @Nullable + private final BuildWorld buildWorld; + + public UnimportSubCommand(BuildSystemPlugin plugin, String worldName) { + this.plugin = plugin; + this.buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(worldName); + } + + @Override + public void execute(Player player, String[] args) { + if (!WorldPermissionsImpl.of(buildWorld).canPerformCommand(player, getArgument().getPermission())) { + Messages.sendPermissionError(player); + return; + } + + if (args.length > 2) { + Messages.sendMessage(player, "worlds_unimport_usage"); + return; + } + + if (buildWorld == null) { + Messages.sendMessage(player, "worlds_unimport_unknown_world"); + return; + } + + plugin.getWorldService() + .unimportWorld(buildWorld, true) + .thenRun(() -> Messages.sendMessage(player, "worlds_unimport_finished", Map.entry("%world%", buildWorld.getName()))); + } + + @Override + public Argument getArgument() { + return WorldsArgument.UNIMPORT; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ArgumentSorter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ArgumentSorter.java new file mode 100644 index 0000000..5ea78cd --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ArgumentSorter.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import java.util.List; +import org.jspecify.annotations.NullMarked; + +@NullMarked +abstract class ArgumentSorter { + + public void addArgument(String input, String argument, List arrayList) { + if (input.isEmpty() || argument.toLowerCase().startsWith(input.toLowerCase())) { + arrayList.add(argument); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/BuildTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/BuildTabCompleter.java new file mode 100644 index 0000000..20a7faf --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/BuildTabCompleter.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuildTabCompleter extends ArgumentSorter implements TabCompleter { + + public BuildTabCompleter(BuildSystemPlugin plugin) { + plugin.getCommand("build").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + if (!(sender instanceof Player player)) { + return arrayList; + } + + if (!player.hasPermission("buildsystem.build")) { + return arrayList; + } + + if (args.length == 1 && !player.hasPermission("buildsystem.build.other")) { + Bukkit.getOnlinePlayers().forEach(pl -> addArgument(args[0], pl.getName(), arrayList)); + } + + return arrayList; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ConfigTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ConfigTabCompleter.java new file mode 100644 index 0000000..c198c29 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/ConfigTabCompleter.java @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ConfigTabCompleter implements TabCompleter { + + public ConfigTabCompleter(BuildSystemPlugin plugin) { + plugin.getCommand("config").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + + if (sender.hasPermission("buildsystem.config")) { + arrayList.add("reload"); + } + + return arrayList; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/EmptyTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/EmptyTabCompleter.java new file mode 100644 index 0000000..4388190 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/EmptyTabCompleter.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class EmptyTabCompleter implements TabCompleter { + + public EmptyTabCompleter(BuildSystemPlugin plugin) { + plugin.getCommand("back").setTabCompleter(this); + plugin.getCommand("blocks").setTabCompleter(this); + plugin.getCommand("buildsystem").setTabCompleter(this); + plugin.getCommand("settings").setTabCompleter(this); + plugin.getCommand("setup").setTabCompleter(this); + plugin.getCommand("top").setTabCompleter(this); + } + + @Override + @Contract("_, _, _, _ -> new") + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + return new ArrayList<>(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/GamemodeTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/GamemodeTabCompleter.java new file mode 100644 index 0000000..99b5771 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/GamemodeTabCompleter.java @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class GamemodeTabCompleter extends ArgumentSorter implements TabCompleter { + + public GamemodeTabCompleter(BuildSystemPlugin plugin) { + plugin.getCommand("gamemode").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + if (!(sender instanceof Player player)) { + return arrayList; + } + + if (args.length == 1) { + Arrays.stream(GameMode.values()) + .map(gameMode -> gameMode.name().toLowerCase(Locale.ROOT)) + .filter(gameModeName -> player.hasPermission("buildsystem.gamemode.%s".formatted(gameModeName))) + .forEach(gameModeName -> addArgument(args[0], gameModeName, arrayList)); + } else if (args.length == 2) { + String gameModeName = switch (args[0].toLowerCase(Locale.ROOT)) { + case "survival", "s", "0" -> GameMode.SURVIVAL.name().toLowerCase(Locale.ROOT); + case "creative", "c", "1" -> GameMode.CREATIVE.name().toLowerCase(Locale.ROOT); + case "adventure", "a", "2" -> GameMode.ADVENTURE.name().toLowerCase(Locale.ROOT); + case "spectator", "sp", "3" -> GameMode.SPECTATOR.name().toLowerCase(Locale.ROOT); + default -> null; + }; + + if (gameModeName != null && player.hasPermission("buildsystem.gamemode.%s.other".formatted(gameModeName))) { + Bukkit.getOnlinePlayers().forEach(pl -> addArgument(args[1], pl.getName(), arrayList)); + } + } + + return arrayList; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/PhysicsTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/PhysicsTabCompleter.java new file mode 100644 index 0000000..21ae60b --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/PhysicsTabCompleter.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PhysicsTabCompleter extends ArgumentSorter implements TabCompleter { + + private final WorldStorage worldStorage; + + public PhysicsTabCompleter(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getCommand("physics").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + if (!(sender instanceof Player player)) { + return arrayList; + } + + if (args.length == 1) { + worldStorage.getBuildWorlds().stream() + .filter(world -> world.getPermissions().canPerformCommand(player, "buildsystem.physics")) + .forEach(world -> addArgument(args[0], world.getName(), arrayList)); + } + + return arrayList; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpawnTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpawnTabCompleter.java new file mode 100644 index 0000000..6b17474 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpawnTabCompleter.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SpawnTabCompleter extends ArgumentSorter implements TabCompleter { + + public SpawnTabCompleter(BuildSystemPlugin plugin) { + plugin.getCommand("spawn").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + if (!(sender instanceof Player player)) { + return arrayList; + } + + if (player.hasPermission("buildsystem.spawn")) { + for (Argument argument : Argument.values()) { + String argumentName = argument.name(); + addArgument(args[0], argumentName, arrayList); + } + } + + return arrayList; + } + + private enum Argument { + set, remove + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpeedTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpeedTabCompleter.java new file mode 100644 index 0000000..de88d8d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/SpeedTabCompleter.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.util.ArrayList; +import java.util.List; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SpeedTabCompleter implements TabCompleter { + + public SpeedTabCompleter(BuildSystemPlugin plugin) { + plugin.getCommand("speed").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + for (int i = 1; i <= 5; i++) { + arrayList.add(String.valueOf(i)); + } + return arrayList; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/TimeTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/TimeTabCompleter.java new file mode 100644 index 0000000..fec8f3a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/TimeTabCompleter.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class TimeTabCompleter extends ArgumentSorter implements TabCompleter { + + private final WorldStorage worldStorage; + + public TimeTabCompleter(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getCommand("day").setTabCompleter(this); + plugin.getCommand("night").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + if (!(sender instanceof Player player)) { + return arrayList; + } + + String labelLowerCase = label.toLowerCase(Locale.ROOT); + switch (labelLowerCase) { + case "day": + case "night": + worldStorage.getBuildWorlds().stream() + .filter(world -> world.getPermissions().canPerformCommand(player, "buildsystem." + labelLowerCase)) + .forEach(world -> addArgument(args[0], world.getName(), arrayList)); + break; + } + + return arrayList; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/WorldsTabCompleter.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/WorldsTabCompleter.java new file mode 100644 index 0000000..dbcaf98 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/command/tabcomplete/WorldsTabCompleter.java @@ -0,0 +1,301 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.command.tabcomplete; + +import com.google.common.collect.Lists; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.storage.FolderStorage; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.WorldService; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.creation.generator.Generator; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.display.Displayable; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.command.subcommand.Argument; +import de.eintosti.buildsystem.util.StringCleaner; +import java.io.File; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import javax.annotation.Nullable; +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WorldsTabCompleter extends ArgumentSorter implements TabCompleter { + + private final WorldStorage worldStorage; + private final FolderStorage folderStorage; + + public WorldsTabCompleter(BuildSystemPlugin plugin) { + WorldService worldService = plugin.getWorldService(); + this.worldStorage = worldService.getWorldStorage(); + this.folderStorage = worldService.getFolderStorage(); + plugin.getCommand("worlds").setTabCompleter(this); + } + + @Override + public List onTabComplete(CommandSender sender, Command cmd, String label, String[] args) { + ArrayList arrayList = new ArrayList<>(); + if (!(sender instanceof Player player)) { + return arrayList; + } + + switch (args.length) { + case 1: { + for (WorldsArgument argument : WorldsArgument.values()) { + String command = argument.getName(); + String permission = argument.getPermission(); + if (player.hasPermission(permission)) { + addArgument(args[0], command, arrayList); + } + } + return arrayList; + } + + case 2: { + switch (args[0].toLowerCase(Locale.ROOT)) { + case "builders": + case "edit": + case "info": + case "rename": + case "setcreator": + case "setitem": + case "setpermission": + case "setproject": + case "setstatus": + case "tp": + case "unimport": { + worldStorage.getBuildWorlds().stream() + .filter(world -> { + String permission = world.getData().permission().get(); + return player.hasPermission(permission) || permission.equalsIgnoreCase("-"); + }) + .filter(world -> world.getPermissions().canPerformCommand(player, "buildsystem." + args[0].toLowerCase(Locale.ROOT))) + .forEach(world -> addArgument(args[1], world.getName(), arrayList)); + break; + } + + case "backup": { + if (player.hasPermission(WorldsArgument.BACKUP.getPermission() + ".create")) { + addArgument(args[1], "create", arrayList); + } + break; + } + + case "delete": { + worldStorage.getBuildWorlds().stream() + .filter(world -> world.getPermissions().canPerformCommand(player, "buildsystem." + args[0].toLowerCase(Locale.ROOT))) + .forEach(world -> addArgument(args[1], world.getName(), arrayList)); + break; + } + + case "addbuilder": { + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld().getName()); + if (buildWorld == null) { + break; + } + Builders builders = buildWorld.getBuilders(); + Bukkit.getOnlinePlayers().stream() + .filter(pl -> !builders.isBuilder(pl) && !builders.isCreator(pl)) + .forEach(pl -> addArgument(args[1], pl.getName(), arrayList)); + break; + } + + case "removebuilder": { + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld().getName()); + if (buildWorld == null) { + break; + } + Builders builders = buildWorld.getBuilders(); + if (builders.isCreator(player)) { + builders.getBuilderNames().forEach(builderName -> addArgument(args[1], builderName, arrayList)); + } + break; + } + + case "import": { + String[] directories = Bukkit.getWorldContainer().list((dir, name) -> { + if (StringCleaner.hasInvalidNameCharacters(name)) { + return false; + } + + File worldFolder = new File(dir, name); + if (!worldFolder.isDirectory()) { + return false; + } + + if (!new File(worldFolder, "level.dat").exists()) { + return false; + } + + return !worldStorage.worldExists(name); + }); + + if (directories == null || directories.length == 0) { + return arrayList; + } + + for (String projectName : directories) { + addArgument(args[1], projectName, arrayList); + } + break; + } + + case "folder": { + folderStorage.getFolders().stream() + .map(Displayable::getName) + .forEach(folderName -> addArgument(args[1], folderName, arrayList)); + break; + } + } + return arrayList; + } + + default: + switch (args[0].toLowerCase(Locale.ROOT)) { + case "import": { + Map> arguments = Map.of( + "-g", Arrays.stream(Generator.values()).filter(generator -> generator != Generator.CUSTOM).map(Enum::name).toList(), + "-c", List.of(), + "-t", Arrays.stream(BuildWorldType.values()).map(Enum::name).toList() + ); + + if (args.length % 2 == 1) { + arguments.keySet().stream() + .filter(key -> !Lists.newArrayList(args).contains(key)) + .forEach(argument -> addArgument(args[args.length - 1], argument, arrayList)); + } else { + List values = arguments.get(args[args.length - 2]); + if (values != null) { + for (String argument : values) { + addArgument(args[args.length - 1], argument, arrayList); + } + } + } + break; + } + + case "folder": { + switch (args.length) { + case 3: + Map subCommands = Map.of( + "add", "buildsystem.folder.add", + "remove", "buildsystem.folder.remove", + "delete", "buildsystem.folder.delete", + "setPermission", "buildsystem.folder.setpermission", + "setProject", "buildsystem.folder.setproject", + "setItem", "buildsystem.folder.setitem" + ); + subCommands.entrySet().stream() + .filter(entry -> player.hasPermission(entry.getKey())) + .forEach(entry -> addArgument(args[2], entry.getKey(), arrayList)); + break; + case 4: + if (!args[2].equalsIgnoreCase("add") && !args[2].equalsIgnoreCase("remove")) { + return arrayList; + } + + Folder folder = folderStorage.getFolder(args[1]); + if (folder == null) { + return arrayList; + } + + worldStorage.getBuildWorlds().stream() + .filter(buildWorld -> NavigatorCategory.of(buildWorld) == folder.getCategory()) + .filter(buildWorld -> { + if (args[2].equalsIgnoreCase("add")) { + return !buildWorld.isAssignedToFolder(); + } else if (args[2].equalsIgnoreCase("remove")) { + return folder.containsWorld(buildWorld); + } + return false; + }) + .forEach(world -> addArgument(args[3], world.getName(), arrayList)); + break; + } + } + } + return arrayList; + } + } + + @NullMarked + public enum WorldsArgument implements Argument { + ARCHIVE("archive", "buildsystem.navigator"), + ADD_BUILDER("addBuilder", "buildsystem.addbuilder"), + BACKUP("backup", "buildsystem.backup"), + BUILDERS("builders", "buildsystem.builders"), + DELETE("delete", "buildsystem.delete"), + EDIT("edit", "buildsystem.edit"), + FOLDER("folder", "buildsystem.folder"), + HELP("help", "buildsystem.help.worlds"), + IMPORT("import", "buildsystem.import"), + IMPORT_ALL("importAll", "buildsystem.import.all"), + INFO("info", "buildsystem.info"), + ITEM("item", "buildsystem.navigator.item"), + PRIVATE("private", "buildsystem.navigator"), + PUBLIC("public", "buildsystem.navigator"), + REMOVE_BUILDER("removeBuilder", "buildsystem.removebuilder"), + RENAME("rename", "buildsystem.rename"), + SET_CREATOR("setCreator", "buildsystem.setcreator"), + SET_ITEM("setItem", "buildsystem.setitem"), + SET_PERMISSION("setPermission", "buildsystem.setpermission"), + SET_PROJECT("setProject", "buildsystem.setproject"), + SET_STATUS("setStatus", "buildsystem.setstatus"), + SET_SPAWN("setSpawn", "buildsystem.setspawn"), + REMOVE_SPAWN("removeSpawn", "buildsystem.removespawn"), + TP("tp", "buildsystem.worldtp"), + UNIMPORT("unimport", "buildsystem.unimport"); + + private final String command; + private final String permission; + + WorldsArgument(String command, String permission) { + this.command = command; + this.permission = permission; + } + + @Nullable + public static WorldsArgument matchArgument(String input) { + return Arrays.stream(values()) + .filter(argument -> argument.getName().equalsIgnoreCase(input)) + .findFirst() + .orElse(null); + } + + @Override + public String getName() { + return command; + } + + @Override + public String getPermission() { + return permission; + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/Config.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/Config.java new file mode 100644 index 0000000..36b5c0a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/Config.java @@ -0,0 +1,646 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.config; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.backup.BackupStorage; +import de.eintosti.buildsystem.config.Config.Settings.Archive; +import de.eintosti.buildsystem.config.Config.Settings.BuildMode; +import de.eintosti.buildsystem.config.Config.Settings.Builder; +import de.eintosti.buildsystem.config.Config.Settings.DisabledPhysics; +import de.eintosti.buildsystem.config.Config.Settings.Navigator; +import de.eintosti.buildsystem.config.Config.Settings.SaveFromDeath; +import de.eintosti.buildsystem.config.Config.World.Backup; +import de.eintosti.buildsystem.config.Config.World.Backup.AutoBackup; +import de.eintosti.buildsystem.config.Config.World.Default; +import de.eintosti.buildsystem.config.Config.World.Default.Permission; +import de.eintosti.buildsystem.config.Config.World.Default.Settings.BuildersEnabled; +import de.eintosti.buildsystem.config.Config.World.Default.Time; +import de.eintosti.buildsystem.config.Config.World.Limits; +import de.eintosti.buildsystem.config.Config.World.Unload; +import de.eintosti.buildsystem.config.migration.ConfigMigrationManager; +import de.eintosti.buildsystem.world.backup.storage.LocalBackupStorage; +import de.eintosti.buildsystem.world.backup.storage.S3BackupStorage; +import de.eintosti.buildsystem.world.backup.storage.SftpBackupStorage; +import de.eintosti.buildsystem.world.modification.GameRuleEntry; +import java.io.File; +import java.util.Arrays; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Set; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.bukkit.Difficulty; +import org.bukkit.GameMode; +import org.bukkit.GameRule; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Manages the plugin's configuration, loading and providing access to various settings. + */ +@NullMarked +public class Config { + + private static final BuildSystemPlugin PLUGIN = BuildSystemPlugin.get(); + private static final FileConfiguration CONFIG = PLUGIN.getConfig(); + + /** + * Gets the plugin's configuration. + * + * @return The plugin's configuration + */ + public static FileConfiguration getConfig() { + return CONFIG; + } + + /** + * Gets the current version of the plugin's configuration. + * + * @return The version number of the configuration + * @see ConfigMigrationManager#LATEST_VERSION + */ + public static int getVersion() { + return CONFIG.getInt("version", 1); + } + + /** + * Sets the version of the plugin's configuration. + * + * @param version The version number to set + */ + public static void setVersion(int version) { + CONFIG.set("version", version); + CONFIG.setComments("version", List.of("Internal, do not change manually!")); + PLUGIN.saveConfig(); + } + + /** + * Stores messages related configurations. + */ + public static class Messages { + + /** + * Whether a message should be sent to the player when teleporting to a world's spawn. + */ + public static boolean spawnTeleportMessage = false; + /** + * Whether join and quit messages should be enabled. + */ + public static boolean joinQuitMessages = true; + /** + * The date format used for various date displays. + */ + public static String dateFormat = "dd/MM/yyyy"; + } + + /** + * Stores general settings for the plugin. + */ + public static class Settings { + + /** + * Whether the update checker should be enabled. + */ + public static boolean updateChecker = true; + /** + * Whether the scoreboard should be enabled. + */ + public static boolean scoreboard = true; + + /** + * Stores archive-related settings. + */ + public static class Archive { + + /** + * Whether players should be vanished when entering an archived world. + */ + public static boolean vanish = true; + /** + * Whether the gamemode should be changed when entering an archived world. + */ + public static boolean changeGamemode = true; + /** + * The gamemode players are set to when entering an archived world. + */ + public static GameMode worldGameMode = GameMode.ADVENTURE; + } + + /** + * Stores settings related to worlds with disabled physics. + */ + public static class DisabledPhysics { + + /** + * Whether connections should be prevented in worlds with disabled physics. + */ + public static boolean preventConnections = true; + /** + * Whether fluid flow should be prevented in worlds with disabled physics. + */ + public static boolean preventFluidFlow = true; + /** + * Whether falling blocks should be prevented in worlds with disabled physics. + */ + public static boolean preventFallingBlocks = true; + } + + /** + * Stores settings related to saving players from death. + */ + public static class SaveFromDeath { + + /** + * Whether saving players from death should be enabled. + */ + public static boolean enabled = true; + /** + * Whether players should be teleported to the map spawn after being saved from death. + */ + public static boolean teleportToMapSpawn = true; + } + + /** + * Stores build mode related settings. + */ + public static class BuildMode { + + /** + * Whether items should be dropped when a player dies in build mode. + */ + public static boolean dropItems = true; + /** + * Whether players can move items in their inventory in build mode. + */ + public static boolean moveItems = true; + } + + /** + * Stores builder-related settings. + */ + public static class Builder { + + /** + * Whether WorldEdit usage should be blocked for non-builders. + */ + public static boolean blockWorldEditNonBuilder = true; + /** + * The {@link XMaterial} of the WorldEdit wand. + */ + public static XMaterial worldEditWand = XMaterial.WOODEN_AXE; + } + + /** + * Stores navigator-related settings. + */ + public static class Navigator { + + /** + * The {@link XMaterial} of the navigator item. + */ + public static XMaterial item = XMaterial.CLOCK; + /** + * Whether the navigator item should be given to players on join. + */ + public static boolean giveItemOnJoin = true; + } + } + + /** + * Stores world-related configurations. + */ + public static class World { + + /** + * Whether the weather should be locked in worlds. + */ + public static boolean lockWeather = true; + /** + * A regex string of invalid characters for world names. + */ + public static String invalidCharacters = "^\b$"; + /** + * The delay in seconds for the /build import all command. + */ + public static int importAllDelay = 30; + /** + * A blacklist of worlds that cannot be deleted. This is used to prevent deletion of important worlds. + */ + public static Set deletionBlacklist = Set.of("world", "world_nether", "worth_the_end"); + ; + + /** + * Stores default world settings. + */ + public static class Default { + + /** + * The default world border size. + */ + public static int worldBoarderSize = 6000000; + /** + * The default difficulty for new worlds. + */ + public static Difficulty difficulty = Difficulty.PEACEFUL; + /** + * Default game rules for new worlds. + */ + public static List> gameRules = List.of( + new GameRuleEntry<>(GameRule.DO_DAYLIGHT_CYCLE, false), + new GameRuleEntry<>(GameRule.DO_MOB_SPAWNING, false), + new GameRuleEntry<>(GameRule.DO_FIRE_TICK, false) + ); + + /** + * Stores permission-related settings for default worlds. + */ + public static class Permission { + + /** + * The permission required to access public worlds by default. + */ + public static String publicPermission = "-"; + /** + * The permission required to access private worlds by default. The `%world%` placeholder will be replaced with the world name. + */ + public static String privatePermission = "worlds.%world%"; + } + + /** + * Stores time-related settings for default worlds. + */ + public static class Time { + + /** + * The time of sunrise in ticks. + */ + public static int sunrise = 0; + /** + * The time of noon in ticks. + */ + public static int noon = 6000; + /** + * The time of night in ticks. + */ + public static int night = 18000; + } + + /** + * Stores various settings for default worlds. + */ + public static class Settings { + + /** + * Whether physics should be enabled by default. + */ + public static boolean physics = true; + /** + * Whether explosions should be enabled by default. + */ + public static boolean explosions = true; + /** + * Whether mob AI should be enabled by default. + */ + public static boolean mobAi = true; + /** + * Whether block breaking should be enabled by default. + */ + public static boolean blockBreaking = true; + /** + * Whether block placement should be enabled by default. + */ + public static boolean blockPlacement = true; + /** + * Whether block interactions should be enabled by default. + */ + public static boolean blockInteractions = true; + + /** + * Stores settings for builders being enabled by default. + */ + public static class BuildersEnabled { + + /** + * Whether builders are enabled by default for public worlds. + */ + public static boolean publicBuilders = false; + /** + * Whether builders are enabled by default for private worlds. + */ + public static boolean privateBuilders = true; + } + } + } + + /** + * Stores world limit settings. + */ + public static class Limits { + + /** + * The maximum number of public worlds a player can have. -1 for unlimited. + */ + public static int publicWorlds = -1; + /** + * The maximum number of private worlds a player can have. -1 for unlimited. + */ + public static int privateWorlds = -1; + } + + /** + * Stores world unload settings. + */ + public static class Unload { + + /** + * Whether world unloading is enabled. + */ + public static boolean enabled = true; + /** + * The time until a world is unloaded after all players have left (HH:mm:ss). + */ + public static String timeUntilUnload = "01:00:00"; + /** + * A set of worlds that should not be unloaded. + */ + public static Set blacklistedWorlds = Set.of("world", "world_nether", "worth_the_end"); + } + + /** + * Stores backup-related settings. + */ + public static class Backup { + + /** + * The maximum number of backups to keep per world. + */ + public static int maxBackupsPerWorld = 5; + /** + * The storage for backups. + */ + public static BackupStorage storage = createBackupStorage(); + + public static class AutoBackup { + + /** + * Whether automatic backups are enabled. + */ + public static boolean enabled = true; + /** + * Whether only active worlds should be backed up. + */ + public static boolean onlyActiveWorlds; + /** + * The interval in seconds between automatic backups. + */ + public static int interval = 900; + } + } + } + + /** + * Stores folder-related settings. + */ + public static class Folder { + + /** + * Whether permissions should be overridden for folders. + */ + public static boolean overridePermissions = true; + /** + * Whether projects should be overridden for folders. + */ + public static boolean overrideProjects = false; + } + + /** + * Loads the configuration values from the plugin's config.yml into the static fields. + */ + public static void load() { + final Logger logger = PLUGIN.getLogger(); + + // Messages + Messages.spawnTeleportMessage = CONFIG.getBoolean("messages.spawn-teleport-message", false); + Messages.joinQuitMessages = CONFIG.getBoolean("messages.join-quit-messages", true); + Messages.dateFormat = CONFIG.getString("messages.date-format", "dd/MM/yyyy"); + + // Settings + Settings.updateChecker = CONFIG.getBoolean("settings.update-checker", true); + Settings.scoreboard = CONFIG.getBoolean("settings.scoreboard", true); + // Settings - Archive + Archive.vanish = CONFIG.getBoolean("settings.archive.vanish", true); + Archive.changeGamemode = CONFIG.getBoolean("settings.archive.change-gamemode", true); + Archive.worldGameMode = parseGameMode(CONFIG.getString("settings.archive.world-gamemode")); + // Settings - Disabled physics + DisabledPhysics.preventConnections = CONFIG.getBoolean("settings.disabled-physics.prevent-connections", true); + DisabledPhysics.preventFluidFlow = CONFIG.getBoolean("settings.disabled-physics.prevent-fluid-flow", true); + DisabledPhysics.preventFallingBlocks = CONFIG.getBoolean("settings.disabled-physics.prevent-falling-blocks", true); + // Settings - Save from death + SaveFromDeath.enabled = CONFIG.getBoolean("settings.save-from-death.enabled", true); + SaveFromDeath.teleportToMapSpawn = CONFIG.getBoolean("settings.save-from-death.teleport-to-map-spawn", true); + // Settings - Build mode + BuildMode.dropItems = CONFIG.getBoolean("settings.build-mode.drop-items", true); + BuildMode.moveItems = CONFIG.getBoolean("settings.build-mode.move-items", true); + // Settings - Builder + Builder.blockWorldEditNonBuilder = CONFIG.getBoolean("settings.builder.block-worldedit-non-builder", true); + Builder.worldEditWand = parseWorldEditWand(); + // Settings - Navigator + Navigator.item = XMaterial.valueOf(CONFIG.getString("settings.navigator.item", "CLOCK")); + Navigator.giveItemOnJoin = CONFIG.getBoolean("settings.navigator.give-item-on-join", true); + + // World + World.lockWeather = CONFIG.getBoolean("world.lock-weather", true); + World.invalidCharacters = CONFIG.getString("world.invalid-characters", "^\b$"); + World.importAllDelay = CONFIG.getInt("world.import-all.delay", 30); + World.deletionBlacklist = CONFIG.getStringList("world.deletion-blacklist").stream().map(String::toLowerCase).collect(Collectors.toSet()); + // World - Default + Default.worldBoarderSize = CONFIG.getInt("world.default.worldborder.size", 6000000); + Default.difficulty = Difficulty.valueOf(CONFIG.getString("world.default.difficulty", "PEACEFUL").toUpperCase(Locale.ROOT)); + Default.gameRules = CONFIG.getConfigurationSection("world.default.gamerules") + .getValues(true) + .entrySet() + .stream() + .map(entry -> { + String key = entry.getKey(); + Object value = entry.getValue(); + GameRule rule = GameRule.getByName(key); + if (rule == null || value == null) { + logger.warning("Could not parse game rule '%s' with value '%s'".formatted(key, value)); + return null; + } + + return switch (value) { + case Boolean booleanValue -> { + if (rule.getType() != Boolean.class) { + PLUGIN.getLogger().warning("Game rule '%s' is not a boolean type, but a boolean value was provided".formatted(key)); + yield null; + } + yield (GameRuleEntry) new GameRuleEntry<>((GameRule) rule, booleanValue); + } + case Integer integerValue -> { + if (rule.getType() != Integer.class) { + logger.warning("Game rule '%s' is not an integer type, but an integer value was provided".formatted(key)); + yield null; + } + yield (GameRuleEntry) new GameRuleEntry<>((GameRule) rule, integerValue); + } + default -> { + logger.warning("Invalid game rule value type. Must be of type Boolean or Integer. Found %s".formatted(value.getClass().getName())); + yield null; + } + }; + }) + .filter(Objects::nonNull) + .toList(); + // World - Default - Permission + Permission.publicPermission = CONFIG.getString("world.default.permission.public", "-"); + Permission.privatePermission = CONFIG.getString("world.default.permission.private", "-"); + // World - Default - Time + Time.sunrise = CONFIG.getInt("world.default.time.sunrise", 0); + Time.noon = CONFIG.getInt("world.default.time.noon", 6000); + Time.night = CONFIG.getInt("world.default.time.night", 18000); + // World - Default - Settings + Default.Settings.physics = CONFIG.getBoolean("world.default.settings.physics", true); + Default.Settings.explosions = CONFIG.getBoolean("world.default.settings.explosions", true); + Default.Settings.mobAi = CONFIG.getBoolean("world.default.settings.mob-ai", true); + Default.Settings.blockBreaking = CONFIG.getBoolean("world.default.settings.block-breaking", true); + Default.Settings.blockPlacement = CONFIG.getBoolean("world.default.settings.block-placement", true); + Default.Settings.blockInteractions = CONFIG.getBoolean("world.default.settings.block-interactions", true); + // World - Default - Settings - Builders Enabled + BuildersEnabled.publicBuilders = CONFIG.getBoolean("world.default.settings.builders-enabled.public", false); + BuildersEnabled.privateBuilders = CONFIG.getBoolean("world.default.settings.builders-enabled.private", true); + // World - Limits + Limits.publicWorlds = CONFIG.getInt("world.default.settings.public-worlds", -1); + Limits.privateWorlds = CONFIG.getInt("world.default.settings.private-worlds", -1); + // World - Unload + Unload.enabled = CONFIG.getBoolean("world.unload.enabled", false); + Unload.timeUntilUnload = CONFIG.getString("world.unload.time-until-unload", "01:00:00"); + Unload.blacklistedWorlds = new HashSet<>(CONFIG.getStringList("world.unload.blacklisted-worlds")); + // World - Backup + Backup.maxBackupsPerWorld = Math.min(CONFIG.getInt("world.backup.max-backups-per-world", 5), 18); + Backup.storage = createBackupStorage(); + // World - Backup - AutoBackup + AutoBackup.enabled = CONFIG.getBoolean("world.backup.auto-backup.enabled", true); + AutoBackup.interval = CONFIG.getInt("world.backup.auto-backup.interval", 900); + AutoBackup.onlyActiveWorlds = CONFIG.getBoolean("world.backup.auto-backup.only-active-worlds", true); + + // Folder + Folder.overridePermissions = CONFIG.getBoolean("folder.override-permissions", true); + Folder.overrideProjects = CONFIG.getBoolean("folder.override-projects", false); + } + + /** + * Parsing the {@link GameMode} from a string. Defaulting to {@link GameMode#ADVENTURE} if the string is not a valid {@link GameMode}. + * + * @param gameModeName The name of the {@link GameMode} to parse. + * @return The parsed {@link GameMode}. + */ + private static GameMode parseGameMode(@Nullable String gameModeName) { + if (gameModeName == null) { + return GameMode.ADVENTURE; + } + + return Arrays.stream(GameMode.values()) + .filter(gameMode -> gameMode.name().equalsIgnoreCase(gameModeName)) + .findAny() + .orElse(GameMode.ADVENTURE); + } + + /** + * Parses the WorldEdit wand from the plugin's configuration. + * + * @return The parsed {@link XMaterial} for the WorldEdit wand. + */ + private static XMaterial parseWorldEditWand() { + File pluginDir = PLUGIN.getDataFolder().getParentFile(); + File configFile = null; + + File weConfig = new File(pluginDir + File.separator + "WorldEdit", "config.yml"); + if (weConfig.exists()) { + configFile = weConfig; + } + + File faweConfig = new File(pluginDir + File.separator + "FastAsyncWorldEdit", "worldedit-config.yml"); + if (faweConfig.exists()) { + configFile = faweConfig; + } + + XMaterial defaultWand = XMaterial.WOODEN_AXE; + if (configFile == null) { + return defaultWand; + } + + YamlConfiguration config = YamlConfiguration.loadConfiguration(configFile); + String wand = config.getString("wand-item"); + if (wand == null) { + return defaultWand; + } + + String namespace = "minecraft:"; + if (wand.toLowerCase(Locale.ROOT).startsWith(namespace)) { + wand = wand.substring(namespace.length()); + } + + return XMaterial.matchXMaterial(wand).orElse(defaultWand); + } + + @Contract("-> new") + private static BackupStorage createBackupStorage() { + String type = CONFIG.getString("world.backup.storage.type", "local").toLowerCase(); + + switch (type) { + case "s3" -> { + ConfigurationSection s3 = CONFIG.getConfigurationSection("world.backup.storage.s3"); + return new S3BackupStorage( + PLUGIN, + s3.getString("url"), + s3.getString("access-key"), + s3.getString("secret-key"), + s3.getString("region"), + s3.getString("bucket"), + s3.getString("path") + ); + } + case "sftp" -> { + ConfigurationSection sftp = CONFIG.getConfigurationSection("world.backup.storage.sftp"); + return new SftpBackupStorage( + PLUGIN, + sftp.getString("host"), + sftp.getInt("port"), + sftp.getString("username"), + sftp.getString("password"), + sftp.getString("path") + ); + } + default -> { + if (!type.equals("local")) { + PLUGIN.getLogger().warning("Unknown backup storage type '" + type + "', defaulting to local storage."); + } + return new LocalBackupStorage(PLUGIN); + } + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/ConfigMigrationManager.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/ConfigMigrationManager.java new file mode 100644 index 0000000..ef75fea --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/ConfigMigrationManager.java @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.config.migration; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.config.Config; +import java.util.HashMap; +import java.util.Map; +import java.util.logging.Logger; +import org.jspecify.annotations.NullMarked; + +/** + * Manages the migration of configurations. It holds a registry of available migration steps and applies them sequentially. + */ +@NullMarked +public class ConfigMigrationManager { + + public static final int LATEST_VERSION = 2; + + private final BuildSystemPlugin plugin; + private final Map migrations; + + /** + * Constructs a new {@link ConfigMigrationManager} instance. + * + * @param plugin The BuildSystemPlugin instance + */ + public ConfigMigrationManager(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.migrations = new HashMap<>(); + + registerMigration(1, new MigrationV1ToV2()); + } + + /** + * Registers a {@link Migration} step. + * + * @param fromVersion The version from which this migration applies. For example, {@link MigrationV1ToV2} is registered with {@code fromVersion = 1} + * @param migration The migration instance + */ + public void registerMigration(int fromVersion, Migration migration) { + this.migrations.put(fromVersion, migration); + } + + /** + * Migrates the given configuration to the latest version. + * + * @throws IllegalStateException If a required migration step is missing. + */ + public void migrate() { + Logger logger = plugin.getLogger(); + + while (Config.getVersion() < LATEST_VERSION) { + int currentVersion = Config.getVersion(); + Migration migration = migrations.get(currentVersion); + if (migration == null) { + throw new IllegalStateException("Missing migration from version " + currentVersion + " to " + (currentVersion + 1)); + } + + logger.info("Migrating from version " + currentVersion + " to " + (currentVersion + 1) + "..."); + migration.migrate(Config.getConfig()); + Config.setVersion(currentVersion + 1); + } + + logger.info("Config is at the latest version: " + Config.getVersion()); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/Migration.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/Migration.java new file mode 100644 index 0000000..2623d83 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/Migration.java @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.config.migration; + +import org.bukkit.configuration.file.FileConfiguration; +import org.jspecify.annotations.NullMarked; + +/** + * Interface for a single configuration migration step. Each implementation should know how to migrate a configuration from a specific source version to the next version. + */ +@NullMarked +public interface Migration { + + /** + * Applies the migration logic to the given configuration. + *

    + * The implementation is responsible for modifying the config's internal data and potentially its version, though the manager will handle version incrementing. + * + * @param config The configuration to migrate + */ + void migrate(FileConfiguration config); +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/MigrationV1ToV2.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/MigrationV1ToV2.java new file mode 100644 index 0000000..0fe471a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/config/migration/MigrationV1ToV2.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.config.migration; + +import java.util.List; +import org.bukkit.configuration.file.FileConfiguration; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class MigrationV1ToV2 implements Migration { + + @Override + public void migrate(FileConfiguration config) { + if (config.contains("settings.archive-vanish")) { + config.set("settings.archive.vanish", config.getBoolean("settings.archive-vanish")); + config.set("settings.archive-vanish", null); + } + + if (config.contains("settings.archive-should-change-gamemode")) { + config.set("settings.archive.change-gamemode", config.getBoolean("settings.archive-should-change-gamemode")); + config.set("settings.archive-should-change-gamemode", null); + } + + if (config.contains("settings.archive-world-game-mode")) { + config.set("settings.archive.world-gamemode", config.getString("settings.archive-world-game-mode")); + config.setComments("settings.archive.world-gamemode", List.of("Options: SURVIVAL, CREATIVE, ADVENTURE, SPECTATOR")); + config.set("settings.archive-world-game-mode", null); + } + + if (config.contains("settings.teleport-after-creation")) { + config.set("settings.teleport-after-creation", null); + } + + if (config.contains("world.void-block")) { + config.set("world.void-block", null); + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/EventDispatcher.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/EventDispatcher.java new file mode 100644 index 0000000..c7f3766 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/EventDispatcher.java @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.event; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.event.world.BuildWorldManipulationEvent; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.jspecify.annotations.NullMarked; + +/** + * Manages the dispatching of custom events related to build world manipulations. + */ +@NullMarked +public class EventDispatcher { + + private final WorldStorageImpl worldStorage; + + /** + * Creates a new {@link EventDispatcher} instance. + * + * @param worldStorage The world storage used to retrieve {@link BuildWorld} information + */ + public EventDispatcher(WorldStorageImpl worldStorage) { + this.worldStorage = worldStorage; + } + + /** + *

    Dispatches a build world manipulation event if the player is in a build world + * and the parent event has not been canceled.

    + * + *

    This method checks if:

    + *
      + *
    1. The parent event is not yet canceled
    2. + *
    3. The player is in a valid build world
    4. + *
    + *

    + * If both conditions are met, it triggers a {@link BuildWorldManipulationEvent} + * to allow further processing of the player's action. + * + * @param player The player who performed the manipulation + * @param parentEvent The original event that triggered this potential manipulation + */ + public void dispatchManipulationEventIfPlayerInBuildWorld(Player player, Cancellable parentEvent) { + if (parentEvent.isCancelled()) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + if (buildWorld == null) { + return; + } + + Bukkit.getPluginManager().callEvent(new BuildWorldManipulationEvent(parentEvent, player, buildWorld)); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/player/PlayerInventoryClearEvent.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/player/PlayerInventoryClearEvent.java new file mode 100644 index 0000000..8de17b6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/player/PlayerInventoryClearEvent.java @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.event.player; + +import java.util.List; +import org.bukkit.entity.Player; +import org.bukkit.event.HandlerList; +import org.bukkit.event.player.PlayerEvent; +import org.jspecify.annotations.NullMarked; + +/** + * Called when a player's inventory is cleared. + */ +@NullMarked +public class PlayerInventoryClearEvent extends PlayerEvent { + + private static final HandlerList HANDLERS = new HandlerList(); + + private final List navigatorSlots; + + public PlayerInventoryClearEvent(Player player, List navigatorSlots) { + super(player); + this.navigatorSlots = navigatorSlots; + } + + public static HandlerList getHandlerList() { + return HANDLERS; + } + + /** + * Gets all slots containing a navigator item in a player's inventory. + * + * @return A list of slot numbers containing navigator items + */ + public List getNavigatorSlots() { + return navigatorSlots; + } + + @Override + public HandlerList getHandlers() { + return HANDLERS; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/world/BuildWorldManipulationEvent.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/world/BuildWorldManipulationEvent.java new file mode 100644 index 0000000..53c0afd --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/event/world/BuildWorldManipulationEvent.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.event.world; + +import de.eintosti.buildsystem.api.event.world.BuildWorldEvent; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.listener.WorldManipulateListener; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.jspecify.annotations.NullMarked; + +/** + * This event reduces duplicated code. + *

    + * It will be called when: + *

      + *
    • Breaking Blocks
    • + *
    • Placing Blocks
    • + *
    • Other modification-related stuff
    • + *
    + * Cancelling this event will affect the parent-Event, which has caused the ManipulationEvent to fire. + *

    Expect the manipulation event to be canceled at {@link org.bukkit.event.EventPriority#LOW} if the player is not allowed to interact with the world.

    + * + * @see WorldManipulateListener + */ +@NullMarked +public class BuildWorldManipulationEvent extends BuildWorldEvent implements Cancellable { + + private final Cancellable parentEvent; + private final Player player; + + public BuildWorldManipulationEvent(Cancellable parentEvent, Player player, BuildWorld buildWorld) { + super(buildWorld); + this.parentEvent = parentEvent; + this.player = player; + } + + @Override + public boolean isCancelled() { + return parentEvent.isCancelled(); + } + + @Override + public void setCancelled(boolean cancelled) { + parentEvent.setCancelled(cancelled); + } + + /** + * @return the event which has caused the manipulation event to fire. + */ + public Cancellable getParentEvent() { + return parentEvent; + } + + /** + * Returns the player manipulating the {@link BuildWorld}. + * + * @return The player manipulating the world + */ + public Player getPlayer() { + return player; + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/LuckPermsExpansion.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/LuckPermsExpansion.java new file mode 100644 index 0000000..0196967 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/LuckPermsExpansion.java @@ -0,0 +1,67 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.expansion.luckperms; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.expansion.luckperms.calculators.BuildModeCalculator; +import de.eintosti.buildsystem.expansion.luckperms.calculators.RoleCalculator; +import java.util.ArrayList; +import java.util.List; +import java.util.function.Supplier; +import net.luckperms.api.LuckPerms; +import net.luckperms.api.context.ContextCalculator; +import net.luckperms.api.context.ContextManager; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class LuckPermsExpansion { + + private final BuildSystemPlugin plugin; + private final ContextManager contextManager; + private final List> registeredCalculators; + + public LuckPermsExpansion(BuildSystemPlugin plugin) { + LuckPerms luckPerms = plugin.getServer().getServicesManager().load(LuckPerms.class); + if (luckPerms == null) { + throw new IllegalStateException("LuckPerms API not loaded."); + } + + this.plugin = plugin; + this.contextManager = luckPerms.getContextManager(); + this.registeredCalculators = new ArrayList<>(); + } + + public void registerAll() { + register("build-mode", () -> new BuildModeCalculator(plugin)); + register("role", () -> new RoleCalculator(plugin)); + plugin.getLogger().info("LuckPerms expansion initialized"); + } + + private void register(String option, Supplier> calculatorSupplier) { + plugin.getLogger().info("Registering '" + option + "' calculator"); + ContextCalculator calculator = calculatorSupplier.get(); + this.contextManager.registerCalculator(calculator); + this.registeredCalculators.add(calculator); + } + + public void unregisterAll() { + this.registeredCalculators.forEach(this.contextManager::unregisterCalculator); + this.registeredCalculators.clear(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/BuildModeCalculator.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/BuildModeCalculator.java new file mode 100644 index 0000000..32a6d15 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/BuildModeCalculator.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.expansion.luckperms.calculators; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import net.luckperms.api.context.ContextCalculator; +import net.luckperms.api.context.ContextConsumer; +import net.luckperms.api.context.ContextSet; +import net.luckperms.api.context.ImmutableContextSet; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuildModeCalculator implements ContextCalculator { + + private static final String KEY = "buildsystem:build-mode"; + + private final PlayerServiceImpl playerManager; + + public BuildModeCalculator(BuildSystemPlugin plugin) { + this.playerManager = plugin.getPlayerService(); + } + + @Override + public void calculate(Player player, ContextConsumer contextConsumer) { + boolean isInBuildMode = playerManager.isInBuildMode(player); + contextConsumer.accept(KEY, String.valueOf(isInBuildMode)); + } + + @Override + public ContextSet estimatePotentialContexts() { + return ImmutableContextSet.builder() + .add(KEY, "true") + .add(KEY, "false") + .build(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/RoleCalculator.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/RoleCalculator.java new file mode 100644 index 0000000..648436d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/luckperms/calculators/RoleCalculator.java @@ -0,0 +1,98 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.expansion.luckperms.calculators; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import java.util.Locale; +import net.luckperms.api.context.ContextCalculator; +import net.luckperms.api.context.ContextConsumer; +import net.luckperms.api.context.ContextSet; +import net.luckperms.api.context.ImmutableContextSet; +import org.bukkit.entity.Player; +import org.checkerframework.checker.nullness.qual.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class RoleCalculator implements ContextCalculator { + + private static final String KEY = "buildsystem:role"; + + private final WorldStorageImpl worldStorage; + + public RoleCalculator(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + } + + @Override + public void calculate(@NonNull Player player, @NonNull ContextConsumer contextConsumer) { + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + contextConsumer.accept(KEY, Role.matchRole(player, buildWorld).toString()); + } + + @Override + public ContextSet estimatePotentialContexts() { + ImmutableContextSet.Builder builder = ImmutableContextSet.builder(); + for (Role role : Role.values()) { + builder.add(KEY, role.toString()); + } + return builder.build(); + } + + @NullMarked + private enum Role { + + /** + * The creator of a {@link BuildWorld}. + */ + CREATOR, + + /** + * A player which has been added to the list of trusted players and is therefore allowed to build in a {@link BuildWorld}. + */ + BUILDER, + + /** + * A player which is neither the {@link #CREATOR} nor a {@link #BUILDER} in a {@link BuildWorld}. + */ + GUEST; + + public static Role matchRole(Player player, @Nullable BuildWorld buildWorld) { + if (buildWorld == null) { + return GUEST; + } + + Builders builders = buildWorld.getBuilders(); + if (builders.isCreator(player)) { + return CREATOR; + } else if (builders.isBuilder(player.getUniqueId())) { + return BUILDER; + } else { + return GUEST; + } + } + + @Override + public String toString() { + return this.name().toLowerCase(Locale.ROOT); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/placeholderapi/PlaceholderApiExpansion.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/placeholderapi/PlaceholderApiExpansion.java new file mode 100644 index 0000000..b9cfd46 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/expansion/placeholderapi/PlaceholderApiExpansion.java @@ -0,0 +1,209 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.expansion.placeholderapi; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import java.util.Locale; +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class PlaceholderApiExpansion extends PlaceholderExpansion { + + private static final String SETTINGS_KEY = "settings"; + + private final BuildSystemPlugin plugin; + private final SettingsManager settingsManager; + private final WorldStorageImpl worldStorage; + + public PlaceholderApiExpansion(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.settingsManager = plugin.getSettingsManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getLogger().info("PlaceholderAPI expansion initialized"); + } + + /** + * Because this is an internal class, you must override this method to let PlaceholderAPI know to not unregister your expansion class when PlaceholderAPI is reloaded + * + * @return true to persist through reloads + */ + @Override + public boolean persist() { + return true; + } + + /** + * Because this is an internal class, this check is not needed, and we can simply return {@code true} + * + * @return Always true since it's an internal class. + */ + @Override + public boolean canRegister() { + return true; + } + + /** + * The name of the person who created this expansion should go here. For convenience do we return the author from the plugin.yml + * + * @return The name of the author as a String. + */ + @Override + public String getAuthor() { + return plugin.getDescription().getAuthors().toString(); + } + + /** + * The placeholder identifier should go here. This is what tells PlaceholderAPI to call our onRequest method to get a value if a placeholder starts with our identifier. This + * must be unique and cannot contain % or _ + * + * @return The identifier in {@code %_%} as String. + */ + @Override + public String getIdentifier() { + return "buildsystem"; + } + + /** + * This is the version of the expansion. You don't have to use numbers since it is set as a String. + *

    + * For convenience do we return the version from the plugin.yml + * + * @return The version as a string. + */ + @Override + public String getVersion() { + return plugin.getDescription().getVersion(); + } + + /** + * This is the method called when a placeholder with our identifier is found and needs a value. We specify the value identifier in this method. + * + * @param player The player for which the placeholder is requested. + * @param identifier The identifier of the placeholder + * @return The value of the placeholder as a string, or {@code null} if the identifier is not recognized. + */ + @Override + @Nullable + public String onPlaceholderRequest(@Nullable Player player, String identifier) { + if (player == null) { + return ""; + } + + if (identifier.matches(".*_.*") && identifier.split("_")[0].equalsIgnoreCase(SETTINGS_KEY)) { + return parseSettingsPlaceholder(player, identifier); + } else { + return parseBuildWorldPlaceholder(player, identifier); + } + } + + /** + * This is the method called when a placeholder with the identifier {@code %buildsystem_settings_%} is found. + * + * @param player The player for which the placeholder is requested + * @param identifier The identifier + * @return The requested setting as a string, or {@code null} if the setting is not recognized + */ + @Nullable + private String parseSettingsPlaceholder(Player player, String identifier) { + Settings settings = settingsManager.getSettings(player); + String settingIdentifier = identifier.split("_")[1]; + + return switch (settingIdentifier.toLowerCase(Locale.ROOT)) { + case "navigatortype" -> settings.getNavigatorType().toString(); + case "glasscolor" -> settings.getDesignColor().toString(); + case "worldsort" -> settings.getWorldDisplay().getWorldSort().toString(); + case "clearinventory" -> String.valueOf(settings.isClearInventory()); + case "disableinteract" -> String.valueOf(settings.isDisableInteract()); + case "hideplayers" -> String.valueOf(settings.isHidePlayers()); + case "instantplacesigns" -> String.valueOf(settings.isInstantPlaceSigns()); + case "keepnavigator" -> String.valueOf(settings.isKeepNavigator()); + case "nightvision" -> String.valueOf(settings.isNightVision()); + case "noclip" -> String.valueOf(settings.isNoClip()); + case "placeplants" -> String.valueOf(settings.isPlacePlants()); + case "scoreboard" -> String.valueOf(settings.isScoreboard()); + case "slabbreaking" -> String.valueOf(settings.isSlabBreaking()); + case "spawnteleport" -> String.valueOf(settings.isSpawnTeleport()); + case "opentrapdoors" -> String.valueOf(settings.isOpenTrapDoors()); + default -> null; + }; + } + + /** + * This is the method called when a placeholder with the identifier needed for {@link PlaceholderApiExpansion#parseSettingsPlaceholder(Player, String)} is not found. + *

    + * The default layout for a world placeholder is {@code %buildsystem_%}. If a world is not specified by using the format {@code %buildsystem__%} then the + * world the player is currently in will be used. + * + * @param player The player for which the placeholder is requested + * @param identifier The identifier + * @return The requested value as a string, or {@code null} if the identifier is not recognized or the world does not exist + */ + @Nullable + private String parseBuildWorldPlaceholder(Player player, String identifier) { + String worldName = player.getWorld().getName(); + if (identifier.matches(".*_.*")) { + String[] splitString = identifier.split("_"); + worldName = splitString[1]; + identifier = splitString[0]; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(worldName); + if (buildWorld == null) { + return "-"; + } + + Builders builders = buildWorld.getBuilders(); + WorldData worldData = buildWorld.getData(); + return switch (identifier.toLowerCase(Locale.ROOT)) { + case "blockbreaking" -> String.valueOf(worldData.blockBreaking().get()); + case "blockplacement" -> String.valueOf(worldData.blockPlacement().get()); + case "builders" -> builders.asPlaceholder(player); + case "buildersenabled" -> String.valueOf(worldData.buildersEnabled().get()); + case "creation" -> Messages.formatDate(buildWorld.getCreation()); + case "creator" -> builders.hasCreator() ? builders.getCreator().getName() : "-"; + case "creatorid" -> builders.hasCreator() ? String.valueOf(builders.getCreator().getUniqueId()) : "-"; + case "explosions" -> String.valueOf(worldData.explosions().get()); + case "lastedited" -> Messages.formatDate(worldData.lastEdited().get()); + case "lastloaded" -> Messages.formatDate(worldData.lastLoaded().get()); + case "lastunloaded" -> Messages.formatDate(worldData.lastUnloaded().get()); + case "loaded" -> String.valueOf(buildWorld.isLoaded()); + case "material" -> worldData.material().get().name(); + case "mobai" -> String.valueOf(worldData.mobAi().get()); + case "permission" -> worldData.permission().get(); + case "private" -> String.valueOf(worldData.privateWorld().get()); + case "project" -> worldData.project().get(); + case "physics" -> String.valueOf(worldData.physics().get()); + case "spawn" -> worldData.customSpawn().get(); + case "status" -> Messages.getString(Messages.getMessageKey(worldData.status().get()), player); + case "time" -> buildWorld.getWorldTime(); + case "type" -> Messages.getString(Messages.getMessageKey(buildWorld.getType()), player); + case "world" -> buildWorld.getName(); + default -> null; + }; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerChatListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerChatListener.java new file mode 100644 index 0000000..5ebc79d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerChatListener.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.util.color.ColorAPI; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class AsyncPlayerChatListener implements Listener { + + public AsyncPlayerChatListener(BuildSystemPlugin plugin) { + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onPlayerChat(AsyncPlayerChatEvent event) { + Player player = event.getPlayer(); + if (!player.hasPermission("buildsystem.color.chat")) { + return; + } + + String coloredMessage = ChatColor.translateAlternateColorCodes('&', ColorAPI.process(event.getMessage())); + event.setMessage(coloredMessage); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerPreLoginListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerPreLoginListener.java new file mode 100644 index 0000000..349525d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/AsyncPlayerPreLoginListener.java @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.LogoutLocation; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.storage.PlayerStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.world.SpawnManager; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerPreLoginEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class AsyncPlayerPreLoginListener implements Listener { + + private final BuildSystemPlugin plugin; + private final PlayerStorage playerStorage; + private final SpawnManager spawnManager; + private final WorldStorageImpl worldStorage; + + public AsyncPlayerPreLoginListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.playerStorage = plugin.getPlayerService().getPlayerStorage(); + this.spawnManager = plugin.getSpawnManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onAsyncPlayerPreLogin(AsyncPlayerPreLoginEvent event) { + UUID uuid = event.getUniqueId(); + BuildPlayer buildPlayer = playerStorage.getBuildPlayer(uuid); + if (buildPlayer == null) { + return; + } + + Settings settings = buildPlayer.getSettings(); + if (settings.isSpawnTeleport() && spawnManager.spawnExists()) { + return; + } + + LogoutLocation logoutLocation = buildPlayer.getLogoutLocation(); + if (logoutLocation == null) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(logoutLocation.worldName()); + if (buildWorld == null) { + buildPlayer.setLogoutLocation(null); + } else { + Bukkit.getScheduler().runTask(plugin, () -> buildWorld.getLoader().load()); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BlockPhysicsListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BlockPhysicsListener.java new file mode 100644 index 0000000..693b6f5 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BlockPhysicsListener.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.config.Config.Settings.DisabledPhysics; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.util.DirectionUtil; +import java.util.List; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.type.Fence; +import org.bukkit.block.data.type.Gate; +import org.bukkit.block.data.type.GlassPane; +import org.bukkit.block.data.type.Stairs; +import org.bukkit.block.data.type.Wall; +import org.bukkit.entity.EntityType; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockExplodeEvent; +import org.bukkit.event.block.BlockFadeEvent; +import org.bukkit.event.block.BlockFormEvent; +import org.bukkit.event.block.BlockFromToEvent; +import org.bukkit.event.block.BlockGrowEvent; +import org.bukkit.event.block.BlockPhysicsEvent; +import org.bukkit.event.block.BlockRedstoneEvent; +import org.bukkit.event.block.BlockSpreadEvent; +import org.bukkit.event.block.LeavesDecayEvent; +import org.bukkit.event.entity.EntityChangeBlockEvent; +import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.metadata.MetadataValue; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BlockPhysicsListener implements Listener { + + private final WorldStorageImpl worldStorage; + + public BlockPhysicsListener(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onBlockPhysics(BlockPhysicsEvent event) { + Block block = event.getBlock(); + BuildWorld buildWorld = worldStorage.getBuildWorld(block.getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + + if (!DisabledPhysics.preventConnections) { + boolean canConnect = switch (block.getBlockData()) { + case Fence fence -> true; + case Gate gate -> true; + case GlassPane glassPane -> true; + case Stairs stairs -> true; + case Wall wall -> true; + default -> false; + }; + if (canConnect) { + event.setCancelled(false); + return; + } + } + + switch (XMaterial.matchXMaterial(block.getType())) { + case REDSTONE_BLOCK -> { + for (BlockFace blockFace : DirectionUtil.BLOCK_SIDES) { + if (isCustomRedstoneLamp(block.getRelative(blockFace))) { + event.setCancelled(false); + return; + } + } + } + case REDSTONE_LAMP -> { + for (BlockFace blockFace : DirectionUtil.BLOCK_SIDES) { + if (block.getRelative(blockFace).getType() == XMaterial.REDSTONE_BLOCK.get()) { + event.setCancelled(false); + return; + } + } + } + } + + event.setCancelled(true); + } + + @EventHandler + public void onLeavesDecay(LeavesDecayEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + event.setCancelled(true); + } + + @EventHandler + public void onBlockFade(BlockFadeEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + event.setCancelled(true); + } + + @EventHandler + public void onBlockForm(BlockFormEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + event.setCancelled(true); + } + + @EventHandler + public void onBlockFromTo(BlockFromToEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + + if (event.getBlock().isLiquid() && !DisabledPhysics.preventFluidFlow) { + event.setCancelled(false); + return; + } + + event.setCancelled(true); + } + + @EventHandler + public void onBlockGrow(BlockGrowEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + event.setCancelled(true); + } + + @EventHandler + public void onBlockSpread(BlockSpreadEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + event.setCancelled(true); + } + + @EventHandler + public void onEntityChangeBlock(EntityChangeBlockEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + + if (event.getEntityType() == EntityType.FALLING_BLOCK && DisabledPhysics.preventFallingBlocks) { + event.setCancelled(true); + event.getBlock().getState().update(false, false); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onBlockRedstone(BlockRedstoneEvent event) { + Block block = event.getBlock(); + if (isCustomRedstoneLamp(block)) { + event.setNewCurrent(15); + } + + XMaterial xMaterial = XMaterial.matchXMaterial(block.getType()); + if (xMaterial != XMaterial.REDSTONE_BLOCK) { + return; + } + + for (BlockFace blockFace : DirectionUtil.BLOCK_SIDES) { + if (isCustomRedstoneLamp(block.getRelative(blockFace))) { + event.setNewCurrent(15); + } + } + } + + @EventHandler + public void onBlockExplode(BlockExplodeEvent event) { + BuildWorld buildWorld = worldStorage.getBuildWorld(event.getBlock().getWorld()); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + event.setCancelled(true); + } + + @EventHandler + public void onEntityExplode(EntityExplodeEvent event) { + World world = event.getLocation().getWorld(); + if (world == null) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(world); + if (buildWorld == null || buildWorld.getData().physics().get()) { + return; + } + + event.setCancelled(true); + } + + private boolean isCustomRedstoneLamp(Block block) { + List metadataValues = block.getMetadata("CustomRedstoneLamp"); + for (MetadataValue value : metadataValues) { + if (value.asBoolean()) { + return true; + } + } + return block.getType().name().equals("REDSTONE_LAMP_ON"); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildModePreventationListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildModePreventationListener.java new file mode 100644 index 0000000..c4688ae --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildModePreventationListener.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.inventory.XInventoryView; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.config.Config.Settings.BuildMode; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuildModePreventationListener implements Listener { + + private final PlayerServiceImpl playerManager; + + public BuildModePreventationListener(BuildSystemPlugin plugin) { + this.playerManager = plugin.getPlayerService(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + /** + * Stops a player from dropping items when in build-mode. + * + * @param event The drop item event + */ + @EventHandler + public void onPlayerDropItem(PlayerDropItemEvent event) { + if (BuildMode.dropItems) { + return; + } + + if (playerManager.isInBuildMode(event.getPlayer())) { + event.getItemDrop().remove(); + } + } + + /** + * Stops a player from moving items into another inventory when in build-mode. + * + * @param event The inventory click event + */ + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + if (BuildMode.moveItems) { + return; + } + + if (!(event.getWhoClicked() instanceof Player player)) { + return; + } + + if (!playerManager.isInBuildMode(player)) { + return; + } + + if (XInventoryView.of(event.getView()).getTopInventory().getType() != InventoryType.CRAFTING) { + event.setCancelled(true); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildWorldResetUnloadListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildWorldResetUnloadListener.java new file mode 100644 index 0000000..3a088af --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/BuildWorldResetUnloadListener.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import org.bukkit.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuildWorldResetUnloadListener implements Listener { + + private final WorldStorageImpl worldStorage; + + public BuildWorldResetUnloadListener(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { + resetUnloadTask(event.getFrom()); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + resetUnloadTask(event.getPlayer().getWorld()); + } + + /** + * Resets the unload task for the given world. + * + * @param world The world for which the unload task should be reset + */ + private void resetUnloadTask(World world) { + BuildWorld buildWorld = worldStorage.getBuildWorld(world.getName()); + if (buildWorld != null) { + buildWorld.getUnloader().resetUnloadTask(); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EditSessionListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EditSessionListener.java new file mode 100644 index 0000000..c891f18 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EditSessionListener.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.event.extent.EditSessionEvent; +import com.sk89q.worldedit.extension.platform.Actor; +import com.sk89q.worldedit.extent.NullExtent; +import com.sk89q.worldedit.util.eventbus.Subscribe; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.Listener; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class EditSessionListener implements Listener { + + private final WorldStorageImpl worldStorage; + + public EditSessionListener(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + WorldEdit.getInstance().getEventBus().register(this); + } + + @Subscribe + public void onEditSession(EditSessionEvent event) { + Actor actor = event.getActor(); + if (actor == null || !actor.isPlayer()) { + return; + } + + Player player = Bukkit.getPlayer(actor.getName()); + if (player == null) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + if (buildWorld == null) { + return; + } + + if (event.getStage() != EditSession.Stage.BEFORE_CHANGE) { + return; + } + + boolean hasAdminPermission = buildWorld.getPermissions().hasAdminPermission(player); + if (hasAdminPermission || (!disableArchivedWorlds(buildWorld, player, event) && !disableNonBuilders(buildWorld, player, event))) { + buildWorld.getData().lastEdited().set(System.currentTimeMillis()); + } + } + + /** + * Disable the editing of archived worlds for players without the necessary permission. + * + * @param buildWorld The build world + * @param player The player + * @param event The EditSessionEvent + * @return {@code true} if the edit was canceled, otherwise {@code false} + */ + private boolean disableArchivedWorlds(BuildWorld buildWorld, Player player, EditSessionEvent event) { + if (buildWorld.getPermissions().canBypassBuildRestriction(player) || player.hasPermission("buildsystem.bypass.archive")) { + return false; + } + + if (buildWorld.getData().status().get() == BuildWorldStatus.ARCHIVE) { + event.setExtent(new NullExtent()); + return true; + } + + return false; + } + + /** + * Disable the editing of worlds in which a player is not a builder. + * + * @param buildWorld The build world + * @param player The player + * @param event The EditSessionEvent + * @return {@code true} if the edit was canceled, otherwise {@code false} + */ + private boolean disableNonBuilders(BuildWorld buildWorld, Player player, EditSessionEvent event) { + if (buildWorld.getPermissions().canBypassBuildRestriction(player) || player.hasPermission("buildsystem.bypass.builders")) { + return false; + } + + Builders builders = buildWorld.getBuilders(); + if (builders.isCreator(player)) { + return false; + } + + if (buildWorld.getData().buildersEnabled().get() && !builders.isBuilder(player)) { + event.setExtent(new NullExtent()); + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntityDamageListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntityDamageListener.java new file mode 100644 index 0000000..e775ceb --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntityDamageListener.java @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.config.Config.Settings.SaveFromDeath; +import io.papermc.lib.PaperLib; +import org.bukkit.Location; +import org.bukkit.entity.EntityType; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntityDamageEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class EntityDamageListener implements Listener { + + private final BuildSystemPlugin plugin; + + public EntityDamageListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onEntityDamage(EntityDamageEvent event) { + if (event.getEntityType() != EntityType.PLAYER) { + return; + } + + // Teleport player up if void damage is taken + if (event.getCause() != EntityDamageEvent.DamageCause.VOID || !SaveFromDeath.enabled) { + return; + } + + Player player = (Player) event.getEntity(); + Location teleportLoc = player.getLocation().clone().add(0, 200, 0); + + if (SaveFromDeath.teleportToMapSpawn) { + BuildWorld buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(player.getWorld()); + if (buildWorld != null) { + Location spawn = buildWorld.getData().getCustomSpawnLocation(); + if (spawn != null) { + teleportLoc = spawn; + } + } + } + + event.setCancelled(true); + player.setFallDistance(0); + PaperLib.teleportAsync(player, teleportLoc) + .whenComplete((completed, throwable) -> { + if (!completed) { + return; + } + XSound.ENTITY_ZOMBIE_INFECT.play(player); + }); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntitySpawnListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntitySpawnListener.java new file mode 100644 index 0000000..9bca933 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/EntitySpawnListener.java @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.World; +import org.bukkit.entity.LivingEntity; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.EntitySpawnEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class EntitySpawnListener implements Listener { + + private final WorldStorage worldStorage; + + public EntitySpawnListener(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onEntitySpawn(EntitySpawnEvent event) { + World bukkitWorld = event.getLocation().getWorld(); + if (bukkitWorld == null) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(bukkitWorld.getName()); + if (buildWorld == null || buildWorld.getData().mobAi().get()) { + return; + } + + if (event.getEntity() instanceof LivingEntity livingEntity) { + livingEntity.setAI(false); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/FoodLevelChangeListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/FoodLevelChangeListener.java new file mode 100644 index 0000000..19ba87f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/FoodLevelChangeListener.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.entity.FoodLevelChangeEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class FoodLevelChangeListener implements Listener { + + private final WorldStorageImpl worldStorage; + + public FoodLevelChangeListener(BuildSystemPlugin plugin) { + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onFoodLevelChange(FoodLevelChangeEvent event) { + if (!(event.getEntity() instanceof Player player)) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + if (buildWorld != null && buildWorld.getData().status().get() == BuildWorldStatus.ARCHIVE) { + event.setCancelled(true); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryCreativeListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryCreativeListener.java new file mode 100644 index 0000000..3fd57ee --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryCreativeListener.java @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.event.player.PlayerInventoryClearEvent; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import java.util.List; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.ClickType; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryCreativeEvent; +import org.bukkit.event.inventory.InventoryType; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class InventoryCreativeListener implements Listener { + + private final BuildSystemPlugin plugin; + + public InventoryCreativeListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onClearInventory(InventoryCreativeEvent event) { + if (event.getClick() != ClickType.CREATIVE + || event.getSlotType() != InventoryType.SlotType.QUICKBAR + || event.getAction() != InventoryAction.PLACE_ALL + ) { + return; + } + + Player player = (Player) event.getWhoClicked(); + List navigatorSlots = InventoryUtils.getNavigatorSlots(player); + + Bukkit.getScheduler().runTaskLater(plugin, () -> { + PlayerInventoryClearEvent playerInventoryClearEvent = new PlayerInventoryClearEvent(player, navigatorSlots); + Bukkit.getServer().getPluginManager().callEvent(playerInventoryClearEvent); + }, 2L); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryListener.java new file mode 100644 index 0000000..34b0071 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/InventoryListener.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class InventoryListener implements Listener { + + private final InventoryManager inventoryManager; + + public InventoryListener(BuildSystemPlugin plugin) { + this.inventoryManager = plugin.getInventoryManager(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onInventoryOpen(InventoryOpenEvent event) { + this.inventoryManager.handleOpen(event); + } + + @EventHandler + public void onInventoryClick(InventoryClickEvent event) { + this.inventoryManager.handleClick(event); + } + + @EventHandler + public void onInventoryClose(InventoryCloseEvent event) { + this.inventoryManager.handleClose(event); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/NavigatorListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/NavigatorListener.java new file mode 100644 index 0000000..d3a58b1 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/NavigatorListener.java @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XPotion; +import com.cryptomorin.xseries.XSound; +import com.cryptomorin.xseries.inventory.XInventoryView; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.CachedValues; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.api.world.navigator.settings.NavigatorType; +import de.eintosti.buildsystem.config.Config.Settings.Navigator; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.world.navigator.ArmorStandManager; +import de.eintosti.buildsystem.world.navigator.inventory.ArchivedWorldsInventory; +import de.eintosti.buildsystem.world.navigator.inventory.DisplayablesInventory; +import de.eintosti.buildsystem.world.navigator.inventory.NavigatorInventory; +import de.eintosti.buildsystem.world.navigator.inventory.PrivateWorldsInventory; +import de.eintosti.buildsystem.world.navigator.inventory.PublicWorldsInventory; +import java.util.Objects; +import java.util.UUID; +import org.bukkit.Material; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.event.player.PlayerArmorStandManipulateEvent; +import org.bukkit.event.player.PlayerDropItemEvent; +import org.bukkit.event.player.PlayerInteractAtEntityEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.potion.PotionEffect; +import org.bukkit.util.Vector; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class NavigatorListener implements Listener { + + private static final double MAX_HEIGHT = 2.074631929397583; + private static final double MIN_HEIGHT = 1.4409877061843872; + + private final BuildSystemPlugin plugin; + private final ArmorStandManager armorStandManager; + private final PlayerServiceImpl playerService; + private final SettingsManager settingsManager; + private final WorldStorage worldStorage; + + public NavigatorListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.armorStandManager = plugin.getArmorStandManager(); + this.playerService = plugin.getPlayerService(); + this.settingsManager = plugin.getSettingsManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + /** + * Called whenever the player interacts with the navigator item. + * + * @param event The event which calls this method + */ + @EventHandler + public void manageNavigatorItemInteraction(PlayerInteractEvent event) { + ItemStack itemStack = event.getItem(); + if (itemStack == null || itemStack.getType() == Material.AIR) { + return; + } + + Player player = event.getPlayer(); + if (XInventoryView.of(player.getOpenInventory()).getTopInventory().getType() != InventoryType.CRAFTING) { + return; + } + + if (InventoryUtils.isNavigator(itemStack)) { + event.setCancelled(true); + if (!player.hasPermission("buildsystem.navigator.item")) { + Messages.sendPermissionError(player); + return; + } + openNavigator(player); + } else if (isCloseNavigatorItem(player, itemStack)) { + event.setCancelled(true); + playerService.closeNewNavigator(player); + } + } + + private void openNavigator(Player player) { + Settings settings = settingsManager.getSettings(player); + switch (settings.getNavigatorType()) { + case OLD -> { + new NavigatorInventory(plugin).openInventory(player); + XSound.BLOCK_CHEST_OPEN.play(player); + } + case NEW -> { + if (playerService.getOpenNavigator().contains(player)) { + Messages.sendMessage(player, "worlds_navigator_open"); + return; + } + + summonNewNavigator(player); + String findItemName = Messages.getString("navigator_item", player); + ItemStack replaceItem = InventoryUtils.createItem(XMaterial.BARRIER, Messages.getString("barrier_item", player)); + InventoryUtils.replaceItem(player, findItemName, Navigator.item, replaceItem); + } + } + } + + private void summonNewNavigator(Player player) { + CachedValues cachedValues = playerService.getPlayerStorage().getBuildPlayer(player).getCachedValues(); + cachedValues.saveWalkSpeed(player.getWalkSpeed()); + cachedValues.saveFlySpeed(player.getFlySpeed()); + + // Cancel movement and disable flying + player.setSprinting(false); + player.setWalkSpeed(0.0f); + player.setFlySpeed(0.0f); + player.setVelocity(new Vector(0, 0, 0)); + player.teleport(player.getLocation()); + player.addPotionEffect(new PotionEffect(XPotion.BLINDNESS.get(), PotionEffect.INFINITE_DURATION, 0, false, false)); + player.addPotionEffect(new PotionEffect(XPotion.JUMP_BOOST.get(), PotionEffect.INFINITE_DURATION, 250, false, false)); + + armorStandManager.spawnArmorStands(player); + playerService.getOpenNavigator().add(player); + } + + /** + * Manages a player's interaction with the {@link NavigatorType#NEW} navigator. + * + * @param event The event which calls this method + */ + @EventHandler + public void manageNewNavigatorInteraction(PlayerInteractAtEntityEvent event) { + Player player = event.getPlayer(); + Entity entity = event.getRightClicked(); + + // Disable interaction entities in archived worlds + disableArchivedWorlds(player, event); + + if (!playerService.getOpenNavigator().contains(player) || !(entity instanceof ArmorStand armorStand)) { + return; + } + + if (isCloseNavigatorItem(player, player.getInventory().getItemInMainHand())) { + event.setCancelled(true); + playerService.closeNewNavigator(player); + return; + } + + Vector clickedPosition = event.getClickedPosition(); + if (clickedPosition.getY() > MIN_HEIGHT && clickedPosition.getY() < MAX_HEIGHT) { + NavigatorCategory category = ArmorStandManager.matchNavigatorCategory(armorStand); + if (category == null) { + return; + } + + UUID ownerUUID = ArmorStandManager.getOwner(armorStand); + if (!Objects.equals(ownerUUID, player.getUniqueId())) { + return; + } + + DisplayablesInventory inventory = switch (category) { + case PUBLIC -> new PublicWorldsInventory(plugin, player); + case ARCHIVE -> new ArchivedWorldsInventory(plugin, player); + case PRIVATE -> new PrivateWorldsInventory(plugin, player); + }; + + XSound.BLOCK_CHEST_OPEN.play(player); + inventory.openInventory(); + } + } + + /** + * Cancels an event if the player is in an archived world. + * + * @param player The player object + * @param cancellable The event to cancel + */ + private void disableArchivedWorlds(Player player, Cancellable cancellable) { + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + if (buildWorld == null || buildWorld.getData().status().get() != BuildWorldStatus.ARCHIVE) { + return; + } + + if (!playerService.isInBuildMode(player)) { + cancellable.setCancelled(true); + } + } + + /** + * Disables players from manipulating with armor stands which make up the {@link NavigatorType#NEW} navigator. + * + * @param event The event which calls this method + */ + @EventHandler + public void preventNewNavigatorManipulation(PlayerArmorStandManipulateEvent event) { + ArmorStand armorStand = event.getRightClicked(); + if (ArmorStandManager.matchNavigatorCategory(armorStand) == null || ArmorStandManager.getOwner(armorStand) == null) { + return; + } + + event.setCancelled(true); + } + + /** + * Prevents players from dropping the item which is used to close the {@link NavigatorType#NEW} navigator. + * + * @param event The event which calls this method + */ + @EventHandler + public void preventBarrierDrop(PlayerDropItemEvent event) { + Player player = event.getPlayer(); + if (!playerService.getOpenNavigator().contains(player)) { + return; + } + + if (isCloseNavigatorItem(player, event.getItemDrop().getItemStack())) { + event.setCancelled(true); + } + } + + /** + * Checks if the given item is the item that is used to close the {@link NavigatorType#NEW} navigator. + * + * @param player The player used to get the item name + * @param itemStack The item stack to check + * @return {@code true} if the item is the navigator close item, {@code false} otherwise + */ + private boolean isCloseNavigatorItem(Player player, @Nullable ItemStack itemStack) { + if (itemStack == null || itemStack.getType() == Material.AIR) { + return false; + } + + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta == null || !itemMeta.hasDisplayName()) { + return false; + } + + if (XMaterial.matchXMaterial(itemStack) != XMaterial.BARRIER) { + return false; + } + + return itemMeta.getDisplayName().equals(Messages.getString("barrier_item", player)); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerChangedWorldListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerChangedWorldListener.java new file mode 100644 index 0000000..565b099 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerChangedWorldListener.java @@ -0,0 +1,200 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.XPotion; +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.CachedValues; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.config.Config.Settings.Archive; +import de.eintosti.buildsystem.config.Config.World.Unload; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.world.navigator.ArmorStandManager; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerChangedWorldEvent; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.potion.PotionEffect; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class PlayerChangedWorldListener implements Listener { + + private final ArmorStandManager armorStandManager; + private final PlayerServiceImpl playerManager; + private final SettingsManager settingsManager; + private final WorldStorageImpl worldStorage; + + private final Map playerGamemode; + private final Map playerInventory; + private final Map playerArmor; + + public PlayerChangedWorldListener(BuildSystemPlugin plugin) { + this.armorStandManager = plugin.getArmorStandManager(); + this.playerManager = plugin.getPlayerService(); + this.settingsManager = plugin.getSettingsManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + + this.playerGamemode = new HashMap<>(); + this.playerInventory = new HashMap<>(); + this.playerArmor = new HashMap<>(); + + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onPlayerChangedWorld(PlayerChangedWorldEvent event) { + Player player = event.getPlayer(); + String worldName = player.getWorld().getName(); + + event.getPlayer().setAllowFlight(true); + + BuildWorld oldWorld = worldStorage.getBuildWorld(event.getFrom()); + if (oldWorld != null && Unload.enabled) { + oldWorld.getUnloader().resetUnloadTask(); + } + + BuildWorld newWorld = worldStorage.getBuildWorld(worldName); + if (newWorld != null && !newWorld.getData().physics().get() && player.hasPermission("buildsystem.physics.message")) { + Messages.sendMessage(player, "physics_deactivated_in_world", Map.entry("%world%", newWorld.getName())); + } + + removeOldNavigator(player); + removeBuildMode(player); + setGoldBlock(newWorld); + checkWorldStatus(player); + + if (settingsManager.getSettings(player).isScoreboard()) { + playerManager.forceUpdateSidebar(player); + } + } + + private void removeOldNavigator(Player player) { + armorStandManager.removeArmorStands(player); + player.removePotionEffect(XPotion.BLINDNESS.get()); + } + + private void removeBuildMode(Player player) { + if (!playerManager.getBuildModePlayers().remove(player.getUniqueId())) { + return; + } + + CachedValues cachedValues = playerManager.getPlayerStorage().getBuildPlayer(player).getCachedValues(); + cachedValues.resetGameModeIfPresent(player); + cachedValues.resetInventoryIfPresent(player); + XSound.ENTITY_EXPERIENCE_ORB_PICKUP.play(player); + Messages.sendMessage(player, "build_deactivated_self"); + } + + private void setGoldBlock(@Nullable BuildWorld buildWorld) { + if (buildWorld == null || buildWorld.getType() != BuildWorldType.VOID + || buildWorld.getData().status().get() != BuildWorldStatus.NOT_STARTED) { + return; + } + + World bukkitWorld = Bukkit.getWorld(buildWorld.getName()); + if (bukkitWorld == null) { + return; + } + + bukkitWorld.getBlockAt(0, 64, 0).setType(Material.GOLD_BLOCK); + } + + @SuppressWarnings("deprecation") + private void checkWorldStatus(Player player) { + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + if (buildWorld == null) { + return; + } + + UUID playerUUID = player.getUniqueId(); + PlayerInventory playerInventory = player.getInventory(); + + if (this.playerGamemode.containsKey(playerUUID)) { + player.setGameMode(this.playerGamemode.get(playerUUID)); + this.playerGamemode.remove(playerUUID); + } + + if (this.playerInventory.containsKey(playerUUID)) { + playerInventory.clear(); + playerInventory.setContents(this.playerInventory.get(playerUUID)); + this.playerInventory.remove(playerUUID); + } + + if (this.playerArmor.containsKey(playerUUID)) { + removeArmorContent(player); + playerInventory.setArmorContents(this.playerArmor.get(playerUUID)); + this.playerArmor.remove(playerUUID); + } + + if (buildWorld.getData().status().get() == BuildWorldStatus.ARCHIVE) { + this.playerGamemode.put(playerUUID, player.getGameMode()); + this.playerInventory.put(playerUUID, playerInventory.getContents()); + this.playerArmor.put(playerUUID, playerInventory.getArmorContents()); + + removeArmorContent(player); + playerInventory.clear(); + setSpectatorMode(player); + + if (Archive.vanish) { + player.addPotionEffect(new PotionEffect(XPotion.INVISIBILITY.get(), PotionEffect.INFINITE_DURATION, 0, false, false), false); + Bukkit.getOnlinePlayers().forEach(pl -> pl.hidePlayer(player)); + } + } else { + player.removePotionEffect(XPotion.INVISIBILITY.get()); + Bukkit.getOnlinePlayers().forEach(pl -> pl.showPlayer(player)); + } + + playerManager.giveNavigator(player); + } + + private void setSpectatorMode(Player player) { + // Checking if the game mode should be set to adventure mode on archive worlds + if (Archive.changeGamemode) { + player.setGameMode(Archive.worldGameMode); + } + player.setSaturation(20); + player.setHealth(20); + player.setAllowFlight(true); + player.setFlying(true); + } + + private void removeArmorContent(Player player) { + PlayerInventory playerInventory = player.getInventory(); + playerInventory.setHelmet(null); + playerInventory.setChestplate(null); + playerInventory.setLeggings(null); + playerInventory.setBoots(null); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerCommandPreprocessListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerCommandPreprocessListener.java new file mode 100644 index 0000000..742612e --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerCommandPreprocessListener.java @@ -0,0 +1,303 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.google.common.collect.Sets; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.config.Config.Settings.Builder; +import de.eintosti.buildsystem.event.player.PlayerInventoryClearEvent; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import java.util.List; +import java.util.Set; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerCommandPreprocessListener implements Listener { + + private static final Set DISABLED_COMMANDS = Sets.newHashSet( + "/worldedit", + "/we", + + // History Control + "//undo", + "//redo", + "/clearhistory", + + // Region Selection + "//wand", + "/toggleeditwand", + "//sel", + "//desel", + "//pos1", + "//pos2", + "//0", + "//1", + "//2", + "//hpos1", + "//hpos2", + "//chunk", + "//expand", + "//contract", + "//outset", + "//inset", + "//count", + "//distr", + + // Region Operation + "//set", + "//replace", + "//repl", + "//overlay", + "//walls", + "//outline", + "//center", + "//smooth", + "//deform", + "//regen", + "//hollow", + "//move", + "//stack", + "//naturalize", + "//line", + "//curve", + "//forest", + "//flora", + "//air", + + // Clipboards and Schematics + "//copy", + "//cut", + "//paste", + "//rotate", + "//flip", + "//schematic", + "//schem", + "/clearclipboard", + + // Generation + "//generate", + "//generatebiome", + "//hcyl", + "//cyl", + "//sphere", + "//hsphere", + "//pyramid", + "/forestgen", + "/pumpkins", + + // Utilities + "/toggleplace", + "//fill", + "//fillr", + "//drain", + "//fixwater", + "//fixlava", + "/removeabove", + "/removebelow", + "/replacenear", + "/removenear", + "/snow", + "/thaw", + "/ex", + "/butcher", + "/remove", + "/green", + "//calc", + + // Chunk Tools + "/chunkinfo", + "/listchunks", + "/delchunks", + + // Superpickaxe Tools + "//", + "/sp single", + "/sp area", + "/sp recur", + + // General Tools + "/tool", + "/none", + "/farwand", + "/lrbuild", + "/tree", + "/deltree", + "/repl", + "/cycler", + "/flood", + + // Brushes + "//brush", + "//br", + "/brush", + "/br", + "/size", + "/mat", + "/range", + "/mask", + "//gmask", + + // Quick-Travel + "/unstuck", + "/ascend", + "/asc", + "/descend", + "/desc", + "/thru", + "/jumpto", + "/up", + + // Snapshots + "//restore", + "/snapshot", + + // Java Scriptings + "//cs", + "/.s", + + // Biomes + "/biomelist", + "/biomels", + "/biomeinfo", + "//setbiome", + + // Voxel Sniper + "/vs", + "/voxel", + "/voxel_chunk", + "/voxel_height", + "/voxel_ink", + "/voxel_ink_replace", + "/voxel_list", + "/voxel_replace", + "/voxel_sniper", + "/b", + "/brush", + "/brush_toolkit", + "/d", + "/default", + "/goto", + "/p", + "/paint", + "/perf", + "/performer", + "/v", + "/vc", + "/vchunk", + "/vh", + "/vi", + "/vir", + "/vl", + "/vr--" + ); + + private final BuildSystemPlugin plugin; + private final SettingsManager settingsManager; + private final WorldStorageImpl worldStorage; + + public PlayerCommandPreprocessListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.settingsManager = plugin.getSettingsManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onPlayerCommandPreprocess(PlayerCommandPreprocessEvent event) { + if (event.isCancelled()) { + return; + } + + String command = event.getMessage().split(" ")[0]; + Player player = event.getPlayer(); + + if (command.equalsIgnoreCase("/clear")) { + ItemStack navigatorItem = InventoryUtils.createNavigatorItem(player); + if (!player.getInventory().contains(navigatorItem)) { + return; + } + + if (settingsManager.getSettings(player).isKeepNavigator()) { + List navigatorSlots = InventoryUtils.getNavigatorSlots(player); + Bukkit.getScheduler().runTaskLater(plugin, () -> { + PlayerInventoryClearEvent playerInventoryClearEvent = new PlayerInventoryClearEvent(player, navigatorSlots); + Bukkit.getServer().getPluginManager().callEvent(playerInventoryClearEvent); + }, 2L); + } + return; + } + + if (Builder.blockWorldEditNonBuilder) { + if (!DISABLED_COMMANDS.contains(command)) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + if (buildWorld == null) { + return; + } + if (disableArchivedWorlds(buildWorld, player, event)) { + return; + } + + checkBuilders(buildWorld, player, event); + } + } + + private boolean disableArchivedWorlds(BuildWorld buildWorld, Player player, PlayerCommandPreprocessEvent event) { + if (buildWorld.getPermissions().canBypassBuildRestriction(player) || player.hasPermission("buildsystem.bypass.archive")) { + return false; + } + + if (buildWorld.getData().status().get() == BuildWorldStatus.ARCHIVE) { + event.setCancelled(true); + Messages.sendMessage(player, "command_archive_world"); + return true; + } + return false; + } + + private void checkBuilders(BuildWorld buildWorld, Player player, PlayerCommandPreprocessEvent event) { + if (buildWorld.getPermissions().canBypassBuildRestriction(player) || player.hasPermission("buildsystem.bypass.builders")) { + return; + } + + Builders builders = buildWorld.getBuilders(); + if (builders.isCreator(player)) { + return; + } + + if (buildWorld.getData().buildersEnabled().get() && !builders.isBuilder(player)) { + event.setCancelled(true); + Messages.sendMessage(player, "command_not_builder"); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerInventoryClearListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerInventoryClearListener.java new file mode 100644 index 0000000..2677389 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerInventoryClearListener.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.event.player.PlayerInventoryClearEvent; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerInventoryClearListener implements Listener { + + private final SettingsManager settingsManager; + + public PlayerInventoryClearListener(BuildSystemPlugin plugin) { + this.settingsManager = plugin.getSettingsManager(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onPlayerInventoryClear(PlayerInventoryClearEvent event) { + Player player = event.getPlayer(); + Settings settings = settingsManager.getSettings(player); + if (!settings.isKeepNavigator() || !player.hasPermission("buildsystem.navigator.item")) { + return; + } + + PlayerInventory playerInventory = player.getInventory(); + ItemStack navigatorItem = InventoryUtils.createNavigatorItem(player); + event.getNavigatorSlots().forEach(slot -> playerInventory.setItem(slot, navigatorItem)); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerJoinListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerJoinListener.java new file mode 100644 index 0000000..b87c2b3 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerJoinListener.java @@ -0,0 +1,201 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.XPotion; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.LogoutLocation; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.config.Config.Settings.Archive; +import de.eintosti.buildsystem.player.LogoutLocationImpl; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.player.settings.SettingsImpl; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.util.UpdateChecker; +import de.eintosti.buildsystem.world.SpawnManager; +import io.papermc.lib.PaperLib; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.potion.PotionEffect; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerJoinListener implements Listener { + + private final BuildSystemPlugin plugin; + private final PlayerServiceImpl playerManager; + private final SettingsManager settingsManager; + private final SpawnManager spawnManager; + private final WorldStorageImpl worldStorage; + + public PlayerJoinListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.playerManager = plugin.getPlayerService(); + this.settingsManager = plugin.getSettingsManager(); + this.spawnManager = plugin.getSpawnManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler(priority = EventPriority.LOWEST) + public void sendPlayerJoinMessage(PlayerJoinEvent event) { + Player player = event.getPlayer(); + String message = Config.Messages.joinQuitMessages + ? Messages.getString("player_join", player, Map.entry("%player%", player.getName())) + : null; + event.setJoinMessage(message); + } + + @EventHandler + @SuppressWarnings("deprecation") + public void onPlayerJoin(PlayerJoinEvent event) { + Player player = event.getPlayer(); + UUIDFetcher.cacheUser(player.getUniqueId(), player.getName()); + + BuildPlayer buildPlayer = playerManager.getPlayerStorage().createBuildPlayer(player); + manageHidePlayer(player, buildPlayer); + manageSettings(player, buildPlayer.getSettings()); + teleportToCorrectLocation(player, buildPlayer); + playerManager.giveNavigator(player); + + String worldName = player.getWorld().getName(); + BuildWorld buildWorld = worldStorage.getBuildWorld(worldName); + if (buildWorld != null) { + WorldData worldData = buildWorld.getData(); + if (!worldData.physics().get() && player.hasPermission("buildsystem.physics.message")) { + Messages.sendMessage(player, "physics_deactivated_in_world", Map.entry("%world%", worldName)); + } + + if (Archive.vanish && worldData.status().get() == BuildWorldStatus.ARCHIVE) { + player.addPotionEffect(new PotionEffect(XPotion.INVISIBILITY.get(), PotionEffect.INFINITE_DURATION, 0, false, false), false); + Bukkit.getOnlinePlayers().forEach(pl -> pl.hidePlayer(player)); + } + } + + if (player.hasPermission("buildsystem.updates")) { + performUpdateCheck(player); + } + } + + /** + * Teleports the player to the correct location. + *

      + *
    • If the spawn exists and {@link SettingsImpl#isSpawnTeleport()} is enabled for the player, the player will be teleported to the spawn
    • + *
    • If the player has a {@link LogoutLocationImpl}, teleport to that location
    • + *
    • Otherwise, do nothing
    • + *
    + * + * @param player The player to teleport + * @param buildPlayer The build-player for the given player + */ + private void teleportToCorrectLocation(Player player, BuildPlayer buildPlayer) { + if (buildPlayer.getSettings().isSpawnTeleport() && spawnManager.spawnExists()) { + spawnManager.teleport(player); + return; + } + + LogoutLocation logoutLocation = buildPlayer.getLogoutLocation(); + if (logoutLocation == null) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(logoutLocation.worldName()); + if (buildWorld == null) { + return; + } + + int delay = buildWorld.isLoaded() ? 0 : 20; + Bukkit.getScheduler().runTaskLater(plugin, () -> { + Location location = logoutLocation.location(); + if (location != null) { + PaperLib.teleportAsync(player, location); + } + }, delay); + } + + @SuppressWarnings("deprecation") + private void manageHidePlayer(Player player, BuildPlayer buildPlayer) { + // Hide all players to player + if (buildPlayer.getSettings().isHidePlayers()) { + Bukkit.getOnlinePlayers().forEach(player::hidePlayer); + } + + // Hide player from all players who have hidePlayers enabled + for (Player pl : Bukkit.getOnlinePlayers()) { + if (!settingsManager.getSettings(pl).isHidePlayers()) { + continue; + } + pl.hidePlayer(player); + } + } + + /** + * Activates features for the player according to their {@link SettingsImpl}. + * + * @param player The player to activate the features for + * @param settings The player's settings + */ + private void manageSettings(Player player, Settings settings) { + if (settings.isNoClip()) { + plugin.getNoClipManager().startNoClip(player); + } + + if (settings.isScoreboard()) { + settingsManager.displayScoreboard(player); + plugin.getPlayerService().forceUpdateSidebar(player); + } + + if (settings.isClearInventory()) { + player.getInventory().clear(); + } + } + + private void performUpdateCheck(Player player) { + if (!Config.Settings.updateChecker) { + return; + } + + UpdateChecker.init(plugin, BuildSystemPlugin.SPIGOT_ID) + .requestUpdateCheck() + .whenComplete((result, e) -> { + if (result.requiresUpdate()) { + StringBuilder stringBuilder = new StringBuilder(); + Messages.getStringList("update_available", player).forEach(line -> + stringBuilder.append(line + .replace("%new_version%", result.getNewestVersion()) + .replace("%current_version%", plugin.getDescription().getVersion())) + .append("\n")); + player.sendMessage(stringBuilder.toString()); + } + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerMoveListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerMoveListener.java new file mode 100644 index 0000000..021006d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerMoveListener.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.navigator.settings.NavigatorType; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerMoveEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerMoveListener implements Listener { + + private final BuildSystemPlugin plugin; + private final PlayerServiceImpl playerManager; + private final SettingsManager settingsManager; + + public PlayerMoveListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.playerManager = plugin.getPlayerService(); + this.settingsManager = plugin.getSettingsManager(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onPlayerMove(PlayerMoveEvent event) { + Player player = event.getPlayer(); + if (!playerManager.getOpenNavigator().contains(player)) { + return; + } + + Settings settings = settingsManager.getSettings(player); + if (!settings.getNavigatorType().equals(NavigatorType.NEW)) { + return; + } + + Location to = event.getTo(); + if (to == null) { + return; + } + + Location from = event.getFrom(); + if (from.getBlockX() != to.getBlockX() || from.getBlockZ() != to.getBlockZ()) { + Bukkit.getServer().getScheduler().runTaskLater(plugin, () -> playerManager.closeNewNavigator(player), 5L); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerQuitListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerQuitListener.java new file mode 100644 index 0000000..8b4314b --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerQuitListener.java @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.CachedValues; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.player.LogoutLocationImpl; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerQuitEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerQuitListener implements Listener { + + private final BuildSystemPlugin plugin; + private final PlayerServiceImpl playerManager; + private final SettingsManager settingsManager; + + public PlayerQuitListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.playerManager = plugin.getPlayerService(); + this.settingsManager = plugin.getSettingsManager(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler(priority = EventPriority.LOWEST) + public void sendPlayerQuitMessage(PlayerQuitEvent event) { + Player player = event.getPlayer(); + String message = Config.Messages.joinQuitMessages + ? Messages.getString("player_quit", player, Map.entry("%player%", player.getName())) + : null; + event.setQuitMessage(message); + } + + @EventHandler + public void onPlayerQuit(PlayerQuitEvent event) { + Player player = event.getPlayer(); + playerManager.closeNewNavigator(player); + + Settings settings = settingsManager.getSettings(player); + if (settings.isNoClip()) { + plugin.getNoClipManager().stopNoClip(player.getUniqueId()); + } + + if (settings.isScoreboard()) { + settingsManager.hideScoreboard(player); + } + + if (settings.isClearInventory()) { + player.getInventory().clear(); + } + + BuildPlayer buildPlayer = playerManager.getPlayerStorage().getBuildPlayer(player); + buildPlayer.setLogoutLocation(new LogoutLocationImpl( + player.getWorld().getName(), + player.getLocation() + )); + + CachedValues cachedValues = buildPlayer.getCachedValues(); + cachedValues.resetGameModeIfPresent(player); + cachedValues.resetInventoryIfPresent(player); + playerManager.getBuildModePlayers().remove(player.getUniqueId()); + + manageHidePlayer(player); + } + + @SuppressWarnings("deprecation") + private void manageHidePlayer(Player player) { + // Show all hidden players to player + if (settingsManager.getSettings(player).isHidePlayers()) { + Bukkit.getOnlinePlayers().forEach(player::showPlayer); + } + + // Show player to all players who had him/her hidden + for (Player pl : Bukkit.getOnlinePlayers()) { + if (!settingsManager.getSettings(pl).isHidePlayers()) { + continue; + } + pl.showPlayer(player); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerRespawnListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerRespawnListener.java new file mode 100644 index 0000000..2044e44 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerRespawnListener.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.world.SpawnManager; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerRespawnEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerRespawnListener implements Listener { + + private final SettingsManager settingsManager; + private final SpawnManager spawnManager; + + public PlayerRespawnListener(BuildSystemPlugin plugin) { + this.settingsManager = plugin.getSettingsManager(); + this.spawnManager = plugin.getSpawnManager(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onPlayerRespawn(PlayerRespawnEvent event) { + Player player = event.getPlayer(); + Settings settings = settingsManager.getSettings(player); + + if (settings.isSpawnTeleport() && spawnManager.spawnExists()) { + event.setRespawnLocation(spawnManager.getSpawn()); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerTeleportListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerTeleportListener.java new file mode 100644 index 0000000..88dd723 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/PlayerTeleportListener.java @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.storage.PlayerStorage; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.player.PlayerTeleportEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerTeleportListener implements Listener { + + private final PlayerStorage playerStorage; + private final WorldStorage worldStorage; + + public PlayerTeleportListener(BuildSystemPlugin plugin) { + this.playerStorage = plugin.getPlayerService().getPlayerStorage(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onPlayerTeleport(PlayerTeleportEvent event) { + Player player = event.getPlayer(); + + if (event.getCause() != PlayerTeleportEvent.TeleportCause.UNKNOWN) { + playerStorage.getBuildPlayer(player).setPreviousLocation(event.getFrom()); + } + + Location to = event.getTo(); + if (to == null) { + return; + } + + World toWorld = to.getWorld(); + if (toWorld == null) { + return; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(toWorld); + if (buildWorld == null) { + return; + } + + // Users can always teleport to the main server world + if (Bukkit.getWorlds().getFirst().equals(toWorld)) { + return; + } + + if (!buildWorld.getPermissions().canEnter(player)) { + Messages.sendMessage(player, "worlds_tp_entry_forbidden"); + event.setCancelled(true); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SettingsInteractListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SettingsInteractListener.java new file mode 100644 index 0000000..873c81c --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SettingsInteractListener.java @@ -0,0 +1,425 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.XBlock; +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XTag; +import com.google.common.collect.Sets; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.player.customblock.CustomBlockManager; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.util.DirectionUtil; +import de.eintosti.buildsystem.util.inventory.MaterialUtils; +import java.util.Arrays; +import java.util.EnumSet; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.DyeColor; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.data.MultipleFacing; +import org.bukkit.block.data.Openable; +import org.bukkit.block.data.type.Slab; +import org.bukkit.entity.Player; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.EquipmentSlot; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.util.RayTraceResult; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SettingsInteractListener implements Listener { + + private static final EnumSet OTHER_PLANTS = Sets.newEnumSet(Sets.newHashSet( + XMaterial.TORCHFLOWER, XMaterial.PITCHER_PLANT, XMaterial.LILY_PAD, XMaterial.PINK_PETALS, + XMaterial.BROWN_MUSHROOM, XMaterial.RED_MUSHROOM, XMaterial.CRIMSON_FUNGUS, XMaterial.WARPED_FUNGUS, + XMaterial.SHORT_GRASS, XMaterial.FERN, XMaterial.DEAD_BUSH, XMaterial.LARGE_FERN, XMaterial.TALL_GRASS, + XMaterial.NETHER_SPROUTS, XMaterial.WARPED_ROOTS, XMaterial.CRIMSON_ROOTS, XMaterial.SUGAR_CANE, XMaterial.BAMBOO, + XMaterial.BIG_DRIPLEAF, XMaterial.SMALL_DRIPLEAF, XMaterial.SEAGRASS, XMaterial.SWEET_BERRIES + ), XMaterial.class); + + private final CustomBlockManager customBlockManager; + private final SettingsManager settingsManager; + private final WorldStorageImpl worldStorage; + + private final Set cachePlayers; + + public SettingsInteractListener(BuildSystemPlugin plugin) { + this.customBlockManager = plugin.getCustomBlockManager(); + this.settingsManager = plugin.getSettingsManager(); + this.worldStorage = plugin.getWorldService().getWorldStorage(); + + this.cachePlayers = new HashSet<>(); + + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void manageIronDoorSetting(PlayerInteractEvent event) { + if (!isValid(event)) { + return; + } + + Block block = event.getClickedBlock(); + if (block == null) { + return; + } + + if (event.getHand() != EquipmentSlot.HAND) { + return; + } + + Player player = event.getPlayer(); + if (player.isSneaking() || event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + + Settings settings = settingsManager.getSettings(player); + if (!settings.isOpenTrapDoors()) { + return; + } + + XMaterial material = XMaterial.matchXMaterial(block.getType()); + if (material != XMaterial.IRON_DOOR && material != XMaterial.IRON_TRAPDOOR) { + return; + } + + event.setCancelled(true); + Openable openable = (Openable) block.getBlockData(); + openable.setOpen(!openable.isOpen()); + block.setBlockData(openable); + } + + @EventHandler + public void manageSlabSetting(PlayerInteractEvent event) { + if (!isValid(event)) { + return; + } + + Action action = event.getAction(); + if (action != Action.LEFT_CLICK_BLOCK) { + return; + } + + Player player = event.getPlayer(); + Settings settings = settingsManager.getSettings(player); + if (!settings.isSlabBreaking()) { + return; + } + + Block block = event.getClickedBlock(); + if (block == null || !(block.getBlockData() instanceof Slab slab) || slab.getType() != Slab.Type.DOUBLE) { + return; + } + + event.setCancelled(true); + + if (isTopHalf(player)) { + slab.setType(Slab.Type.BOTTOM); + } else { + slab.setType(Slab.Type.TOP); + } + + block.setBlockData(slab); + } + + /** + * Determines if the player's ray trace hit position is on the top half of a block. + * + * @param player The {@link Player} to check. + * @return {@code true} if the player's hit position is on the top half of a block, {@code false} otherwise. + */ + public boolean isTopHalf(Player player) { + RayTraceResult result = player.rayTraceBlocks(6); + if (result == null) { + return false; + } + return Math.abs(result.getHitPosition().getY() % 1) < 0.5; + } + + @EventHandler + public void managePlacePlantsSetting(PlayerInteractEvent event) { + if (!isValid(event)) { + return; + } + + Block block = event.getClickedBlock(); + if (block == null || event.getAction() != Action.RIGHT_CLICK_BLOCK) { + return; + } + + Settings settings = settingsManager.getSettings(event.getPlayer()); + if (!settings.isPlacePlants()) { + return; + } + + ItemStack itemStack = event.getItem(); + if (itemStack == null) { + return; + } + + XMaterial xMaterial = XMaterial.matchXMaterial(itemStack.getType()); + if (!XTag.FLOWERS.isTagged(xMaterial) + && !XTag.REPLACEABLE_PLANTS.isTagged(xMaterial) + && !XTag.ALIVE_CORAL_PLANTS.isTagged(xMaterial) + && !XTag.DEAD_CORAL_PLANTS.isTagged(xMaterial) + && !XTag.SAPLINGS.isTagged(xMaterial) + && !OTHER_PLANTS.contains(xMaterial) + ) { + return; + } + + event.setCancelled(true); + Block adjacent = block.getRelative(event.getBlockFace()); + + switch (xMaterial) { + case SWEET_BERRIES: + adjacent.setType(XMaterial.SWEET_BERRY_BUSH.get()); + break; + case VINE: + BlockFace toPlace = event.getBlockFace().getOppositeFace(); + if (toPlace == BlockFace.DOWN) { // Cannot place vines facing down + break; + } + adjacent.setType(xMaterial.get()); + MultipleFacing multipleFacing = (MultipleFacing) adjacent.getBlockData(); + Arrays.stream(DirectionUtil.BLOCK_SIDES).forEach(blockFace -> multipleFacing.setFace(blockFace, blockFace == toPlace)); + adjacent.setBlockData(multipleFacing); + break; + default: + adjacent.setType(xMaterial.get()); + break; + } + } + + @EventHandler + public void manageInstantPlaceSignsSetting(PlayerInteractEvent event) { + if (event.getAction() != Action.RIGHT_CLICK_BLOCK || !isValid(event)) { + return; + } + + Player player = event.getPlayer(); + Settings settings = settingsManager.getSettings(player); + if (!settings.isInstantPlaceSigns()) { + return; + } + + ItemStack itemStack = event.getItem(); + if (itemStack == null) { + return; + } + + Material material = itemStack.getType(); + XMaterial xMaterial = XMaterial.matchXMaterial(itemStack); + if (!XTag.SIGNS.isTagged(xMaterial) && !XTag.HANGING_SIGNS.isTagged(xMaterial)) { + return; + } + + Block clickedBlock = event.getClickedBlock(); + BlockFace blockFace = event.getBlockFace(); + if (clickedBlock == null) { + return; + } + + Block adjacent = clickedBlock.getRelative(blockFace); + if (adjacent.getType() != XMaterial.AIR.get()) { + return; + } + + event.setUseItemInHand(Event.Result.DENY); + event.setCancelled(true); + + boolean isHangingSign = XTag.HANGING_SIGNS.isTagged(xMaterial); + + switch (blockFace) { + case UP -> { + if (isHangingSign) { + return; + } + adjacent.setType(material); + customBlockManager.rotateBlock(adjacent, DirectionUtil.getPlayerDirection(player).getOppositeFace()); + } + case DOWN -> { + if (!isHangingSign) { + return; + } + adjacent.setType(material); + customBlockManager.rotateBlock(adjacent, getHangingSignDirection(event)); + } + case NORTH, EAST, SOUTH, WEST -> { + String woodType = xMaterial.name() + .replace("_HANGING", "") // Replace hanging if present + .replace("_SIGN", ""); // Get wood type + String block = isHangingSign ? "_WALL_HANGING_SIGN" : "_WALL_SIGN"; + BlockFace facing = isHangingSign ? getHangingSignDirection(event) : blockFace; + XMaterial.matchXMaterial(woodType + block).ifPresent(value -> adjacent.setType(value.get())); + customBlockManager.rotateBlock(adjacent, facing); + } + } + } + + private BlockFace getHangingSignDirection(PlayerInteractEvent event) { + BlockFace clickedFace = event.getBlockFace(); + BlockFace playerFacing = DirectionUtil.getCardinalDirection(event.getPlayer()).getOppositeFace(); + if (clickedFace != playerFacing && clickedFace != playerFacing.getOppositeFace()) { + return playerFacing; + } + return (clickedFace == BlockFace.NORTH || clickedFace == BlockFace.SOUTH) ? BlockFace.EAST : BlockFace.SOUTH; + } + + @EventHandler + public void manageDisabledInteractSetting(PlayerInteractEvent event) { + Player player = event.getPlayer(); + if (event.getAction() != Action.RIGHT_CLICK_BLOCK || !isValid(event)) { + return; + } + + Settings settings = settingsManager.getSettings(player); + if (!settings.isDisableInteract()) { + return; + } + + Block block = event.getClickedBlock(); + if (block == null || !MaterialUtils.isInteractable(XMaterial.matchXMaterial(block.getType()))) { + return; + } + + ItemStack itemStack = event.getItem(); + if (itemStack == null) { + return; + } + + Material material = itemStack.getType(); + XMaterial xMaterial = XMaterial.matchXMaterial(material); + if (xMaterial == Config.Settings.Builder.worldEditWand) { + return; + } + + cachePlayers.add(player.getUniqueId()); + event.setCancelled(true); + event.setUseItemInHand(Event.Result.DENY); + event.setUseInteractedBlock(Event.Result.DENY); + + if (XTag.SIGNS.isTagged(xMaterial) && event.getBlockFace() != BlockFace.UP) { + String[] splitMaterial = material.toString().split("_"); + material = Material.valueOf(splitMaterial[0] + "_WALL_SIGN"); + } + + if (!material.isBlock()) { + return; + } + + Block adjacent = block.getRelative(event.getBlockFace()); + adjacent.setType(material); + XBlock.setColor(adjacent, DyeColor.getByWoolData((byte) itemStack.getDurability())); + + customBlockManager.rotateBlock(adjacent, DirectionUtil.getBlockDirection(player, false)); + + EquipmentSlot hand = event.getHand(); + if (hand == null) { + hand = EquipmentSlot.HAND; + } + + BlockPlaceEvent blockPlaceEvent = new BlockPlaceEvent(adjacent, adjacent.getState(), block, itemStack, player, true, hand); + Bukkit.getServer().getPluginManager().callEvent(blockPlaceEvent); + } + + /** + * Not every player can always interact with the {@link BuildWorld} they are in. + *

    + * Reasons an interaction could be canceled: + *

      + *
    • The world has its {@link BuildWorldStatus} set to archive
    • + *
    • The world has a setting enabled which disallows certain events
    • + *
    • The world only allows {@link Builder}s to build and the player is not such a builder
    • + *
    + *

    + * However, a player can override these reasons if: + *

      + *
    • The player has the permission buildsystem.admin
    • + *
    • The player has the permission buildsystem.bypass.archive
    • + *
    • The player has used /build to enter build-mode
    • + *
    + * + * @param event the event which was called by the world manipulation + * @return if the interaction with the world is valid + */ + private boolean isValid(PlayerInteractEvent event) { + if (event.isCancelled()) { + return false; + } + + Player player = event.getPlayer(); + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld().getName()); + if (buildWorld == null) { + return true; + } + + if (buildWorld.getPermissions().canBypassBuildRestriction(player)) { + return true; + } + + WorldData worldData = buildWorld.getData(); + if (worldData.status().get() == BuildWorldStatus.ARCHIVE && !player.hasPermission("buildsystem.bypass.archive")) { + return false; + } + + if (!worldData.blockPlacement().get()) { + return false; + } + + Builders builders = buildWorld.getBuilders(); + if (buildWorld.getData().buildersEnabled().get() + && !builders.isBuilder(player) + && !player.hasPermission("buildsystem.bypass.builders")) { + return builders.isCreator(player); + } + + return true; + } + + /** + * Stop {@link Player} from opening {@link Inventory} because the event should be canceled as it was fired due to an interaction caused in + * {@link SettingsInteractListener#manageDisabledInteractSetting} + */ + @EventHandler + public void onInventoryOpen(InventoryOpenEvent event) { + if (cachePlayers.remove(event.getPlayer().getUniqueId())) { + event.setCancelled(true); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SignChangeListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SignChangeListener.java new file mode 100644 index 0000000..b29d0c9 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/SignChangeListener.java @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.util.color.ColorAPI; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.SignChangeEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SignChangeListener implements Listener { + + public SignChangeListener(BuildSystemPlugin plugin) { + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onSignChange(SignChangeEvent event) { + Player player = event.getPlayer(); + if (!player.hasPermission("buildsystem.color.sign")) { + return; + } + + for (int i = 0; i < event.getLines().length; i++) { + String line = event.getLine(i); + if (line == null) { + continue; + } + + String coloredLine = ChatColor.translateAlternateColorCodes('&', ColorAPI.process(line)); + event.setLine(i, coloredLine); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WeatherChangeListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WeatherChangeListener.java new file mode 100644 index 0000000..14dfe50 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WeatherChangeListener.java @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.config.Config.World; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.weather.ThunderChangeEvent; +import org.bukkit.event.weather.WeatherChangeEvent; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WeatherChangeListener implements Listener { + + public WeatherChangeListener(BuildSystemPlugin plugin) { + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onWeatherChange(WeatherChangeEvent event) { + if (!World.lockWeather) { + return; + } + + if (event.toWeatherState()) { + event.setCancelled(true); + } + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onThunderChange(ThunderChangeEvent event) { + if (!World.lockWeather) { + return; + } + + if (event.toThunderState()) { + event.setCancelled(true); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateByAxiomListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateByAxiomListener.java new file mode 100644 index 0000000..a538f7a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateByAxiomListener.java @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.moulberry.axiom.event.AxiomModifyWorldEvent; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.event.EventDispatcher; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.jspecify.annotations.NullMarked; + +/** + * Only register if axiom is available + */ +@NullMarked +public class WorldManipulateByAxiomListener implements Listener { + + private final EventDispatcher dispatcher; + + /** + * Creates a new {@link WorldManipulateByAxiomListener} instance. + * + * @param plugin plugin to register. + */ + public WorldManipulateByAxiomListener(BuildSystemPlugin plugin) { + this.dispatcher = new EventDispatcher(plugin.getWorldService().getWorldStorage()); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + plugin.getLogger().info("Axiom build-world manipulation prevention has been enabled."); + } + + @EventHandler() + public void onWorldModification(AxiomModifyWorldEvent event) { + // I don't know if it is possible. Just to be safe. + if (!event.getPlayer().getWorld().equals(event.getWorld())) { + event.setCancelled(true); + throw new IllegalStateException("Player modifies a world in which he is not present! The event got cancelled for safety reasons."); + } + dispatcher.dispatchManipulationEventIfPlayerInBuildWorld(event.getPlayer(), event); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateListener.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateListener.java new file mode 100644 index 0000000..7950778 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/listener/WorldManipulateListener.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.listener; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.data.Type; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.config.Config.Settings.Builder; +import de.eintosti.buildsystem.event.EventDispatcher; +import de.eintosti.buildsystem.event.world.BuildWorldManipulationEvent; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Player; +import org.bukkit.event.Cancellable; +import org.bukkit.event.Event; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.Action; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.entity.EntityDamageByEntityEvent; +import org.bukkit.event.player.PlayerArmorStandManipulateEvent; +import org.bukkit.event.player.PlayerInteractEvent; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WorldManipulateListener implements Listener { + + private final BuildSystemPlugin plugin; + private final WorldStorageImpl worldStorage; + private final EventDispatcher dispatcher; + + public WorldManipulateListener(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + this.dispatcher = new EventDispatcher(worldStorage); + + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onBlockBreak(BlockBreakEvent event) { + dispatcher.dispatchManipulationEventIfPlayerInBuildWorld(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onBlockPlace(BlockPlaceEvent event) { + dispatcher.dispatchManipulationEventIfPlayerInBuildWorld(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onEntityDamageByEntity(EntityDamageByEntityEvent event) { + if (!(event.getDamager() instanceof Player player) || !(event.getEntity() instanceof ArmorStand)) { + return; + } + + dispatcher.dispatchManipulationEventIfPlayerInBuildWorld(player, event); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerArmorStandManipulate(PlayerArmorStandManipulateEvent event) { + dispatcher.dispatchManipulationEventIfPlayerInBuildWorld(event.getPlayer(), event); + } + + @EventHandler(priority = EventPriority.HIGHEST) + public void onPlayerInteract(PlayerInteractEvent event) { + ItemStack itemStack = event.getItem(); + if (itemStack != null && itemStack.getType() == Builder.worldEditWand.get()) { + return; + } + + Player player = event.getPlayer(); + BuildWorld buildWorld = worldStorage.getBuildWorld(player.getWorld()); + if (buildWorld == null) { + return; + } + + dispatcher.dispatchManipulationEventIfPlayerInBuildWorld(player, event); + + if (!buildWorld.getData().physics().get() && event.getClickedBlock() != null) { + if (event.getAction() == Action.PHYSICAL && event.getClickedBlock().getType() == XMaterial.FARMLAND.get()) { + event.setCancelled(true); + } + } + } + + @EventHandler(priority = EventPriority.LOW) + public void onWorldManipulation(BuildWorldManipulationEvent event) { + if (event.isCancelled()) { + return; + } + + Player player = event.getPlayer(); + BuildWorld buildWorld = event.getBuildWorld(); + WorldData worldData = buildWorld.getData(); + + Cancellable parentEvent = event.getParentEvent(); + boolean canModify = buildWorld.getPermissions().canModify(player, getRelatedWorldSetting(parentEvent, worldData)); + if (!canModify) { + parentEvent.setCancelled(true); + denyPlayerInteraction(event); + return; + } + + worldData.lastEdited().set(System.currentTimeMillis()); + updateStatus(worldData, player); + } + + private Type getRelatedWorldSetting(Cancellable event, WorldData data) { + return switch (event) { + case BlockBreakEvent ignored -> data.blockBreaking(); + case BlockPlaceEvent ignored -> data.blockPlacement(); + case PlayerInteractEvent ignored -> data.blockInteractions(); + default -> throw new IllegalStateException("Unexpected event type: " + event.getClass().getName()); + }; + } + + private void denyPlayerInteraction(Event event) { + if (event instanceof PlayerInteractEvent interactEvent) { + interactEvent.setUseItemInHand(Event.Result.DENY); + interactEvent.setUseInteractedBlock(Event.Result.DENY); + } + } + + private void updateStatus(WorldData worldData, Player player) { + if (worldData.status().get() == BuildWorldStatus.NOT_STARTED) { + worldData.status().set(BuildWorldStatus.IN_PROGRESS); + plugin.getPlayerService().forceUpdateSidebar(player); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/BuildPlayerImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/BuildPlayerImpl.java new file mode 100644 index 0000000..59e8023 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/BuildPlayerImpl.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player; + +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.CachedValues; +import de.eintosti.buildsystem.api.player.LogoutLocation; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import java.util.UUID; +import org.bukkit.Location; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class BuildPlayerImpl implements BuildPlayer { + + private final UUID uuid; + private final Settings settings; + private final CachedValues cachedValues; + + @Nullable + private LogoutLocation logoutLocation; + @Nullable + private Location previousLocation; + @Nullable + private NavigatorCategory lastLookedAt; + + public BuildPlayerImpl(UUID uuid, Settings settings) { + this.uuid = uuid; + this.settings = settings; + this.cachedValues = new CachedValuesImpl(); + } + + @Override + public UUID getUniqueId() { + return uuid; + } + + @Override + public Settings getSettings() { + return settings; + } + + @Override + public CachedValues getCachedValues() { + return cachedValues; + } + + @Override + @Nullable + public LogoutLocation getLogoutLocation() { + return logoutLocation; + } + + @Override + public void setLogoutLocation(@Nullable LogoutLocation logoutLocation) { + this.logoutLocation = logoutLocation; + } + + @Override + @Nullable + public Location getPreviousLocation() { + return previousLocation; + } + + @Override + public void setPreviousLocation(@Nullable Location location) { + this.previousLocation = location; + } + + @Override + @Nullable + public NavigatorCategory getLastLookedAt() { + return lastLookedAt; + } + + @Override + public void setLastLookedAt(@Nullable NavigatorCategory type) { + this.lastLookedAt = type; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/CachedValuesImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/CachedValuesImpl.java new file mode 100644 index 0000000..b1ee5db --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/CachedValuesImpl.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player; + +import de.eintosti.buildsystem.api.player.CachedValues; +import java.util.List; +import org.bukkit.GameMode; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class CachedValuesImpl implements CachedValues { + + @Nullable + private GameMode gameMode; + @Nullable + private List inventory; + @Nullable + private Float walkSpeed; + @Nullable + private Float flySpeed; + + @Override + public void saveGameMode(GameMode gameMode) { + this.gameMode = gameMode; + } + + @Override + public void resetGameModeIfPresent(Player player) { + if (this.gameMode == null) { + return; + } + player.setGameMode(gameMode); + this.gameMode = null; + } + + @Override + public void saveInventory(ItemStack[] inventory) { + this.inventory = List.of(inventory); + } + + @Override + public void resetInventoryIfPresent(Player player) { + if (this.inventory == null) { + return; + } + player.getInventory().clear(); + player.getInventory().setContents(this.inventory.toArray(new ItemStack[0])); + this.inventory = null; + } + + @Override + public void saveWalkSpeed(float walkSpeed) { + this.walkSpeed = walkSpeed; + } + + @Override + public void resetWalkSpeedIfPresent(Player player) { + if (this.walkSpeed == null) { + return; + } + player.setWalkSpeed(walkSpeed); + this.walkSpeed = null; + } + + @Override + public void saveFlySpeed(float flySpeed) { + this.flySpeed = flySpeed; + } + + @Override + public void resetFlySpeedIfPresent(Player player) { + if (this.flySpeed == null) { + return; + } + player.setFlySpeed(flySpeed); + this.flySpeed = null; + } + + @Override + public void resetCachedValues(Player player) { + resetGameModeIfPresent(player); + resetInventoryIfPresent(player); + resetWalkSpeedIfPresent(player); + resetFlySpeedIfPresent(player); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/LogoutLocationImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/LogoutLocationImpl.java new file mode 100644 index 0000000..5fea765 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/LogoutLocationImpl.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player; + +import de.eintosti.buildsystem.api.player.LogoutLocation; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class LogoutLocationImpl implements LogoutLocation { + + private final String worldName; + private final double x, y, z; + private final float yaw, pitch; + + public LogoutLocationImpl(String worldName, Location location) { + this.worldName = worldName; + this.x = location.getX(); + this.y = location.getY(); + this.z = location.getZ(); + this.yaw = location.getYaw(); + this.pitch = location.getPitch(); + } + + public LogoutLocationImpl(String worldName, double x, double y, double z, float yaw, float pitch) { + this.worldName = worldName; + this.x = x; + this.y = y; + this.z = z; + this.yaw = yaw; + this.pitch = pitch; + } + + @Override + public String worldName() { + return worldName; + } + + @Nullable + @Override + public Location location() { + World world = Bukkit.getWorld(worldName); + if (world == null) { + return null; + } + return new Location(world, x, y, z, yaw, pitch); + } + + @Override + public String toString() { + return worldName + ":" + x + ":" + y + ":" + z + ":" + yaw + ":" + pitch; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/PlayerServiceImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/PlayerServiceImpl.java new file mode 100644 index 0000000..9ab81d8 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/PlayerServiceImpl.java @@ -0,0 +1,391 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XPotion; +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.CachedValues; +import de.eintosti.buildsystem.api.player.PlayerService; +import de.eintosti.buildsystem.api.storage.PlayerStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.config.Config.Settings; +import de.eintosti.buildsystem.config.Config.Settings.Navigator; +import de.eintosti.buildsystem.config.Config.World.Limits; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.storage.PlayerStorageImpl; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.storage.factory.PlayerStorageFactory; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.world.navigator.ArmorStandManager; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletionException; +import java.util.logging.Level; +import net.md_5.bungee.api.ChatMessageType; +import net.md_5.bungee.api.chat.TextComponent; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Entity; +import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.permissions.PermissionAttachmentInfo; +import org.bukkit.util.Vector; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class PlayerServiceImpl implements PlayerService { + + private final BuildSystemPlugin plugin; + private final PlayerStorageImpl playerStorage; + + private final Set openNavigator; + private final Set buildModePlayers; + + public PlayerServiceImpl(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.playerStorage = new PlayerStorageFactory(plugin).createStorage(); + + this.openNavigator = new HashSet<>(); + this.buildModePlayers = new HashSet<>(); + + initEntityChecker(); + } + + public void init() { + this.playerStorage.loadPlayers(); + } + + @Override + public PlayerStorage getPlayerStorage() { + return playerStorage; + } + + public Set getOpenNavigator() { + return openNavigator; + } + + @Override + public Set getBuildModePlayers() { + return buildModePlayers; + } + + @Override + public boolean isInBuildMode(Player player) { + return buildModePlayers.contains(player.getUniqueId()); + } + + @Override + public boolean canCreateWorld(Player player, Visibility visibility) { + boolean showPrivateWorlds = visibility == Visibility.PRIVATE; + WorldStorageImpl worldStorage = plugin.getWorldService().getWorldStorage(); + + int maxWorldAmountConfig = showPrivateWorlds + ? Limits.privateWorlds + : Limits.publicWorlds; + if (maxWorldAmountConfig >= 0 && worldStorage.getBuildWorlds().size() >= maxWorldAmountConfig) { + return false; + } + + int maxWorldAmountPlayer = getMaxWorlds(player, showPrivateWorlds ? Visibility.PRIVATE : Visibility.PUBLIC); + return maxWorldAmountPlayer < 0 || worldStorage.getBuildWorldsCreatedByPlayer(player, visibility).size() < maxWorldAmountPlayer; + } + + @Override + public int getMaxWorlds(Player player, Visibility visibility) { + int max = -1; + if (player.hasPermission(BuildSystemPlugin.ADMIN_PERMISSION)) { + return max; + } + + for (PermissionAttachmentInfo permission : player.getEffectivePermissions()) { + String permissionString = permission.getPermission(); + String[] splitPermission = permissionString.split("\\."); + + if (splitPermission.length != 4) { + continue; + } + + if (!splitPermission[1].equalsIgnoreCase("create")) { + continue; + } + + if (!splitPermission[2].equalsIgnoreCase(visibility.name())) { + continue; + } + + String amountString = splitPermission[3]; + if (amountString.equals("*")) { + return -1; + } + + try { + int amount = Integer.parseInt(amountString); + if (amount > max) { + max = amount; + } + } catch (NumberFormatException e) { + plugin.getLogger().log(Level.WARNING, "Invalid max. world amount (must be int)", e); + } + } + + return max; + } + + /** + * Forces an update of the sidebar for all players in the given {@link BuildWorld}. + * + * @param buildWorld The world for which the sidebar should be updated + */ + public void forceUpdateSidebar(BuildWorld buildWorld) { + if (!Settings.scoreboard) { + return; + } + + World bukkitWorld = Bukkit.getWorld(buildWorld.getName()); + if (bukkitWorld == null) { + return; + } + + bukkitWorld.getPlayers().forEach(this::forceUpdateSidebar); + } + + /** + * Forces an update of the sidebar for a specific player. + * + * @param player The player for whom the sidebar should be updated + */ + public void forceUpdateSidebar(Player player) { + SettingsManager settingsManager = plugin.getSettingsManager(); + if (!Settings.scoreboard || !settingsManager.getSettings(player).isScoreboard()) { + return; + } + settingsManager.updateScoreboard(player); + } + + /** + * Gives the navigator item to the player if they have the permission and do not already have it in their inventory. + * + * @param player The player to whom the navigator item should be given + */ + public void giveNavigator(Player player) { + if (!Navigator.giveItemOnJoin || !player.hasPermission("buildsystem.navigator.item") || InventoryUtils.hasNavigator(player)) { + return; + } + + ItemStack navigatorItem = InventoryUtils.createNavigatorItem(player); + PlayerInventory playerInventory = player.getInventory(); + ItemStack slot8 = playerInventory.getItem(8); + + if (slot8 == null || slot8.getType() == XMaterial.AIR.get()) { + playerInventory.setItem(8, navigatorItem); + } else { + playerInventory.addItem(navigatorItem); + } + } + + /** + * Initializes the entity checker that periodically checks if players with open navigators are looking at armor stands representing navigator categories. + */ + private void initEntityChecker() { + Bukkit.getScheduler().runTaskTimer(plugin, this::checkForAmorStandNavigator, 0L, 2L); + } + + /** + * Checks if players with open navigators are looking at armor stands that represent a {@link NavigatorCategory} and updates their UI accordingly. + *

    + * This method iterates through all players who have the navigator open and determines if they are looking at an armor stand within the valid look height range. When a player + * looks at a different navigator category or stops looking at one entirely, appropriate UI updates are sent. + */ + private void checkForAmorStandNavigator() { + for (Player player : openNavigator) { + if (!(getTargetEntity(player) instanceof ArmorStand armorStand)) { + continue; + } + + BuildPlayer buildPlayer = playerStorage.getBuildPlayer(player); + if (isLookingAtArmorStandHead(player, armorStand)) { + NavigatorCategory category = ArmorStandManager.matchNavigatorCategory(armorStand); + sendTypeInfo(player, category); + } else { + sendTypeInfo(player, null); + } + } + } + + /** + * Checks if the player is looking at the head of an armor stand within a 3-block radius. + * + * @param player The player to check + * @param armorStand The armor stand to check against + * @return {@code true} if the player is looking at the armor stand's head, {@code false} otherwise + */ + private boolean isLookingAtArmorStandHead(Player player, ArmorStand armorStand) { + Location eyeLocation = player.getEyeLocation(); + Vector direction = eyeLocation.getDirection().normalize(); + + Location standEyeLocation = armorStand.getEyeLocation().clone(); + double boxHalfSize = 0.3; + + double minX = standEyeLocation.getX() - boxHalfSize; + double maxX = standEyeLocation.getX() + boxHalfSize; + double minY = standEyeLocation.getY() - boxHalfSize; + double maxY = standEyeLocation.getY() + boxHalfSize; + double minZ = standEyeLocation.getZ() - boxHalfSize; + double maxZ = standEyeLocation.getZ() + boxHalfSize; + + for (double distance = 0; distance <= 3; distance += 0.05) { + Vector point = eyeLocation.toVector().add(direction.clone().multiply(distance)); + + if (point.getX() >= minX && point.getX() <= maxX + && point.getY() >= minY && point.getY() <= maxY + && point.getZ() >= minZ && point.getZ() <= maxZ) { + return true; + } + } + + return false; + } + + /** + * Finds the closest target entity that the player is looking at within a 3-block radius. + * + * @param entity The entity from which to find the target + * @return The closest target entity that the given entity is looking at, or {@code null} if no suitable target is found + */ + @Nullable + private Entity getTargetEntity(Entity entity) { + return getTarget(entity, entity.getNearbyEntities(3, 3, 3)); + } + + /** + * Finds the closest target entity from a given entity collection within a specified threshold. + * + * @param entity The entity from which to find the target + * @param entities The collection of entities to search through + * @param The type of entity to search for, which must extend {@link Entity} + * @return The closest target entity that the given entity is looking at, or {@code null} if no suitable target is found + */ + @Nullable + @Contract("null, _ -> null") + private T getTarget(@Nullable Entity entity, Iterable entities) { + if (entity == null) { + return null; + } + + T target = null; + final Location entityLocation = entity.getLocation(); + final double threshold = 0.5; + + for (T other : entities) { + final Location otherLocation = other.getLocation(); + final Vector vector = otherLocation.toVector().subtract(entityLocation.toVector()); + + if (entityLocation.getDirection().normalize().crossProduct(vector).lengthSquared() < threshold + && vector.normalize().dot(entityLocation.getDirection().normalize()) >= 0) { + if (target == null + || target.getLocation().distanceSquared(entityLocation) > otherLocation.distanceSquared(entityLocation)) { + target = other; + } + } + } + + return target; + } + + /** + * Sends type information to the player based on the {@link NavigatorCategory} they are looking at in the navigator. + * + * @param player The player to whom the type information should be sent + * @param category The {@link NavigatorCategory} that the player is looking at, or {@code null} if they are not looking at any category + */ + private void sendTypeInfo(Player player, @Nullable NavigatorCategory category) { + BuildPlayer buildPlayer = playerStorage.getBuildPlayer(player); + if (category == null) { + buildPlayer.setLastLookedAt(null); + displayActionBarMessage(player, "§0"); + return; + } + + NavigatorCategory lastLookedAt = buildPlayer.getLastLookedAt(); + if (lastLookedAt == null || lastLookedAt != category) { + buildPlayer.setLastLookedAt(category); + XSound.ENTITY_CHICKEN_EGG.play(player); + } + + String message = switch (category) { + case PUBLIC -> "new_navigator_world_navigator"; + case ARCHIVE -> "new_navigator_world_archive"; + case PRIVATE -> "new_navigator_private_worlds"; + }; + displayActionBarMessage(player, Messages.getString(message, player)); + } + + private void displayActionBarMessage(Player player, String message) { + player.spigot().sendMessage(ChatMessageType.ACTION_BAR, new TextComponent(message)); + } + + /** + * Closes the new navigator for the given player. + *

    + * This method removes the armor stands, resets the player's cached values, and updates the player's inventory. It also plays a sound effect and clears the action bar message. + * + * @param player The player whose navigator should be closed + */ + public void closeNewNavigator(Player player) { + if (!openNavigator.contains(player)) { + return; + } + + BuildPlayer buildPlayer = playerStorage.getBuildPlayer(player); + buildPlayer.setLastLookedAt(null); + plugin.getArmorStandManager().removeArmorStands(player); + + XSound.ENTITY_ITEM_BREAK.play(player); + displayActionBarMessage(player, ""); + InventoryUtils.replaceItem(player, Messages.getString("barrier_item", player), XMaterial.BARRIER, InventoryUtils.createNavigatorItem(player)); + + CachedValues cachedValues = buildPlayer.getCachedValues(); + cachedValues.resetWalkSpeedIfPresent(player); + cachedValues.resetFlySpeedIfPresent(player); + player.removePotionEffect(XPotion.JUMP_BOOST.get()); + player.removePotionEffect(XPotion.BLINDNESS.get()); + + openNavigator.remove(player); + } + + public void save() { + this.playerStorage + .save(this.playerStorage.getBuildPlayers()) + .exceptionally(e -> { + plugin.getLogger().log(Level.SEVERE, "Failed to save player data", e); + throw new CompletionException("Failed to save player data", e); + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlock.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlock.java new file mode 100644 index 0000000..9bc0f8a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlock.java @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.customblock; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.util.Locale; +import org.bukkit.NamespacedKey; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public enum CustomBlock { + + BARRIER("blocks_barrier", "3ed1aba73f639f4bc42bd48196c715197be2712c3b962c97ebf9e9ed8efa025"), + BROWN_MUSHROOM("blocks_brown_mushroom", "fa49eca0369d1e158e539d78149acb1572949b88ba921d9ee694fea4c726b3"), + BURNING_FURNACE("blocks_burning_furnace", "d17b8b43f8c4b5cfeb919c9f8fe93f26ceb6d2b133c2ab1eb339bd6621fd309c"), + COMMAND_BLOCK("blocks_command_block", "8514d225b262d847c7e557b474327dcef758c2c5882e41ee6d8c5e9cd3bc914"), + DOUBLE_STONE_SLAB("blocks_double_stone_slab", "151e70169ea00f04a9439221cf33770844159dd775fc8830e311fd9b5ccd2969"), + DRAGON_EGG("blocks_dragon_egg", "3c151fb54b21fe5769ffb4825b5bc92da73657f214380e5d0301e45b6c13f7d"), + END_PORTAL("blocks_end_portal", "7840b87d52271d2a755dedc82877e0ed3df67dcc42ea479ec146176b02779a5"), + FULL_ACACIA_BARCH("blocks_full_acacia_barch", "96a3bba2b7a2b4fa46945b1471777abe4599695545229e782259aed41d6"), + FULL_BIRCH_BARCH("blocks_full_birch_barch", "a221f813dacee0fef8c59f76894dbb26415478d9ddfc44c2e708a6d3b7549b"), + FULL_DARK_OAK_BARCH("blocks_full_dark_oak_barch", "cde9d4e4c343afdb3ed68038450fc6a67cd208b2efc99fb622c718d24aac"), + FULL_JUNGLE_BARCH("blocks_full_jungle_barch", "1cefc19380683015e47c666e5926b15ee57ab33192f6a7e429244cdffcc262"), + FULL_MUSHROOM_STEM("blocks_full_mushroom_stem", "f55fa642d5ebcba2c5246fe6499b1c4f6803c10f14f5299c8e59819d5dc"), + FULL_OAK_BARCH("blocks_full_oak_barch", "22e4bb979efefd2ddb3f8b1545e59cd360492e12671ec371efc1f88af21ab83"), + FULL_SPRUCE_BARCH("blocks_full_spruce_barch", "966cbdef8efb914d43a213be66b5396f75e5c1b9124f76f67d7cd32525748"), + INVISIBLE_ITEM_FRAME("blocks_invisible_item_frame", "8122a503d7a6f57802b03af7624194a4c4f5077a99ae21dd276ce7db88bc38ae"), + MOB_SPAWNER("blocks_mob_spawner", "db6bd9727abb55d5415265789d4f2984781a343c68dcaf57f554a5e9aa1cd"), + MUSHROOM_BLOCK("blocks_mushroom_block", "3fa39ccf4788d9179a8795e6b72382d49297b39217146eda68ae78384355b13"), + MUSHROOM_STEM("blocks_mushroom_stem", "84d541275c7f924bcb9eb2dbbf4b866b7649c330a6a013b53d584fd4ddf186ca"), + NETHER_PORTAL("blocks_nether_portal", "b0bfc2577f6e26c6c6f7365c2c4076bccee653124989382ce93bca4fc9e39b"), + PISTON_HEAD("blocks_piston_head", "aa868ce917c09af8e4c350a5807041f6509bf2b89aca45e591fbbd7d4b117d"), + POWERED_REDSTONE_LAMP("blocks_powered_redstone_lamp", "7eb4b34519fe15847dbea7229179feeb6ea57712d165dcc8ff6b785bb58911b0"), + RED_MUSHROOM("blocks_red_mushroom", "732dbd6612e9d3f42947b5ca8785bfb334258f3ceb83ad69a5cdeebea4cd65"), + SMOOTH_RED_SANDSTONE("blocks_smooth_red_sandstone", "a2da7aa1ae6cc9d6c36c18a460d2398162edc2207fdfc9e28a7bf84d7441b8a2"), + SMOOTH_SANDSTONE("blocks_smooth_sandstone", "38fffbb0b8fdec6f62b17c451ab214fb86e4e355b116be961a9ae93eb49a43"), + SMOOTH_STONE("blocks_smooth_stone", "8dd0cd158c2bb6618650e3954b2d29237f5b4c0ddc7d258e17380ab6979f071"), + DEBUG_STICK("blocks_debug_stick", "badc048a7ce78f7dad72a07da27d85c0916881e5522eeed1e3daf217a38c1a"); + + public static final NamespacedKey CUSTOM_BLOCK_KEY = new NamespacedKey(BuildSystemPlugin.get(), "custom-block"); + + private final String messageKey; + private final String skullUrl; + + CustomBlock(String messageKey, String skullUrl) { + this.messageKey = messageKey; + this.skullUrl = skullUrl; + } + + @Nullable + @Contract("null -> null") + public static CustomBlock of(@Nullable ItemStack itemStack) { + if (itemStack == null) { + return null; + } + + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta == null) { + return null; + } + + String identifier = itemMeta.getPersistentDataContainer().get(CUSTOM_BLOCK_KEY, PersistentDataType.STRING); + if (identifier == null) { + return null; + } + + String customBlock = identifier.toUpperCase(Locale.ROOT); + try { + return CustomBlock.valueOf(customBlock); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * Gets the message key for this custom block. + * + * @return The message key associated with this custom block + */ + public String getMessageKey() { + return messageKey; + } + + /** + * Gets the URL for the skull texture of this custom block. + * + * @return The URL of the skull texture for this custom block + */ + public String getSkullUrl() { + return skullUrl; + } + + /** + * Stores the custom block identifier in the provided item's {@link PersistentDataContainer}. + * + * @param itemStack The item to store the custom block identifier in + * @return The modified item stack with the custom block identifier stored + */ + public ItemStack storeCustomBlock(ItemStack itemStack) { + ItemMeta itemMeta = itemStack.getItemMeta(); + itemMeta.getPersistentDataContainer().set(CUSTOM_BLOCK_KEY, PersistentDataType.STRING, this.name()); + itemStack.setItemMeta(itemMeta); + return itemStack; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockInventory.java new file mode 100644 index 0000000..37f58be --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockInventory.java @@ -0,0 +1,166 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.customblock; + +import com.cryptomorin.xseries.XEnchantment; +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class CustomBlockInventory implements InventoryHandler { + + private final InventoryManager inventoryManager; + + public CustomBlockInventory(BuildSystemPlugin plugin) { + this.inventoryManager = plugin.getInventoryManager(); + } + + public void openInventory(Player player) { + Inventory inventory = getInventory(player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(Player player) { + Inventory inventory = new BlocksInventoryHolder(player).getInventory(); + fillGuiWithGlass(player, inventory); + + setCustomBlock(inventory, player, 1, CustomBlock.FULL_OAK_BARCH); + setCustomBlock(inventory, player, 2, CustomBlock.FULL_SPRUCE_BARCH); + setCustomBlock(inventory, player, 3, CustomBlock.FULL_BIRCH_BARCH); + setCustomBlock(inventory, player, 4, CustomBlock.FULL_JUNGLE_BARCH); + setCustomBlock(inventory, player, 5, CustomBlock.FULL_ACACIA_BARCH); + setCustomBlock(inventory, player, 6, CustomBlock.FULL_DARK_OAK_BARCH); + + setCustomBlock(inventory, player, 10, CustomBlock.RED_MUSHROOM); + setCustomBlock(inventory, player, 11, CustomBlock.BROWN_MUSHROOM); + setCustomBlock(inventory, player, 12, CustomBlock.FULL_MUSHROOM_STEM); + setCustomBlock(inventory, player, 13, CustomBlock.MUSHROOM_STEM); + setCustomBlock(inventory, player, 14, CustomBlock.MUSHROOM_BLOCK); + + setCustomBlock(inventory, player, 19, CustomBlock.SMOOTH_STONE); + setCustomBlock(inventory, player, 20, CustomBlock.DOUBLE_STONE_SLAB); + setCustomBlock(inventory, player, 21, CustomBlock.SMOOTH_SANDSTONE); + setCustomBlock(inventory, player, 22, CustomBlock.SMOOTH_RED_SANDSTONE); + + setCustomBlock(inventory, player, 28, CustomBlock.POWERED_REDSTONE_LAMP); + setCustomBlock(inventory, player, 29, CustomBlock.BURNING_FURNACE); + setCustomBlock(inventory, player, 30, CustomBlock.PISTON_HEAD); + setCustomBlock(inventory, player, 31, CustomBlock.COMMAND_BLOCK); + setCustomBlock(inventory, player, 32, CustomBlock.BARRIER); + setCustomBlock(inventory, player, 33, CustomBlock.INVISIBLE_ITEM_FRAME); + + setCustomBlock(inventory, player, 37, CustomBlock.MOB_SPAWNER); + setCustomBlock(inventory, player, 38, CustomBlock.NETHER_PORTAL); + setCustomBlock(inventory, player, 39, CustomBlock.END_PORTAL); + setCustomBlock(inventory, player, 40, CustomBlock.DRAGON_EGG); + setCustomBlock(inventory, player, 41, CustomBlock.DEBUG_STICK); + + return inventory; + } + + private void setCustomBlock(Inventory inventory, Player player, int position, CustomBlock customBlock) { + inventory.setItem(position, InventoryUtils.createSkull(Messages.getString(customBlock.getMessageKey(), player), Profileable.detect(customBlock.getSkullUrl()))); + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + int[] glassSlots = {0, 8, 9, 17, 18, 26, 27, 35, 36, 44}; + for (int i : glassSlots) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof BlocksInventoryHolder)) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + + switch (event.getSlot()) { + case 1 -> giveCustomBlock(player, CustomBlock.FULL_OAK_BARCH); + case 2 -> giveCustomBlock(player, CustomBlock.FULL_SPRUCE_BARCH); + case 3 -> giveCustomBlock(player, CustomBlock.FULL_BIRCH_BARCH); + case 4 -> giveCustomBlock(player, CustomBlock.FULL_JUNGLE_BARCH); + case 5 -> giveCustomBlock(player, CustomBlock.FULL_ACACIA_BARCH); + case 6 -> giveCustomBlock(player, CustomBlock.FULL_DARK_OAK_BARCH); + + case 10 -> giveCustomBlock(player, CustomBlock.RED_MUSHROOM); + case 11 -> giveCustomBlock(player, CustomBlock.BROWN_MUSHROOM); + case 12 -> giveCustomBlock(player, CustomBlock.FULL_MUSHROOM_STEM); + case 13 -> giveCustomBlock(player, CustomBlock.MUSHROOM_STEM); + case 14 -> giveCustomBlock(player, CustomBlock.MUSHROOM_BLOCK); + + case 19 -> giveCustomBlock(player, CustomBlock.SMOOTH_STONE); + case 20 -> giveCustomBlock(player, CustomBlock.DOUBLE_STONE_SLAB); + case 21 -> giveCustomBlock(player, CustomBlock.SMOOTH_SANDSTONE); + case 22 -> giveCustomBlock(player, CustomBlock.SMOOTH_RED_SANDSTONE); + + case 28 -> giveCustomBlock(player, CustomBlock.POWERED_REDSTONE_LAMP); + case 29 -> giveCustomBlock(player, CustomBlock.BURNING_FURNACE); + case 30 -> giveCustomBlock(player, CustomBlock.PISTON_HEAD); + case 31 -> giveCustomBlock(player, CustomBlock.COMMAND_BLOCK); + case 32 -> giveCustomBlock(player, CustomBlock.BARRIER, XMaterial.BARRIER); + case 33 -> giveCustomBlock(player, CustomBlock.INVISIBLE_ITEM_FRAME, XMaterial.ITEM_FRAME); + + case 37 -> giveCustomBlock(player, CustomBlock.MOB_SPAWNER); + case 38 -> giveCustomBlock(player, CustomBlock.NETHER_PORTAL); + case 39 -> giveCustomBlock(player, CustomBlock.END_PORTAL); + case 40 -> giveCustomBlock(player, CustomBlock.DRAGON_EGG); + case 41 -> giveCustomBlock(player, CustomBlock.DEBUG_STICK, XMaterial.DEBUG_STICK); + } + } + + private void giveCustomBlock(Player player, CustomBlock customBlock) { + giveCustomBlock(player, customBlock, XMaterial.PLAYER_HEAD); + } + + private void giveCustomBlock(Player player, CustomBlock customBlock, XMaterial material) { + ItemStack itemStack; + if (material == XMaterial.PLAYER_HEAD) { + itemStack = InventoryUtils.createSkull(Messages.getString(customBlock.getMessageKey(), player), Profileable.detect(customBlock.getSkullUrl())); + } else { + itemStack = InventoryUtils.createItem(material, Messages.getString(customBlock.getMessageKey(), player)); + ItemMeta itemMeta = itemStack.getItemMeta(); + itemMeta.addEnchant(XEnchantment.UNBREAKING.get(), 1, true); + itemStack.setItemMeta(itemMeta); + } + player.getInventory().addItem(customBlock.storeCustomBlock(itemStack)); + } + + private static class BlocksInventoryHolder extends BuildSystemHolder { + + public BlocksInventoryHolder(Player player) { + super(45, Messages.getString("blocks_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockManager.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockManager.java new file mode 100644 index 0000000..a2f09e6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/customblock/CustomBlockManager.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.customblock; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.util.DirectionUtil; +import org.bukkit.Axis; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.block.Furnace; +import org.bukkit.block.data.Directional; +import org.bukkit.block.data.Lightable; +import org.bukkit.block.data.MultipleFacing; +import org.bukkit.block.data.Orientable; +import org.bukkit.block.data.type.HangingSign; +import org.bukkit.block.data.type.Sign; +import org.bukkit.block.data.type.Slab; +import org.bukkit.entity.ItemFrame; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.hanging.HangingPlaceEvent; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class CustomBlockManager implements Listener { + + private final BuildSystemPlugin plugin; + + public CustomBlockManager(BuildSystemPlugin plugin) { + this.plugin = plugin; + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + @EventHandler + public void onCustomBlockPlace(BlockPlaceEvent event) { + if (event.isCancelled()) { + return; + } + + Player player = event.getPlayer(); + BuildWorld buildWorld = plugin.getWorldService().getWorldStorage().getBuildWorld(player.getWorld()); + boolean isBuildWorld = buildWorld != null; + + ItemStack itemStack = event.getItemInHand(); + XMaterial xMaterial = XMaterial.matchXMaterial(itemStack); + if (xMaterial != XMaterial.PLAYER_HEAD) { + return; + } + + CustomBlock customBlock = CustomBlock.of(itemStack); + if (customBlock == null) { + return; + } + + boolean hadToDisablePhysics = false; + if (isBuildWorld && !buildWorld.getData().physics().get()) { + hadToDisablePhysics = true; + buildWorld.getData().physics().set(true); + } + + setBlock(event, customBlock); + + if (isBuildWorld && hadToDisablePhysics) { + buildWorld.getData().physics().set(false); + } + } + + /** + * Sets a custom block based on the provided {@link CustomBlock}. + * + * @param event The {@link BlockPlaceEvent} triggered by the player + * @param customBlock The custom block to be placed + */ + public void setBlock(BlockPlaceEvent event, CustomBlock customBlock) { + Player player = event.getPlayer(); + Block block = event.getBlockPlaced(); + + Bukkit.getScheduler().runTask(plugin, () -> { + switch (customBlock) { + case FULL_OAK_BARCH: + block.setType(Material.OAK_WOOD); + break; + case FULL_SPRUCE_BARCH: + block.setType(Material.SPRUCE_WOOD); + break; + case FULL_BIRCH_BARCH: + block.setType(Material.BIRCH_WOOD); + break; + case FULL_JUNGLE_BARCH: + block.setType(Material.JUNGLE_WOOD); + break; + case FULL_ACACIA_BARCH: + block.setType(Material.ACACIA_WOOD); + break; + case FULL_DARK_OAK_BARCH: + block.setType(Material.DARK_OAK_WOOD); + break; + case RED_MUSHROOM: + block.setType(Material.RED_MUSHROOM_BLOCK); + break; + case BROWN_MUSHROOM: + block.setType(Material.BROWN_MUSHROOM_BLOCK); + break; + case FULL_MUSHROOM_STEM: + block.setType(Material.MUSHROOM_STEM); + break; + case MUSHROOM_STEM: + block.setType(Material.MUSHROOM_STEM); + MultipleFacing mushroomStem = (MultipleFacing) block.getBlockData(); + mushroomStem.setFace(BlockFace.UP, false); + mushroomStem.setFace(BlockFace.DOWN, false); + block.setBlockData(mushroomStem); + break; + case MUSHROOM_BLOCK: + block.setType(Material.MUSHROOM_STEM); + MultipleFacing mushroomBlock = (MultipleFacing) block.getBlockData(); + for (BlockFace blockFace : DirectionUtil.BLOCK_SIDES) { + mushroomBlock.setFace(blockFace, false); + } + block.setBlockData(mushroomBlock); + break; + case SMOOTH_STONE: + block.setType(Material.SMOOTH_STONE); + break; + case DOUBLE_STONE_SLAB: + block.setType(Material.SMOOTH_STONE_SLAB); + Slab slab = (Slab) block.getBlockData(); + slab.setType(Slab.Type.DOUBLE); + block.setBlockData(slab); + break; + case SMOOTH_SANDSTONE: + block.setType(Material.SMOOTH_SANDSTONE); + break; + case SMOOTH_RED_SANDSTONE: + block.setType(Material.SMOOTH_RED_SANDSTONE); + break; + case POWERED_REDSTONE_LAMP: + block.setType(Material.REDSTONE_LAMP); + Lightable lightable = (Lightable) block.getBlockData(); + lightable.setLit(true); + block.setBlockData(lightable); + break; + case BURNING_FURNACE: + block.setType(Material.FURNACE); + Furnace furnace = (Furnace) block.getState(); + furnace.setBurnTime(Short.MAX_VALUE); + furnace.update(); + rotateBlock(block, DirectionUtil.getBlockDirection(player, false)); + break; + case PISTON_HEAD: + block.setType(Material.PISTON_HEAD); + rotateBlock(block, DirectionUtil.getBlockDirection(player, true)); + break; + case COMMAND_BLOCK: + block.setType(Material.COMMAND_BLOCK); + rotateBlock(block, DirectionUtil.getBlockDirection(player, true)); + break; + case BARRIER: + block.setType(Material.BARRIER); + break; + case INVISIBLE_ITEM_FRAME: + // Handled below + return; + case MOB_SPAWNER: + block.setType(Material.SPAWNER); + break; + case NETHER_PORTAL: + block.setType(Material.NETHER_PORTAL); + rotateBlock(block, DirectionUtil.getBlockDirection(player, false)); + break; + case END_PORTAL: + block.setType(Material.END_PORTAL); + break; + case DRAGON_EGG: + block.setType(Material.DRAGON_EGG); + break; + default: + return; + } + + event.setCancelled(true); + }); + } + + /** + * Handles the placement of an invisible item frame, making it invisible. + * + * @param event The {@link HangingPlaceEvent} triggered when an item frame is placed + */ + @EventHandler + public void onInvisibleItemFramePlacement(HangingPlaceEvent event) { + if (!(event.getEntity() instanceof ItemFrame itemFrame)) { + return; + } + + if (CustomBlock.of(event.getItemStack()) != CustomBlock.INVISIBLE_ITEM_FRAME) { + return; + } + + itemFrame.setVisible(false); + } + + /** + * Rotates a block based on the provided {@link BlockFace} direction. + * + * @param block The block to rotate + * @param direction The {@link BlockFace} representing the new direction or axis + */ + public void rotateBlock(Block block, BlockFace direction) { + switch (block.getBlockData()) { + case Directional directional -> { + directional.setFacing(direction); + block.setBlockData(directional); + } + case Orientable orientable -> { + Axis axis = switch (direction) { + case UP, DOWN -> Axis.Y; + case EAST, WEST -> Axis.X; + default -> Axis.Z; + }; + orientable.setAxis(axis); + block.setBlockData(orientable); + } + case Sign sign -> { + sign.setRotation(direction); + block.setBlockData(sign); + } + case HangingSign hangingSign -> { + hangingSign.setRotation(direction); + block.setBlockData(hangingSign); + } + default -> { + } + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/DesignInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/DesignInventory.java new file mode 100644 index 0000000..7ca7d3e --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/DesignInventory.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.settings; + +import com.cryptomorin.xseries.XEnchantment; +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.settings.DesignColor; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class DesignInventory implements InventoryHandler { + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + + public DesignInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + } + + public void openInventory(Player player) { + Inventory inventory = getInventory(player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(Player player) { + Inventory inventory = new DesignInventoryHolder(player).getInventory(); + fillGuiWithGlass(inventory, player); + + setItem(player, inventory, 10, XMaterial.RED_STAINED_GLASS, "design_red", DesignColor.RED); + setItem(player, inventory, 11, XMaterial.ORANGE_STAINED_GLASS, "design_orange", DesignColor.ORANGE); + setItem(player, inventory, 12, XMaterial.YELLOW_STAINED_GLASS, "design_yellow", DesignColor.YELLOW); + setItem(player, inventory, 13, XMaterial.PINK_STAINED_GLASS, "design_pink", DesignColor.PINK); + setItem(player, inventory, 14, XMaterial.MAGENTA_STAINED_GLASS, "design_magenta", DesignColor.MAGENTA); + setItem(player, inventory, 15, XMaterial.PURPLE_STAINED_GLASS, "design_purple", DesignColor.PURPLE); + setItem(player, inventory, 16, XMaterial.BROWN_STAINED_GLASS, "design_brown", DesignColor.BROWN); + + setItem(player, inventory, 18, XMaterial.LIME_STAINED_GLASS, "design_lime", DesignColor.LIME); + setItem(player, inventory, 19, XMaterial.GREEN_STAINED_GLASS, "design_green", DesignColor.GREEN); + setItem(player, inventory, 20, XMaterial.BLUE_STAINED_GLASS, "design_blue", DesignColor.BLUE); + setItem(player, inventory, 21, XMaterial.CYAN_STAINED_GLASS, "design_aqua", DesignColor.CYAN); + setItem(player, inventory, 22, XMaterial.LIGHT_BLUE_STAINED_GLASS, "design_light_blue", DesignColor.LIGHT_BLUE); + setItem(player, inventory, 23, XMaterial.WHITE_STAINED_GLASS, "design_white", DesignColor.WHITE); + setItem(player, inventory, 24, XMaterial.LIGHT_GRAY_STAINED_GLASS, "design_grey", DesignColor.LIGHT_GRAY); + setItem(player, inventory, 25, XMaterial.GRAY_STAINED_GLASS, "design_dark_grey", DesignColor.GRAY); + setItem(player, inventory, 26, XMaterial.BLACK_STAINED_GLASS, "design_black", DesignColor.BLACK); + + return inventory; + } + + private void fillGuiWithGlass(Inventory inventory, Player player) { + for (int i = 0; i <= 8; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + for (int i = 27; i <= 35; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + private void setItem(Player player, Inventory inventory, int position, XMaterial material, String key, DesignColor color) { + SettingsManager settingsManager = plugin.getSettingsManager(); + Settings settings = settingsManager.getSettings(player); + + String displayName = Messages.getString(key, player); + ItemStack itemStack = InventoryUtils.createItem(material, + settings.getDesignColor() == color + ? "§a" + displayName + : "§7" + displayName + ); + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta != null) { + itemMeta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + } + + itemStack.setItemMeta(itemMeta); + if (settings.getDesignColor() == color) { + itemStack.addUnsafeEnchantment(XEnchantment.UNBREAKING.get(), 1); + } + + inventory.setItem(position, itemStack); + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof DesignInventoryHolder)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + + if (itemStack.getType().toString().contains("STAINED_GLASS_PANE")) { + new SettingsInventory(plugin).openInventory(player); + return; + } + + Settings settings = plugin.getSettingsManager().getSettings(player); + + switch (event.getSlot()) { + case 10: + settings.setDesignColor(DesignColor.RED); + break; + case 11: + settings.setDesignColor(DesignColor.ORANGE); + break; + case 12: + settings.setDesignColor(DesignColor.YELLOW); + break; + case 13: + settings.setDesignColor(DesignColor.PINK); + break; + case 14: + settings.setDesignColor(DesignColor.MAGENTA); + break; + case 15: + settings.setDesignColor(DesignColor.PURPLE); + break; + case 16: + settings.setDesignColor(DesignColor.BROWN); + break; + case 18: + settings.setDesignColor(DesignColor.LIME); + break; + case 19: + settings.setDesignColor(DesignColor.GREEN); + break; + case 20: + settings.setDesignColor(DesignColor.BLUE); + break; + case 21: + settings.setDesignColor(DesignColor.CYAN); + break; + case 22: + settings.setDesignColor(DesignColor.LIGHT_BLUE); + break; + case 23: + settings.setDesignColor(DesignColor.WHITE); + break; + case 24: + settings.setDesignColor(DesignColor.LIGHT_GRAY); + break; + case 25: + settings.setDesignColor(DesignColor.GRAY); + break; + case 26: + settings.setDesignColor(DesignColor.BLACK); + break; + } + + openInventory(player); + } + + private static class DesignInventoryHolder extends BuildSystemHolder { + + public DesignInventoryHolder(Player player) { + super(36, Messages.getString("design_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/NoClipManager.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/NoClipManager.java new file mode 100644 index 0000000..5af1d0b --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/NoClipManager.java @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.settings; + +import com.google.common.collect.Sets; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.settings.Settings; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.GameMode; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.block.Block; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class NoClipManager { + + private final BuildSystemPlugin plugin; + private final Map previousGameMode; + private final Set noClipPlayers; + + public NoClipManager(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.noClipPlayers = new HashSet<>(); + this.previousGameMode = new HashMap<>(); + runBlockCheckTask(); + } + + private void runBlockCheckTask() { + Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, this::checkForBlocks, 0L, 4L); + } + + private void checkForBlocks() { + List toRemove = new ArrayList<>(); + + for (UUID uuid : noClipPlayers) { + Player player = Bukkit.getPlayer(uuid); + if (player == null) { + toRemove.add(uuid); + continue; + } + + GameMode playerGameMode = player.getGameMode(); + if (playerGameMode != GameMode.SPECTATOR) { + previousGameMode.put(uuid, playerGameMode); + } + + GameMode gameMode; + if (checkNoClip(player)) { + gameMode = GameMode.SPECTATOR; + } else { + gameMode = previousGameMode.getOrDefault(uuid, GameMode.CREATIVE); + previousGameMode.remove(uuid); + } + + if (player.getGameMode() == gameMode) { + continue; + } + + GameMode finalGameMode = gameMode; + Bukkit.getScheduler().runTask(plugin, () -> { + player.setGameMode(finalGameMode); + if (gameMode == GameMode.SURVIVAL || gameMode == GameMode.ADVENTURE) { + if (player.getAllowFlight()) { + player.setFlying(true); + } + } + }); + } + + toRemove.forEach(this::stopNoClip); + } + + private boolean checkNoClip(Player player) { + Location playerLocation = player.getLocation(); + + Set locations = Sets.newHashSet( + playerLocation.clone().add(0.4, 0, 0), + playerLocation.clone().add(-0.4, 0, 0), + playerLocation.clone().add(0.4, 1, 0), + playerLocation.clone().add(-0.4, 1, 0), + playerLocation.clone().add(0, 0, 0.4), + playerLocation.clone().add(0, 0, -0.4), + playerLocation.clone().add(0, 1, 0.4), + playerLocation.clone().add(0, 1, -0.4), + playerLocation.clone().add(0, 1.9, 0) + ); + + return locations.stream().anyMatch(location -> isSolidBlock(location.getBlock())) + || (player.isSneaking() && isSolidBlock(playerLocation.clone().add(0, -0.1, 0).getBlock())); + } + + private boolean isSolidBlock(Block block) { + return block.getType() != Material.AIR; + } + + public boolean isNoClip(UUID uuid) { + return noClipPlayers.contains(uuid); + } + + /** + * Only add a player to the list of No-Clip players if {@link Settings#isNoClip} is equal to {@code true}. + * + * @param player The player to add + * @param settings The player's settings + */ + public void startNoClip(Player player, Settings settings) { + if (!settings.isNoClip()) { + noClipPlayers.remove(player.getUniqueId()); + return; + } + + startNoClip(player); + } + + /** + * Forcefully add a player to the list of NoClip players. + * + * @param player The player to add + */ + public void startNoClip(Player player) { + noClipPlayers.add(player.getUniqueId()); + } + + /** + * Only remove a player from the list of No-Clip players if said player has No-Clip enabled, i.e. {@link NoClipManager#isNoClip} is equal to {@code true}. + *

    + * Will also set the player to their previous {@link GameMode}. + * + * @param uuid The uuid of the player to remove + */ + public void stopNoClip(UUID uuid) { + if (!isNoClip(uuid)) { + return; + } + + this.noClipPlayers.remove(uuid); + + Player player = Bukkit.getPlayer(uuid); + if (player == null) { + return; + } + + player.setGameMode(previousGameMode.getOrDefault(uuid, GameMode.CREATIVE)); + previousGameMode.remove(uuid); + + if (player.getAllowFlight()) { + player.setFlying(true); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsImpl.java new file mode 100644 index 0000000..62e0ee6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsImpl.java @@ -0,0 +1,258 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.settings; + +import de.eintosti.buildsystem.api.player.settings.DesignColor; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.navigator.settings.NavigatorType; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldDisplay; +import de.eintosti.buildsystem.world.navigator.settings.WorldDisplayImpl; +import org.bukkit.scheduler.BukkitTask; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SettingsImpl implements Settings { + + private DesignColor designColor; + private NavigatorType navigatorType; + private WorldDisplay worldDisplay; + private boolean clearInventory; + private boolean disableInteract; + private boolean hidePlayers; + private boolean instantPlaceSigns; + private boolean keepNavigator; + private boolean nightVision; + private boolean noClip; + private boolean placePlants; + private boolean scoreboard; + private boolean slabBreaking; + private boolean spawnTeleport; + private boolean openTrapDoors; + + @Nullable + private BukkitTask scoreboardTask; + + public SettingsImpl() { + this.navigatorType = NavigatorType.OLD; + this.designColor = DesignColor.BLACK; + this.worldDisplay = new WorldDisplayImpl(); + this.clearInventory = false; + this.disableInteract = false; + this.hidePlayers = false; + this.instantPlaceSigns = false; + this.keepNavigator = false; + this.nightVision = false; + this.noClip = false; + this.placePlants = false; + this.scoreboard = true; + this.slabBreaking = false; + this.spawnTeleport = true; + this.openTrapDoors = false; + } + + public SettingsImpl( + @Nullable NavigatorType navigatorType, + @Nullable DesignColor designColor, + WorldDisplay worldDisplay, + boolean clearInventory, + boolean disableInteract, + boolean hidePlayers, + boolean instantPlaceSigns, + boolean keepNavigator, + boolean nightVision, + boolean noClip, + boolean placePlants, + boolean scoreboard, + boolean slabBreaking, + boolean spawnTeleport, + boolean openTrapDoors + ) { + this.navigatorType = navigatorType == null ? NavigatorType.OLD : navigatorType; + this.designColor = designColor == null ? DesignColor.BLACK : designColor; + this.worldDisplay = worldDisplay; + this.clearInventory = clearInventory; + this.disableInteract = disableInteract; + this.hidePlayers = hidePlayers; + this.instantPlaceSigns = instantPlaceSigns; + this.keepNavigator = keepNavigator; + this.nightVision = nightVision; + this.noClip = noClip; + this.placePlants = placePlants; + this.scoreboard = scoreboard; + this.slabBreaking = slabBreaking; + this.spawnTeleport = spawnTeleport; + this.openTrapDoors = openTrapDoors; + } + + @Override + public NavigatorType getNavigatorType() { + return navigatorType; + } + + @Override + public void setNavigatorType(NavigatorType navigatorType) { + this.navigatorType = navigatorType; + } + + @Override + public DesignColor getDesignColor() { + return designColor; + } + + @Override + public void setDesignColor(DesignColor designColor) { + this.designColor = designColor; + } + + @Override + public WorldDisplay getWorldDisplay() { + return worldDisplay; + } + + @Override + public boolean isClearInventory() { + return clearInventory; + } + + @Override + public void setClearInventory(boolean clearInventory) { + this.clearInventory = clearInventory; + } + + @Override + public boolean isDisableInteract() { + return disableInteract; + } + + @Override + public void setDisableInteract(boolean disableInteract) { + this.disableInteract = disableInteract; + } + + @Override + public boolean isHidePlayers() { + return hidePlayers; + } + + @Override + public void setHidePlayers(boolean hidePlayers) { + this.hidePlayers = hidePlayers; + } + + @Override + public boolean isInstantPlaceSigns() { + return instantPlaceSigns; + } + + @Override + public void setInstantPlaceSigns(boolean instantPlaceSigns) { + this.instantPlaceSigns = instantPlaceSigns; + } + + @Override + public boolean isKeepNavigator() { + return keepNavigator; + } + + @Override + public void setKeepNavigator(boolean keepNavigator) { + this.keepNavigator = keepNavigator; + } + + @Override + public boolean isNightVision() { + return nightVision; + } + + @Override + public void setNightVision(boolean nightVision) { + this.nightVision = nightVision; + } + + @Override + public boolean isNoClip() { + return noClip; + } + + @Override + public void setNoClip(boolean noClip) { + this.noClip = noClip; + } + + @Override + public boolean isPlacePlants() { + return placePlants; + } + + @Override + public void setPlacePlants(boolean placePlants) { + this.placePlants = placePlants; + } + + @Override + public boolean isScoreboard() { + return scoreboard; + } + + @Override + public void setScoreboard(boolean scoreboard) { + this.scoreboard = scoreboard; + } + + @Override + public boolean isSlabBreaking() { + return slabBreaking; + } + + @Override + public void setSlabBreaking(boolean slabBreaking) { + this.slabBreaking = slabBreaking; + } + + @Override + public boolean isSpawnTeleport() { + return spawnTeleport; + } + + @Override + public void setSpawnTeleport(boolean spawnTeleport) { + this.spawnTeleport = spawnTeleport; + } + + @Override + public boolean isOpenTrapDoors() { + return openTrapDoors; + } + + @Override + public void setOpenTrapDoors(boolean trapDoor) { + this.openTrapDoors = trapDoor; + } + + @Nullable + @Override + public BukkitTask getScoreboardTask() { + return scoreboardTask; + } + + @Override + public void setScoreboardTask(@Nullable BukkitTask scoreboardTask) { + this.scoreboardTask = scoreboardTask; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsInventory.java new file mode 100644 index 0000000..d183b25 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsInventory.java @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.settings; + +import com.cryptomorin.xseries.XEnchantment; +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XPotion; +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.settings.DesignColor; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.navigator.settings.NavigatorType; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.config.Config.Settings.Navigator; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.potion.PotionEffect; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SettingsInventory implements InventoryHandler { + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + private final SettingsManager settingsManager; + + public SettingsInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + this.settingsManager = plugin.getSettingsManager(); + } + + public void openInventory(Player player) { + Inventory inventory = getInventory(player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(Player player) { + Inventory inventory = new SettingsInventoryHolder(player).getInventory(); + fillGuiWithGlass(player, inventory); + + Settings settings = settingsManager.getSettings(player); + addDesignItem(inventory, player); + addClearInventoryItem(inventory, player); + addSettingsItem(player, inventory, 13, XMaterial.DIAMOND_AXE, settings.isDisableInteract(), "settings_disableinteract_item", "settings_disableinteract_lore"); + addSettingsItem(player, inventory, 14, XMaterial.ENDER_EYE, settings.isHidePlayers(), "settings_hideplayers_item", "settings_hideplayers_lore"); + addSettingsItem(player, inventory, 15, XMaterial.OAK_SIGN, settings.isInstantPlaceSigns(), "settings_instantplacesigns_item", "settings_instantplacesigns_lore"); + addSettingsItem(player, inventory, 20, XMaterial.SLIME_BLOCK, settings.isKeepNavigator(), "settings_keep_navigator_item", "settings_keep_navigator_lore"); + addSettingsItem(player, inventory, 21, Navigator.item, settings.getNavigatorType() == NavigatorType.NEW, "settings_new_navigator_item", "settings_new_navigator_lore"); + addSettingsItem(player, inventory, 22, XMaterial.GOLDEN_CARROT, settings.isNightVision(), "settings_nightvision_item", "settings_nightvision_lore"); + addSettingsItem(player, inventory, 23, XMaterial.BRICKS, settings.isNoClip(), "settings_no_clip_item", "settings_no_clip_lore"); + addSettingsItem(player, inventory, 24, XMaterial.IRON_TRAPDOOR, settings.isOpenTrapDoors(), "settings_open_trapdoors_item", "settings_open_trapdoors_lore"); + addSettingsItem(player, inventory, 29, XMaterial.FERN, settings.isPlacePlants(), "settings_placeplants_item", "settings_placeplants_lore"); + addSettingsItem(player, inventory, 30, XMaterial.PAPER, settings.isScoreboard(), + Config.Settings.scoreboard ? "settings_scoreboard_item" : "settings_scoreboard_disabled_item", + Config.Settings.scoreboard ? "settings_scoreboard_lore" : "settings_scoreboard_disabled_lore"); + addSettingsItem(player, inventory, 31, XMaterial.SMOOTH_STONE_SLAB, settings.isSlabBreaking(), "settings_slab_breaking_item", "settings_slab_breaking_lore"); + addSettingsItem(player, inventory, 32, XMaterial.MAGMA_CREAM, settings.isSpawnTeleport(), "settings_spawnteleport_item", "settings_spawnteleport_lore"); + + return inventory; + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + for (int i = 0; i <= 44; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + private void addSettingsItem(Player player, Inventory inventory, int position, XMaterial material, boolean enabled, String displayNameKey, String loreKey) { + ItemStack itemStack = material.parseItem(); + ItemMeta itemMeta = itemStack.getItemMeta(); + + itemMeta.setDisplayName(Messages.getString(displayNameKey, player)); + itemMeta.setLore(Messages.getStringList(loreKey, player)); + itemMeta.addItemFlags(ItemFlag.values()); + itemStack.setItemMeta(itemMeta); + + if (enabled) { + itemStack.addUnsafeEnchantment(XEnchantment.UNBREAKING.get(), 1); + } + + inventory.setItem(position, itemStack); + } + + private void addClearInventoryItem(Inventory inventory, Player player) { + Settings settings = settingsManager.getSettings(player); + XMaterial xMaterial = settings.isClearInventory() ? XMaterial.MINECART : XMaterial.CHEST_MINECART; + addSettingsItem(player, inventory, 12, xMaterial, settings.isClearInventory(), + "settings_clear_inventory_item", "settings_clear_inventory_lore" + ); + } + + private void addDesignItem(Inventory inventory, Player player) { + DesignColor color = plugin.getSettingsManager().getSettings(player).getDesignColor(); + XMaterial material = XMaterial.matchXMaterial(color.name() + "_STAINED_GLASS").orElse(XMaterial.BLACK_STAINED_GLASS); + ItemStack itemStack = InventoryUtils.createItem(material, Messages.getString("settings_change_design_item", player)); + ItemMeta itemMeta = itemStack.getItemMeta(); + + itemMeta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + itemMeta.setLore(Messages.getStringList("settings_change_design_lore", player)); + itemStack.setItemMeta(itemMeta); + itemStack.addUnsafeEnchantment(XEnchantment.UNBREAKING.get(), 1); + + inventory.setItem(11, itemStack); + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof SettingsInventoryHolder)) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + Settings settings = settingsManager.getSettings(player); + + switch (event.getSlot()) { + case 11: + new DesignInventory(plugin).openInventory(player); + XSound.ENTITY_ITEM_PICKUP.play(player); + return; + case 12: + settings.setClearInventory(!settings.isClearInventory()); + break; + case 13: + settings.setDisableInteract(!settings.isDisableInteract()); + break; + case 14: + settings.setHidePlayers(!settings.isHidePlayers()); + toggleHidePlayers(player, settings); + break; + case 15: + settings.setInstantPlaceSigns(!settings.isInstantPlaceSigns()); + break; + + case 20: + settings.setKeepNavigator(!settings.isKeepNavigator()); + break; + case 21: + if (settings.getNavigatorType().equals(NavigatorType.OLD)) { + settings.setNavigatorType(NavigatorType.NEW); + } else { + settings.setNavigatorType(NavigatorType.OLD); + plugin.getArmorStandManager().removeArmorStands(player); + player.removePotionEffect(XPotion.BLINDNESS.get()); + } + break; + case 22: + if (!settings.isNightVision()) { + settings.setNightVision(true); + player.addPotionEffect(new PotionEffect(XPotion.NIGHT_VISION.get(), PotionEffect.INFINITE_DURATION, 0, false, false)); + } else { + settings.setNightVision(false); + player.removePotionEffect(XPotion.NIGHT_VISION.get()); + } + break; + case 23: + NoClipManager noClipManager = plugin.getNoClipManager(); + if (!settings.isNoClip()) { + settings.setNoClip(true); + noClipManager.startNoClip(player); + } else { + settings.setNoClip(false); + noClipManager.stopNoClip(player.getUniqueId()); + } + break; + case 24: + settings.setOpenTrapDoors(!settings.isOpenTrapDoors()); + break; + + case 29: + settings.setPlacePlants(!settings.isPlacePlants()); + break; + case 30: + if (!Config.Settings.scoreboard) { + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + if (settings.isScoreboard()) { + settings.setScoreboard(false); + settingsManager.hideScoreboard(player); + } else { + settings.setScoreboard(true); + settingsManager.displayScoreboard(player); + plugin.getPlayerService().forceUpdateSidebar(player); + } + break; + case 31: + settings.setSlabBreaking(!settings.isSlabBreaking()); + break; + case 32: + settings.setSpawnTeleport(!settings.isSpawnTeleport()); + break; + default: + return; + } + + XSound.ENTITY_ITEM_PICKUP.play(player); + new SettingsInventory(plugin).openInventory(player); + } + + @SuppressWarnings("deprecation") + private void toggleHidePlayers(Player player, Settings settings) { + if (settings.isHidePlayers()) { + Bukkit.getOnlinePlayers().forEach(player::hidePlayer); + } else { + Bukkit.getOnlinePlayers().forEach(player::showPlayer); + } + } + + private static class SettingsInventoryHolder extends BuildSystemHolder { + + public SettingsInventoryHolder(Player player) { + super(45, Messages.getString("settings_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsManager.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsManager.java new file mode 100644 index 0000000..169dfdd --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SettingsManager.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.settings; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import fr.mrmicky.fastboard.FastBoard; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SettingsManager { + + private final BuildSystemPlugin plugin; + private final WorldServiceImpl worldService; + + private final Map boards; + + public SettingsManager(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.worldService = plugin.getWorldService(); + + this.boards = new HashMap<>(); + } + + public Settings getSettings(Player player) { + return plugin.getPlayerService().getPlayerStorage().getBuildPlayer(player).getSettings(); + } + + /** + * Displays the player's scoreboard if enabled in the config and in their {@link Settings}. + * + * @param player The player object + */ + public void displayScoreboard(Player player) { + if (!Config.Settings.scoreboard) { + return; + } + + Settings settings = getSettings(player); + FastBoard board = new FastBoard(player); + this.boards.put(player.getUniqueId(), board); + + if (!settings.isScoreboard()) { + hideScoreboard(player); + return; + } + + board.updateTitle(Messages.getString("title", player)); + BukkitTask scoreboardTask = Bukkit.getScheduler().runTaskTimerAsynchronously(plugin, () -> updateScoreboard(player, board), 0L, 20L); + settings.setScoreboardTask(scoreboardTask); + } + + /** + * Displays scoreboards for all online players who have it enabled and if enabled in the config. + */ + public void displayScoreboard() { + if (!Config.Settings.scoreboard) { + return; + } + + Bukkit.getOnlinePlayers().forEach(this::displayScoreboard); + } + + public void updateScoreboard(Player player) { + FastBoard board = this.boards.get(player.getUniqueId()); + if (board != null) { + updateScoreboard(player, board); + } + } + + private void updateScoreboard(Player player, FastBoard board) { + List body = Messages.getStringList("body", player, (line) -> getPlaceholders(line, player)); + board.updateLines(body); + } + + @Contract("_, _ -> new") + @SuppressWarnings("unchecked") + private Map.Entry[] getPlaceholders(String originalString, Player player) { + if (!originalString.matches(".*%*%.*")) { + return new Map.Entry[0]; // Don't replace anything + } + + String worldName = player.getWorld().getName(); + BuildWorld buildWorld = worldService.getWorldStorage().getBuildWorld(worldName); + + final String defaultVal = "§f-"; + String status = defaultVal; + String permission = defaultVal; + String project = defaultVal; + String creator = defaultVal; + String creation = defaultVal; + String lastEdited = defaultVal; + String lastLoaded = defaultVal; + String lastUnloaded = defaultVal; + + if (buildWorld != null) { + WorldData worldData = buildWorld.getData(); + Builders builders = buildWorld.getBuilders(); + + status = Messages.getString(Messages.getMessageKey(worldData.status().get()), player); + permission = worldData.permission().get(); + project = worldData.project().get(); + creator = builders.hasCreator() ? builders.getCreator().getName() : "-"; + creation = Messages.formatDate(buildWorld.getCreation()); + lastEdited = Messages.formatDate(worldData.lastEdited().get()); + lastLoaded = Messages.formatDate(worldData.lastLoaded().get()); + lastUnloaded = Messages.formatDate(worldData.lastUnloaded().get()); + } + + return new Map.Entry[]{ + Map.entry("%world%", worldName), + Map.entry("%status%", status), + Map.entry("%permission%", permission), + Map.entry("%project%", project), + Map.entry("%creator%", creator), + Map.entry("%creation%", creation), + Map.entry("%lastedited%", lastEdited), + Map.entry("%lastloaded%", lastLoaded), + Map.entry("%lastunloaded%", lastUnloaded) + }; + } + + public void hideScoreboard(Player player) { + Settings settings = getSettings(player); + BukkitTask scoreboardTask = settings.getScoreboardTask(); + if (scoreboardTask != null) { + scoreboardTask.cancel(); + settings.setScoreboardTask(null); + } + + FastBoard board = this.boards.remove(player.getUniqueId()); + if (board != null) { + board.delete(); + } + } + + public void hideScoreboards() { + Bukkit.getOnlinePlayers().forEach(this::hideScoreboard); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SpeedInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SpeedInventory.java new file mode 100644 index 0000000..1b2f5c2 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/player/settings/SpeedInventory.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.player.settings; + +import com.cryptomorin.xseries.XSound; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import java.util.Map; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SpeedInventory implements InventoryHandler { + + private final InventoryManager inventoryManager; + + public SpeedInventory(BuildSystemPlugin plugin) { + this.inventoryManager = plugin.getInventoryManager(); + } + + public void openInventory(Player player) { + Inventory inventory = getInventory(player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(Player player) { + Inventory inventory = new SpeedInventoryHolder(player).getInventory(); + fillGuiWithGlass(player, inventory); + + inventory.setItem(11, InventoryUtils.createSkull(Messages.getString("speed_1", player), Profileable.detect("71bc2bcfb2bd3759e6b1e86fc7a79585e1127dd357fc202893f9de241bc9e530"))); + inventory.setItem(12, InventoryUtils.createSkull(Messages.getString("speed_2", player), Profileable.detect("4cd9eeee883468881d83848a46bf3012485c23f75753b8fbe8487341419847"))); + inventory.setItem(13, InventoryUtils.createSkull(Messages.getString("speed_3", player), Profileable.detect("1d4eae13933860a6df5e8e955693b95a8c3b15c36b8b587532ac0996bc37e5"))); + inventory.setItem(14, InventoryUtils.createSkull(Messages.getString("speed_4", player), Profileable.detect("d2e78fb22424232dc27b81fbcb47fd24c1acf76098753f2d9c28598287db5"))); + inventory.setItem(15, InventoryUtils.createSkull(Messages.getString("speed_5", player), Profileable.detect("6d57e3bc88a65730e31a14e3f41e038a5ecf0891a6c243643b8e5476ae2"))); + + return inventory; + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + for (int i = 0; i <= 26; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof SpeedInventoryHolder)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + if (!player.hasPermission("buildsystem.speed")) { + player.closeInventory(); + return; + } + + switch (event.getSlot()) { + case 11: + setSpeed(player, 0.2f, 1); + break; + case 12: + setSpeed(player, 0.4f, 2); + break; + case 13: + setSpeed(player, 0.6f, 3); + break; + case 14: + setSpeed(player, 0.8f, 4); + break; + case 15: + setSpeed(player, 1.0f, 5); + break; + default: + return; + } + + XSound.ENTITY_CHICKEN_EGG.play(player); + player.closeInventory(); + } + + private void setSpeed(Player player, float speed, int num) { + if (player.isFlying()) { + player.setFlySpeed(speed - 0.1f); + Messages.sendMessage(player, "speed_set_flying", Map.entry("%speed%", num)); + } else { + player.setWalkSpeed(speed); + Messages.sendMessage(player, "speed_set_walking", Map.entry("%speed%", num)); + } + } + + private static class SpeedInventoryHolder extends BuildSystemHolder { + + public SpeedInventoryHolder(Player player) { + super(27, Messages.getString("speed_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/FolderStorageImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/FolderStorageImpl.java new file mode 100644 index 0000000..87dff9a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/FolderStorageImpl.java @@ -0,0 +1,122 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.storage.FolderStorage; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.display.FolderImpl; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.ExecutionException; +import java.util.function.Function; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.jetbrains.annotations.Nullable; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public abstract class FolderStorageImpl implements FolderStorage { + + protected final Logger logger; + protected final BuildSystemPlugin plugin; + protected final WorldServiceImpl worldService; + + private final Map foldersByName; + + public FolderStorageImpl(BuildSystemPlugin plugin, WorldServiceImpl worldService) { + this.logger = plugin.getLogger(); + this.plugin = plugin; + this.worldService = worldService; + + this.foldersByName = new HashMap<>(); + } + + public void loadFolders() { + try { + this.foldersByName.putAll( + load().get().stream().collect(Collectors.toMap(folder -> folder.getName().toLowerCase(), Function.identity())) + ); + } catch (InterruptedException | ExecutionException e) { + logger.severe("Failed to load folders from storage: " + e.getMessage()); + } + } + + @Override + @Unmodifiable + public Collection getFolders() { + return Collections.unmodifiableCollection(foldersByName.values()); + } + + @Nullable + @Override + public Folder getFolder(String name) { + return foldersByName.get(name.toLowerCase()); + } + + @Override + public boolean folderExists(String name) { + return getFolder(name) != null; + } + + @Override + public Folder createFolder(String name, NavigatorCategory category, Builder creator) { + return createFolder(name, category, null, creator); + } + + @Override + public Folder createFolder(String name, NavigatorCategory category, @Nullable Folder parent, Builder creator) { + Folder folder = new FolderImpl(name, category, parent, creator); + foldersByName.put(name.toLowerCase(), folder); + return folder; + } + + @Override + public void removeFolder(String name) { + Folder removed = foldersByName.remove(name.toLowerCase()); + if (removed == null) { + return; + } + + // Remove all subfolders + getFolders().stream() + .filter(folder -> Objects.equals(folder.getParent(), removed)) + .forEach(this::removeFolder); + + // Remove world <> folder assignments + WorldStorage worldStorage = worldService.getWorldStorage(); + removed.getWorldUUIDs() + .stream() + .map(worldStorage::getBuildWorld) + .filter(Objects::nonNull) + .forEach(buildWorld -> buildWorld.setFolder(null)); + } + + @Override + public void removeFolder(Folder folder) { + removeFolder(folder.getName()); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/PlayerStorageImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/PlayerStorageImpl.java new file mode 100644 index 0000000..7afe9c6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/PlayerStorageImpl.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.storage.PlayerStorage; +import de.eintosti.buildsystem.player.BuildPlayerImpl; +import de.eintosti.buildsystem.player.settings.SettingsImpl; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Collectors; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public abstract class PlayerStorageImpl implements PlayerStorage { + + protected final BuildSystemPlugin plugin; + protected final Logger logger; + + private final Map buildPlayers; + + public PlayerStorageImpl(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.logger = plugin.getLogger(); + + this.buildPlayers = new HashMap<>(); + } + + public void loadPlayers() { + load().thenAccept(players -> { + Map loadedPlayers = players.stream().collect(Collectors.toMap(BuildPlayer::getUniqueId, Function.identity())); + this.buildPlayers.putAll(loadedPlayers); + logger.info("Loaded " + players.size() + " players from storage"); + }).exceptionally(throwable -> { + logger.log(Level.SEVERE, "Failed to load players from storage", throwable); + return null; + }); + } + + @Override + public BuildPlayer createBuildPlayer(UUID uuid) { + BuildPlayer buildPlayer = this.buildPlayers.getOrDefault(uuid, new BuildPlayerImpl(uuid, new SettingsImpl())); + this.buildPlayers.put(uuid, buildPlayer); + return buildPlayer; + } + + @Override + public BuildPlayer createBuildPlayer(Player player) { + return createBuildPlayer(player.getUniqueId()); + } + + @Override + public Collection getBuildPlayers() { + return Collections.unmodifiableCollection(this.buildPlayers.values()); + } + + @Override + @Nullable + public BuildPlayer getBuildPlayer(UUID uuid) { + return this.buildPlayers.get(uuid); + } + + public BuildPlayer getBuildPlayer(Player player) { + UUID playerUuid = player.getUniqueId(); + return this.buildPlayers.getOrDefault(playerUuid, createBuildPlayer(playerUuid)); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/WorldStorageImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/WorldStorageImpl.java new file mode 100644 index 0000000..1809de9 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/WorldStorageImpl.java @@ -0,0 +1,261 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage; + +import com.google.common.collect.BiMap; +import com.google.common.collect.HashBiMap; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.config.Config.World.Unload; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.creation.BuildWorldCreatorImpl; +import java.io.File; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public abstract class WorldStorageImpl implements WorldStorage { + + protected final Logger logger; + protected final BuildSystemPlugin plugin; + protected final WorldServiceImpl worldService; + + private final Map buildWorldsByUuid; + private final BiMap worldIdentifiers; + + public WorldStorageImpl(BuildSystemPlugin plugin, WorldServiceImpl worldService) { + this.logger = plugin.getLogger(); + this.plugin = plugin; + this.worldService = worldService; + + this.buildWorldsByUuid = new HashMap<>(); + this.worldIdentifiers = HashBiMap.create(); + } + + @Override + @Nullable + @Contract("null -> null") + public BuildWorld getBuildWorld(@Nullable String name) { + if (name == null || name.isEmpty()) { + return null; + } + + UUID uuid = this.worldIdentifiers.inverse().get(name.toLowerCase()); + if (uuid != null) { + return this.buildWorldsByUuid.get(uuid); + } + + return null; + } + + @Override + @Nullable + public BuildWorld getBuildWorld(World world) { + return getBuildWorld(world.getName()); + } + + @Override + @Nullable + public BuildWorld getBuildWorld(UUID uuid) { + return this.buildWorldsByUuid.get(uuid); + } + + @Override + @Unmodifiable + public Collection getBuildWorlds() { + return Collections.unmodifiableCollection(buildWorldsByUuid.values()); + } + + public void addBuildWorld(BuildWorld buildWorld) { + this.buildWorldsByUuid.put(buildWorld.getUniqueId(), buildWorld); + this.worldIdentifiers.put(buildWorld.getUniqueId(), buildWorld.getName().toLowerCase()); + } + + public void removeBuildWorld(BuildWorld buildWorld) { + UUID worldId = buildWorld.getUniqueId(); + this.buildWorldsByUuid.remove(worldId); + this.worldIdentifiers.remove(worldId); + + // Also remove world from any folder it may be in + Folder assignedFolder = buildWorld.getFolder(); + if (assignedFolder != null) { + assignedFolder.removeWorld(buildWorld); + } + } + + @Override + public boolean worldExists(String worldName) { + return getBuildWorld(worldName) != null; + } + + @Override + public boolean worldAndFolderExist(String worldName) { + boolean worldExists = worldExists(worldName); + if (!worldExists) { + return false; + } + + File worldFile = new File(Bukkit.getWorldContainer(), worldName); + return worldFile.exists(); + } + + @Override + @Unmodifiable + public List getBuildWorldsCreatedByPlayer(Player player) { + return getBuildWorlds().stream() + .filter(buildWorld -> buildWorld.getBuilders().isCreator(player)) + .toList(); + } + + @Override + @Unmodifiable + public List getBuildWorldsCreatedByPlayer(Player player, Visibility visibility) { + return getBuildWorldsCreatedByPlayer(player).stream() + .filter(buildWorld -> isCorrectVisibility(buildWorld.getData().privateWorld().get(), visibility)) + .toList(); + } + + /** + * Gets if a {@link BuildWorld}'s visibility is equal to the given visibility. + * + * @param privateWorld Whether the world is private + * @param visibility The visibility the world should have + * @return {@code true} if the world's visibility is equal to the given visibility, otherwise {@code false} + */ + public boolean isCorrectVisibility(boolean privateWorld, Visibility visibility) { + return switch (visibility) { + case PRIVATE -> privateWorld; + case PUBLIC -> !privateWorld; + case IGNORE -> true; + }; + } + + public void loadWorlds() { + load().thenAccept(worlds -> Bukkit.getScheduler().runTask(plugin, () -> { + worlds.forEach(this::addBuildWorld); + assignWorldsToFolders(); + + boolean loadAllWorlds = !Unload.enabled; + if (loadAllWorlds) { + logger.info("*** All worlds will be loaded now ***"); + } + + List notLoaded = new ArrayList<>(); + getBuildWorlds().forEach(buildWorld -> { + LoadResult loadResult = loadWorld(buildWorld, loadAllWorlds); + if (loadResult == LoadResult.FAILED) { + notLoaded.add(buildWorld); + } + }); + notLoaded.forEach(this::removeBuildWorld); + + logger.info("Loaded " + worlds.size() + " worlds from storage"); + })).exceptionally(throwable -> { + logger.log(Level.SEVERE, "Failed to load worlds from storage", throwable); + return null; + }); + } + + /** + * Assigns all {@link BuildWorld}s to their respective {@link Folder}s. + *

    + * Must be called after all folders and worlds have been loaded. + */ + private void assignWorldsToFolders() { + worldService.getFolderStorage().getFolders().forEach(folder -> { + List invalidWorlds = new ArrayList<>(); + folder.getWorldUUIDs().stream() + .map(worldUUID -> { + BuildWorld buildWorld = getBuildWorld(worldUUID); + if (buildWorld == null) { + invalidWorlds.add(worldUUID); + logger.warning("World with UUID " + worldUUID + " does not exist. Removing from folder: " + folder.getName()); + } + return buildWorld; + }) + .filter(Objects::nonNull) + .forEach(buildWorld -> buildWorld.setFolder(folder)); + invalidWorlds.forEach(folder::removeWorld); + } + ); + } + + /** + * Attempts to load the {@link World} with the given {@link BuildWorld}. + * + * @param buildWorld The world to load + * @param alwaysLoad Whether the world should always be loaded, regardless of being blacklisted + * @return The result of the load attempt + */ + private LoadResult loadWorld(BuildWorld buildWorld, boolean alwaysLoad) { + String worldName = buildWorld.getName(); + boolean shouldPreLoad = alwaysLoad || Unload.blacklistedWorlds.contains(worldName); + if (!shouldPreLoad) { + return LoadResult.NOT_LOADED; + } + + World world = new BuildWorldCreatorImpl(plugin, buildWorld).generateBukkitWorld(); + if (world == null) { + return LoadResult.FAILED; + } + + buildWorld.getData().lastLoaded().set(System.currentTimeMillis()); + logger.info("✔ World loaded: " + worldName); + return LoadResult.LOADED; + } + + private enum LoadResult { + + /** + * The {@link BuildWorld} was successfully loaded. + */ + LOADED, + + /** + * The {@link BuildWorld} was attempted to be loaded, but failed. + */ + FAILED, + + /** + * The {@link BuildWorld} was not attempted to be loaded because: + *

      + *
    • {@link Unload#enabled} is set to {@code true}, and
    • + *
    • {@link Unload#blacklistedWorlds} does not contain the world's name (therefore, it can remain unloaded)
    • + *
    + */ + NOT_LOADED + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/FolderStorageFactory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/FolderStorageFactory.java new file mode 100644 index 0000000..9a5baab --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/FolderStorageFactory.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.factory; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.storage.FolderStorageImpl; +import de.eintosti.buildsystem.storage.yaml.YamlFolderStorage; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +/** + * Factory for creating folder storage implementations. + */ +@NullMarked +public class FolderStorageFactory { + + private final BuildSystemPlugin plugin; + + public FolderStorageFactory(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + /** + * Creates a folder storage implementation based on the configured storage type. + * + * @param worldService The world service implementation to be used by the storage + * @return The folder storage implementation + */ + @Contract("_ -> new") + public FolderStorageImpl createStorage(WorldServiceImpl worldService) { + return new YamlFolderStorage(plugin, worldService); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/PlayerStorageFactory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/PlayerStorageFactory.java new file mode 100644 index 0000000..68989a2 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/PlayerStorageFactory.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.factory; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.storage.PlayerStorageImpl; +import de.eintosti.buildsystem.storage.yaml.YamlPlayerStorage; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +/** + * Factory for creating folder storage implementations. + */ +@NullMarked +public class PlayerStorageFactory { + + private final BuildSystemPlugin plugin; + + public PlayerStorageFactory(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + /** + * Creates a folder storage implementation based on the configured storage type. + * + * @return The folder storage implementation + */ + @Contract("-> new") + public PlayerStorageImpl createStorage() { + return new YamlPlayerStorage(plugin); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/WorldStorageFactory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/WorldStorageFactory.java new file mode 100644 index 0000000..a89addb --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/factory/WorldStorageFactory.java @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.factory; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.storage.yaml.YamlWorldStorage; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +/** + * Factory for creating world storage implementations. + */ +@NullMarked +public class WorldStorageFactory { + + private final BuildSystemPlugin plugin; + + public WorldStorageFactory(BuildSystemPlugin plugin) { + this.plugin = plugin; + } + + /** + * Creates a world storage implementation based on the configured storage type. + * + * @param worldService The world service implementation to be used by the storage + * @return The world storage implementation + */ + @Contract("_ -> new") + public WorldStorageImpl createStorage(WorldServiceImpl worldService) { + return new YamlWorldStorage(plugin, worldService); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/AbstractYamlStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/AbstractYamlStorage.java new file mode 100644 index 0000000..8a5382e --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/AbstractYamlStorage.java @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.yaml; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import java.io.File; +import java.io.IOException; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public abstract class AbstractYamlStorage { + + private final File file; + private final FileConfiguration configuration; + private final Logger logger; + + public AbstractYamlStorage(BuildSystemPlugin plugin, String fileName) { + this.file = new File(plugin.getDataFolder(), fileName); + this.configuration = YamlConfiguration.loadConfiguration(file); + this.logger = plugin.getLogger(); + loadFile(); + } + + public void loadFile() { + if (!file.exists()) { + configuration.options().copyDefaults(true); + saveFile(); + return; + } + + try { + configuration.load(file); + } catch (IOException | InvalidConfigurationException e) { + logger.log(Level.SEVERE, "Failed to load configuration file: " + file.getName(), e); + } + } + + public void saveFile() { + try { + configuration.save(file); + } catch (IOException e) { + logger.log(Level.SEVERE, "Failed to save configuration file: " + file.getName(), e); + } + } + + @Nullable + public FileConfiguration getFile() { + return configuration; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlFolderStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlFolderStorage.java new file mode 100644 index 0000000..2effb19 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlFolderStorage.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.yaml; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.storage.FolderStorageImpl; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.display.FolderImpl; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class YamlFolderStorage extends FolderStorageImpl { + + private static final String FOLDERS_KEY = "folders"; + + private final File file; + private final FileConfiguration config; + + public YamlFolderStorage(BuildSystemPlugin plugin, WorldServiceImpl worldService) { + super(plugin, worldService); + this.file = new File(plugin.getDataFolder(), "folders.yml"); + this.config = YamlConfiguration.loadConfiguration(file); + } + + @Override + public CompletableFuture save(Folder folder) { + return CompletableFuture.runAsync(() -> { + config.set(FOLDERS_KEY + "." + folder.getName(), serializeFolder(folder)); + saveFile(); + }); + } + + @Override + public CompletableFuture save(Collection folders) { + return CompletableFuture.runAsync(() -> { + folders.forEach(folder -> config.set(FOLDERS_KEY + "." + folder.getName(), serializeFolder(folder))); + saveFile(); + }); + } + + private void saveFile() { + try { + config.save(file); + } catch (IOException e) { + logger.log(Level.SEVERE, "Could not save folders.yml file", e); + } + } + + public Map serializeFolder(Folder folder) { + Map serializedFolder = new HashMap<>(); + + serializedFolder.put("creator", folder.getCreator().toString()); + serializedFolder.put("creation", folder.getCreation()); + serializedFolder.put("category", folder.getCategory().name()); + serializedFolder.put("parent", folder.hasParent() ? folder.getParent().getName() : null); + serializedFolder.put("material", folder.getIcon().name()); + serializedFolder.put("permission", folder.getPermission()); + serializedFolder.put("project", folder.getProject()); + serializedFolder.put("worlds", folder.getWorldUUIDs().stream().map(UUID::toString).toList()); + + return serializedFolder; + } + + @Override + @Contract("-> new") + public CompletableFuture> load() { + return CompletableFuture.supplyAsync(() -> { + Set folders = loadFolderKeys(); + + // First pass: Create all folders without parent references + Map loadedFolders = folders.stream() + .map(this::loadFolder) + .collect(Collectors.toMap(Folder::getName, Function.identity())); + + // Second pass: Set up parent references + for (String folderName : folders) { + String parentName = config.getString(FOLDERS_KEY + "." + folderName + ".parent"); + if (parentName != null) { + Folder folder = loadedFolders.get(folderName); + Folder parent = loadedFolders.get(parentName); + if (folder != null && parent != null) { + folder.setParent(parent); + } + } + } + + return new ArrayList<>(loadedFolders.values()); + }); + } + + private Set loadFolderKeys() { + if (!file.exists()) { + config.options().copyDefaults(true); + saveFile(); + return Set.of(); + } + + try { + config.load(file); + } catch (IOException | InvalidConfigurationException e) { + logger.log(Level.SEVERE, "Could not load folders.yml file", e); + } + + ConfigurationSection section = config.getConfigurationSection(FOLDERS_KEY); + if (section == null) { + return Set.of(); + } + + return section.getKeys(false); + } + + @Contract("_ -> new") + private Folder loadFolder(String folderName) { + final String path = FOLDERS_KEY + "." + folderName; + + Builder creator = Objects.requireNonNull(Builder.deserialize(config.getString(path + ".creator")), "Creator cannot be null for folder: " + folderName); + long creation = config.getLong(path + ".creation", System.currentTimeMillis()); + NavigatorCategory category = NavigatorCategory.valueOf(config.getString(path + ".category")); + XMaterial defaultMaterial = XMaterial.CHEST; + XMaterial material = XMaterial.matchXMaterial(config.getString(path + ".material", defaultMaterial.name())).orElse(defaultMaterial); + String permission = config.getString(path + ".permission", "-"); + String project = config.getString(path + ".project", "-"); + List worlds = config.getStringList(path + ".worlds").stream().map(UUID::fromString).toList(); + + return new FolderImpl( + folderName, + creation, + category, + null, // Parent will be set in second pass + creator, + material, + permission, + project, + worlds, + new ArrayList<>() + ); + } + + @Override + public CompletableFuture delete(Folder folder) { + return delete(folder.getName()); + } + + @Override + public CompletableFuture delete(String folderKey) { + return CompletableFuture.runAsync(() -> { + config.set(FOLDERS_KEY + "." + folderKey, null); + saveFile(); + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlPlayerStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlPlayerStorage.java new file mode 100644 index 0000000..734527d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlPlayerStorage.java @@ -0,0 +1,246 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.yaml; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.player.BuildPlayer; +import de.eintosti.buildsystem.api.player.LogoutLocation; +import de.eintosti.buildsystem.api.player.settings.DesignColor; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.navigator.settings.NavigatorType; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldDisplay; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldFilter; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldSort; +import de.eintosti.buildsystem.player.BuildPlayerImpl; +import de.eintosti.buildsystem.player.LogoutLocationImpl; +import de.eintosti.buildsystem.player.settings.SettingsImpl; +import de.eintosti.buildsystem.storage.PlayerStorageImpl; +import de.eintosti.buildsystem.world.navigator.settings.WorldDisplayImpl; +import de.eintosti.buildsystem.world.navigator.settings.WorldFilterImpl; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class YamlPlayerStorage extends PlayerStorageImpl { + + private static final String PLAYERS_KEY = "players"; + + private final File file; + private final FileConfiguration config; + + public YamlPlayerStorage(BuildSystemPlugin plugin) { + super(plugin); + this.file = new File(plugin.getDataFolder(), "players.yml"); + this.config = YamlConfiguration.loadConfiguration(file); + } + + @Override + public CompletableFuture save(BuildPlayer buildPlayer) { + return CompletableFuture.runAsync(() -> { + config.set(PLAYERS_KEY + "." + buildPlayer.getUniqueId(), serializePlayer(buildPlayer)); + saveFile(); + }); + } + + @Override + public CompletableFuture save(Collection players) { + return CompletableFuture.runAsync(() -> { + players.forEach(player -> config.set(PLAYERS_KEY + "." + player.getUniqueId(), serializePlayer(player))); + saveFile(); + }); + } + + private void saveFile() { + try { + config.save(file); + } catch (IOException e) { + logger.log(Level.SEVERE, "Could not save players.yml file", e); + } + } + + public Map serializePlayer(BuildPlayer player) { + Map serialized = new HashMap<>(); + + serialized.put("settings", serializeSettings(player.getSettings())); + if (player.getLogoutLocation() != null) { + serialized.put("logout-location", player.getLogoutLocation().toString()); + } + + return serialized; + } + + public Map serializeSettings(Settings settings) { + Map serialized = new HashMap<>(); + + serialized.put("type", settings.getNavigatorType().toString()); + serialized.put("glass", settings.getDesignColor().toString()); + serialized.put("world-display", serializeWorldDisplay(settings.getWorldDisplay())); + serialized.put("slab-breaking", settings.isSlabBreaking()); + serialized.put("no-clip", settings.isNoClip()); + serialized.put("trapdoor", settings.isOpenTrapDoors()); + serialized.put("nightvision", settings.isNightVision()); + serialized.put("scoreboard", settings.isScoreboard()); + serialized.put("keep-navigator", settings.isKeepNavigator()); + serialized.put("disable-interact", settings.isDisableInteract()); + serialized.put("spawn-teleport", settings.isSpawnTeleport()); + serialized.put("clear-inventory", settings.isClearInventory()); + serialized.put("instant-place-signs", settings.isInstantPlaceSigns()); + serialized.put("hide-players", settings.isHidePlayers()); + serialized.put("place-plants", settings.isPlacePlants()); + + return serialized; + } + + public Map serializeWorldDisplay(WorldDisplay worldDisplay) { + Map serialized = new HashMap<>(); + serialized.put("sort", worldDisplay.getWorldSort().toString()); + serialized.put("filter", serializeFilter(worldDisplay.getWorldFilter())); + return serialized; + } + + public Map serializeFilter(WorldFilter filter) { + Map serialized = new HashMap<>(); + serialized.put("mode", filter.getMode().toString()); + serialized.put("text", filter.getText()); + return serialized; + } + + @Override + public CompletableFuture> load() { + return CompletableFuture.supplyAsync(() -> + loadPlayerKeys().stream() + .map(this::loadPlayer) + .collect(Collectors.toCollection(ArrayList::new)) + ); + } + + private Set loadPlayerKeys() { + if (!file.exists()) { + config.options().copyDefaults(true); + saveFile(); + return Set.of(); + } + + try { + config.load(file); + } catch (IOException | InvalidConfigurationException e) { + logger.log(Level.SEVERE, "Could not load players.yml file", e); + } + + ConfigurationSection section = config.getConfigurationSection(PLAYERS_KEY); + if (section == null) { + return Set.of(); + } + + return section.getKeys(false); + } + + private BuildPlayer loadPlayer(String playerUuid) { + final String path = PLAYERS_KEY + "." + playerUuid; + + UUID uuid = UUID.fromString(playerUuid); + Settings settings = loadSettings(config, path + ".settings"); + + BuildPlayer buildPlayer = new BuildPlayerImpl(uuid, settings); + buildPlayer.setLogoutLocation(loadLogoutLocation(config, "players." + playerUuid + ".logout-location")); + return buildPlayer; + } + + @Nullable + private LogoutLocation loadLogoutLocation(FileConfiguration configuration, String pathPrefix) { + String location = configuration.getString(pathPrefix); + if (location == null || location.trim().isEmpty()) { + return null; + } + + String[] parts = location.split(":"); + if (parts.length != 6) { + return null; + } + + String worldName = parts[0]; + double x = Double.parseDouble(parts[1]); + double y = Double.parseDouble(parts[2]); + double z = Double.parseDouble(parts[3]); + float yaw = Float.parseFloat(parts[4]); + float pitch = Float.parseFloat(parts[5]); + + return new LogoutLocationImpl(worldName, x, y, z, yaw, pitch); + } + + @Contract("_, _ -> new") + private SettingsImpl loadSettings(FileConfiguration configuration, String pathPrefix) { + NavigatorType navigatorType = NavigatorType.valueOf(configuration.getString(pathPrefix + ".type")); + DesignColor glassColor = DesignColor.matchColor(configuration.getString(pathPrefix + ".glass")); + WorldDisplay worldDisplay = loadWorldDisplay(configuration, pathPrefix + ".world-display"); + boolean clearInventory = configuration.getBoolean(pathPrefix + ".clear-inventory", false); + boolean disableInteract = configuration.getBoolean(pathPrefix + ".disable-interact", false); + boolean hidePlayers = configuration.getBoolean(pathPrefix + ".hide-players", false); + boolean instantPlaceSigns = configuration.getBoolean(pathPrefix + ".instant-place-signs", false); + boolean keepNavigator = configuration.getBoolean(pathPrefix + ".keep-navigator", false); + boolean nightVision = configuration.getBoolean(pathPrefix + ".nightvision", false); + boolean noClip = configuration.getBoolean(pathPrefix + ".no-clip", false); + boolean placePlants = configuration.getBoolean(pathPrefix + ".place-plants", false); + boolean scoreboard = configuration.getBoolean(pathPrefix + ".scoreboard", true); + boolean slabBreaking = configuration.getBoolean(pathPrefix + ".slab-breaking", false); + boolean spawnTeleport = configuration.getBoolean(pathPrefix + ".spawn-teleport", true); + boolean trapDoor = configuration.getBoolean(pathPrefix + ".trapdoor", false); + + return new SettingsImpl( + navigatorType, glassColor, worldDisplay, clearInventory, disableInteract, hidePlayers, instantPlaceSigns, + keepNavigator, nightVision, noClip, placePlants, scoreboard, slabBreaking, spawnTeleport, trapDoor + ); + } + + @Contract("_, _ -> new") + private WorldDisplay loadWorldDisplay(FileConfiguration configuration, String pathPrefix) { + WorldSort worldSort = WorldSort.matchWorldSort(configuration.getString(pathPrefix + ".sort", WorldSort.NEWEST_FIRST.name())); + WorldFilter.Mode filterMode = WorldFilterImpl.Mode.valueOf(configuration.getString(pathPrefix + ".filter.mode", WorldFilter.Mode.NONE.name())); + String filterText = configuration.getString(pathPrefix + ".filter.text", ""); + return new WorldDisplayImpl(worldSort, new WorldFilterImpl(filterMode, filterText)); + } + + @Override + public CompletableFuture delete(BuildPlayer buildPlayer) { + return delete(buildPlayer.getUniqueId().toString()); + } + + @Override + public CompletableFuture delete(String playerKey) { + return CompletableFuture.runAsync(() -> { + config.set(PLAYERS_KEY + "." + playerKey, null); + saveFile(); + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSetupStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSetupStorage.java new file mode 100644 index 0000000..4c0c143 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSetupStorage.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.yaml; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.world.display.CustomizableIcons.IconType; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.FileConfiguration; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class YamlSetupStorage extends AbstractYamlStorage { + + public YamlSetupStorage(BuildSystemPlugin plugin) { + super(plugin, "setup.yml"); + } + + public > void saveIcons(IconType iconType, Map typeIcons) { + typeIcons.forEach((type, material) -> getFile().set("setup." + iconType.getKey() + "." + type.name().toLowerCase(Locale.ROOT), material.name())); + saveFile(); + } + + public > void saveIcon(IconType iconType, T type, XMaterial material) { + getFile().set("setup." + iconType.getKey() + "." + type.name().toLowerCase(Locale.ROOT), material.name()); + saveFile(); + } + + @Nullable + public Map loadIcons(IconType iconType, Function mapper) { + FileConfiguration configuration = getFile(); + if (configuration == null) { + return null; + } + + ConfigurationSection configurationSection = configuration.getConfigurationSection("setup." + iconType.getKey()); + if (configurationSection == null) { + return null; + } + + Set types = configurationSection.getKeys(false); + if (types.isEmpty()) { + return null; + } + + Map icons = new HashMap<>(); + + for (String type : types) { + String materialString = configuration.getString("setup." + iconType.getKey() + "." + type); + if (materialString == null) { + continue; + } + + T mappedKey = mapper.apply(type); + XMaterial.matchXMaterial(materialString).ifPresent(material -> icons.put(mappedKey, material)); + } + + return icons; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSpawnStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSpawnStorage.java new file mode 100644 index 0000000..bff2300 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlSpawnStorage.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.yaml; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import org.bukkit.Location; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class YamlSpawnStorage extends AbstractYamlStorage { + + public YamlSpawnStorage(BuildSystemPlugin plugin) { + super(plugin, "spawn.yml"); + } + + public void saveSpawn(@Nullable Location location) { + if (location == null || location.getWorld() == null) { + return; + } + + getFile().set("spawn", location.getWorld().getName() + ":" + + location.getX() + ":" + + location.getY() + ":" + + location.getZ() + ":" + + location.getYaw() + ":" + + location.getPitch() + ); + saveFile(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlWorldStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlWorldStorage.java new file mode 100644 index 0000000..4be363a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/storage/yaml/YamlWorldStorage.java @@ -0,0 +1,291 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.storage.yaml; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.world.BuildWorldImpl; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.creation.generator.CustomGeneratorImpl; +import de.eintosti.buildsystem.world.data.WorldDataImpl; +import de.eintosti.buildsystem.world.data.WorldDataImpl.WorldDataBuilder; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.stream.Collectors; +import org.bukkit.Difficulty; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.InvalidConfigurationException; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class YamlWorldStorage extends WorldStorageImpl { + + private static final String WORLDS_KEY = "worlds"; + + private final File file; + private final FileConfiguration config; + + public YamlWorldStorage(BuildSystemPlugin plugin, WorldServiceImpl worldService) { + super(plugin, worldService); + this.file = new File(plugin.getDataFolder(), "worlds.yml"); + this.config = YamlConfiguration.loadConfiguration(file); + } + + @Override + public CompletableFuture save(BuildWorld buildWorld) { + return CompletableFuture.runAsync(() -> { + config.set(WORLDS_KEY + "." + buildWorld.getName(), serializeWorld(buildWorld)); + saveFile(); + }); + } + + @Override + public CompletableFuture save(Collection buildWorlds) { + return CompletableFuture.runAsync(() -> { + buildWorlds.forEach(buildWorld -> config.set(WORLDS_KEY + "." + buildWorld.getName(), serializeWorld(buildWorld))); + saveFile(); + }); + } + + private void saveFile() { + try { + config.save(file); + } catch (IOException e) { + logger.log(Level.SEVERE, "Could not save worlds.yml file", e); + } + } + + public Map serializeWorld(BuildWorld buildWorld) { + Map world = new HashMap<>(); + + world.put("uuid", buildWorld.getUniqueId().toString()); + Builders builders = buildWorld.getBuilders(); + if (builders.getCreator() != null) { + world.put("creator", builders.getCreator().toString()); + } + world.put("type", buildWorld.getType().name()); + world.put("data", serializeWorldData(buildWorld.getData())); + world.put("date", buildWorld.getCreation()); + world.put("builders", serializeBuilders(builders.getAllBuilders())); + if (buildWorld.getCustomGenerator() != null) { + world.put("chunk-generator", buildWorld.getCustomGenerator().toString()); + } + + return world; + } + + private Map serializeWorldData(WorldData worldData) { + return worldData.getAllData().entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, entry -> entry.getValue().getConfigFormat())); + } + + private String serializeBuilders(Collection builders) { + StringBuilder builderList = new StringBuilder(); + for (Builder builder : builders) { + builderList.append(";").append(builder); + } + return !builderList.isEmpty() ? builderList.substring(1) : builderList.toString(); + } + + @Override + public CompletableFuture> load() { + return CompletableFuture.supplyAsync(() -> + loadWorldKeys().stream() + .map(this::loadWorld) + .collect(Collectors.toCollection(ArrayList::new)) + ); + } + + private Set loadWorldKeys() { + if (!file.exists()) { + config.options().copyDefaults(true); + saveFile(); + return Set.of(); + } + + try { + config.load(file); + } catch (IOException | InvalidConfigurationException e) { + logger.log(Level.SEVERE, "Could not load worlds.yml file", e); + } + + ConfigurationSection section = config.getConfigurationSection(WORLDS_KEY); + if (section == null) { + return Set.of(); + } + + return section.getKeys(false); + } + + @Contract("_ -> new") + private BuildWorldImpl loadWorld(String worldName) { + UUID uuid = config.isString("worlds." + worldName + ".uuid") + ? UUID.fromString(config.getString("worlds." + worldName + ".uuid")) + : UUID.randomUUID(); // Generate a new UUID if not present + Builder creator = parseCreator(worldName); + BuildWorldType worldType = config.isString("worlds." + worldName + ".type") + ? BuildWorldType.valueOf(config.getString("worlds." + worldName + ".type")) + : BuildWorldType.UNKNOWN; + WorldDataImpl worldData = parseWorldData(worldName); + long creationDate = config.isLong("worlds." + worldName + ".date") + ? config.getLong("worlds." + worldName + ".date") + : -1; + List builders = parseBuilders(worldName); + String generatorName = config.getString("worlds." + worldName + ".chunk-generator"); + CustomGeneratorImpl customGenerator = generatorName != null + ? CustomGeneratorImpl.of(generatorName, worldName) + : null; + + return new BuildWorldImpl( + uuid, + worldName, + worldType, + worldData, + creator, + builders, + creationDate, + customGenerator, + null // The folder will be set later + ); + } + + @Contract("_ -> new") + private WorldDataImpl parseWorldData(String worldName) { + final String path = WORLDS_KEY + "." + worldName + ".data"; + return new WorldDataBuilder(worldName) + .withCustomSpawn(config.getString(WORLDS_KEY + "." + worldName + ".spawn", "")) + .withPermission(config.getString(path + ".permission", "-")) + .withProject(config.getString(path + ".project", "-")) + .withDifficulty(Difficulty.valueOf(config.getString(path + ".difficulty", "PEACEFUL").toUpperCase(Locale.ROOT))) + .withMaterial(parseMaterial(path + ".material", worldName)) + .withStatus(BuildWorldStatus.valueOf(config.getString(path + ".status"))) + .withBlockBreaking(config.getBoolean(path + ".block-breaking")) + .withBlockInteractions(config.getBoolean(path + ".block-interactions")) + .withBlockPlacement(config.getBoolean(path + ".block-placement")) + .withBuildersEnabled(config.getBoolean(path + ".builders-enabled")) + .withExplosions(config.getBoolean(path + ".explosions")) + .withMobAi(config.getBoolean(path + ".mob-ai")) + .withPhysics(config.getBoolean(path + ".physics")) + .withPrivateWorld(config.getBoolean(path + ".private")) + .withTimeSinceBackup(config.getInt(path + ".time-since-backup", 0)) + .withLastLoaded(config.getLong(path + ".last-loaded")) + .withLastUnloaded(config.getLong(path + ".last-unloaded")) + .withLastEdited(config.getLong(path + ".last-edited")) + .build(); + } + + private XMaterial parseMaterial(String path, String worldName) { + String itemString = config.getString(path); + if (itemString == null) { + itemString = XMaterial.BEDROCK.name(); + plugin.getLogger().warning("Could not find material for \"" + worldName + "\". Defaulting to BEDROCK."); + } + + Optional xMaterial = XMaterial.matchXMaterial(itemString); + if (xMaterial.isPresent()) { + return xMaterial.get(); + } else { + plugin.getLogger().warning("Unknown material found for \"" + worldName + "\" (" + itemString + ")."); + plugin.getLogger().warning("Defaulting back to BEDROCK."); + return XMaterial.BEDROCK; + } + } + + @Nullable + private Builder parseCreator(String worldName) { + final String creator = config.getString(WORLDS_KEY + "." + worldName + ".creator"); + + // Previously, creator name & id were stored separately + final String oldCreatorIdPath = WORLDS_KEY + "." + worldName + ".creator-id"; + final String oldCreatorId = config.isString(oldCreatorIdPath) ? config.getString(oldCreatorIdPath) : null; + if (oldCreatorId != null) { + if (creator == null || creator.equals("-")) { + return null; + } + + if (!oldCreatorId.equals("null")) { + return Builder.of(UUID.fromString(oldCreatorId), creator); + } + + UUID creatorId = UUIDFetcher.getUUID(creator); + if (creatorId == null) { + return null; + } + + return Builder.of(creatorId, creator); + } + + return Builder.deserialize(creator); + } + + private List parseBuilders(String worldName) { + List builders = new ArrayList<>(); + + if (config.isString(WORLDS_KEY + "." + worldName + ".builders")) { + String buildersString = config.getString(WORLDS_KEY + "." + worldName + ".builders"); + if (buildersString != null && !buildersString.isEmpty()) { + String[] splitBuilders = buildersString.split(";"); + for (String serializedBuilder : splitBuilders) { + Builder builder = Builder.deserialize(serializedBuilder); + if (builder == null) { + plugin.getLogger().warning("Could not deserialize builder: " + serializedBuilder + " for world: " + worldName); + continue; + } + builders.add(builder); + } + } + } + + return builders; + } + + @Override + public CompletableFuture delete(BuildWorld buildWorld) { + return delete(buildWorld.getName()); + } + + @Override + public CompletableFuture delete(String worldKey) { + return CompletableFuture.runAsync(() -> { + config.set(WORLDS_KEY + "." + worldKey, null); + saveFile(); + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ArgumentParser.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ArgumentParser.java new file mode 100644 index 0000000..0d50ebf --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ArgumentParser.java @@ -0,0 +1,131 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class ArgumentParser { + + private final List args; + private final Map> map; + private final Set flags; + + public ArgumentParser(String[] arguments) { + this.args = Arrays.asList(arguments); + this.map = new HashMap<>(); + this.flags = new HashSet<>(); + map(); + } + + /** + * Gets whether the provided string is used as an argument.
    Useful for figuring out if an argument has a value. + * + * @param name The name of the argument + * @return {@code true} if the argument is present, otherwise {@code false} + */ + public boolean isArgument(String name) { + return args.stream() + .map(arg -> arg.replace("-", "")) + .anyMatch(name::equalsIgnoreCase); + } + + /** + * Gets whether the flag is present in the array of arguments. A flag is a true or false argument with no value. + *

    + * For example: {@code -nogui} + * + * @param name The name of the flag + * @return {@code true} if the flag is present, otherwise {@code false} + */ + public boolean getFlag(String name) { + return flags.contains(name); + } + + /** + * Gets the value of an argument. + *

    + * For example: {@code -name XYZ} + * + * @param name The name of the argument + * @return The argument value if present, otherwise {@code null}. + */ + @Nullable + public String getValue(String name) { + List value = map.get(name); + if (value != null) { + return String.join(" ", value); + } + return null; + } + + /** + * Maps the arguments passed to the ArgumentParser. Arguments starting with "-" are considered flags, and their presence is added to the 'flags' set. Arguments without "-" are + * considered argument names, and their values are stored in the 'map' HashMap. + */ + public void map() { + for (int index = 0; index < args.size(); index++) { + String arg = args.get(index); + if (!arg.startsWith("-")) { + continue; + } + if (isFlagArgument(arg, index)) { + flags.add(arg.replace("-", "")); + } else { + map.put(arg.replace("-", ""), storeArgumentValues(arg, index)); + } + } + } + + /** + * Determines whether the provided argument is a flag argument. A flag argument is an argument that is either the last argument in the list or is followed by another argument + * that starts with a dash (-). + * + * @param arg The argument to check + * @param index The index of the argument in the argument list + * @return {@code true} if the argument is a flag argument, otherwise {@code false} + */ + private boolean isFlagArgument(String arg, int index) { + return index == (args.size() - 1) || args.get(index + 1).startsWith("-"); + } + + /** + * Stores the argument values for a given argument name. + * + * @param arg The argument name + * @param index The index of the argument in the list of arguments + * @return The argument values as a list, or an empty list if no values are found + */ + private List storeArgumentValues(String arg, int index) { + List argumentValues = new ArrayList<>(); + int i = index + 1; + while (i != args.size() && !args.get(i).startsWith("-")) { + argumentValues.add(args.get(i)); + i++; + } + return argumentValues; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/DirectionUtil.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/DirectionUtil.java new file mode 100644 index 0000000..d0a36b1 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/DirectionUtil.java @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class DirectionUtil { + + public static final BlockFace[] BLOCK_SIDES = new BlockFace[]{ + BlockFace.NORTH, BlockFace.EAST, BlockFace.SOUTH, BlockFace.WEST, BlockFace.UP, BlockFace.DOWN + }; + + public static BlockFace getPlayerDirection(Player player) { + float yaw = player.getLocation().getYaw(); + if (yaw < 0) { + yaw += 360; + } + + yaw %= 360; + int i = (int) ((yaw + 8) / 22.5); + return switch (i) { + case 1 -> BlockFace.SOUTH_SOUTH_WEST; + case 2 -> BlockFace.SOUTH_WEST; + case 3 -> BlockFace.WEST_SOUTH_WEST; + case 4 -> BlockFace.WEST; + case 5 -> BlockFace.WEST_NORTH_WEST; + case 6 -> BlockFace.NORTH_WEST; + case 7 -> BlockFace.NORTH_NORTH_WEST; + case 8 -> BlockFace.NORTH; + case 9 -> BlockFace.NORTH_NORTH_EAST; + case 10 -> BlockFace.NORTH_EAST; + case 11 -> BlockFace.EAST_NORTH_EAST; + case 12 -> BlockFace.EAST; + case 13 -> BlockFace.EAST_SOUTH_EAST; + case 14 -> BlockFace.SOUTH_EAST; + case 15 -> BlockFace.SOUTH_SOUTH_EAST; + default -> BlockFace.SOUTH; + }; + } + + /** + * Gets the direction the block should be facing. + * + * @param player The player placing the block + * @param allowNonCardinal Should the block be allowed to face {@link BlockFace#UP} and {@link BlockFace#DOWN} + * @return The direction the block should be facing + */ + public static BlockFace getBlockDirection(Player player, boolean allowNonCardinal) { + if (!allowNonCardinal) { + return getCardinalDirection(player); + } + + float pitch = player.getLocation().getPitch(); + if (pitch <= -45) { + return BlockFace.DOWN; + } else if (pitch >= 45) { + return BlockFace.UP; + } else { + return getCardinalDirection(player); + } + } + + public static BlockFace getCardinalDirection(Player player) { + float yaw = player.getLocation().getYaw(); + if (yaw < 0) { + yaw += 360; + } + yaw %= 360; + + int i = (int) ((yaw + 8) / 22.5); + return switch (i) { + case 3, 4, 5, 6 -> BlockFace.EAST; + case 7, 8, 9, 10 -> BlockFace.SOUTH; + case 11, 12, 13, 14 -> BlockFace.WEST; + default -> BlockFace.NORTH; + }; + + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/FileUtils.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/FileUtils.java new file mode 100644 index 0000000..17a2031 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/FileUtils.java @@ -0,0 +1,226 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import com.google.common.collect.Sets; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; +import java.util.Comparator; +import java.util.Set; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import net.lingala.zip4j.ZipFile; +import net.lingala.zip4j.model.ExcludeFileFilter; +import net.lingala.zip4j.model.ZipParameters; +import org.bukkit.Bukkit; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public final class FileUtils { + + private static final Logger LOGGER = BuildSystemPlugin.get().getLogger(); + private static final Set IGNORE_FILES = Sets.newHashSet("uid.dat", "session.lock"); + + private FileUtils() { + } + + public static Path resolve(File file, final String child) { + return resolve(file.toPath(), child); + } + + public static Path resolve(Path parent, final String child) { + Path path = parent; + try { + if (!Files.exists(parent)) { + Files.createDirectory(parent); + } + path = parent.resolve(child); + if (!Files.exists(path)) { + Files.createDirectory(path); + } + } catch (final Exception e) { + LOGGER.log(Level.SEVERE, "Failed to create backup directory for " + child, e); + } + return path; + } + + /** + * Copies a file or directory from the source location to the target location. + * + * @param source The source file or directory to be copied + * @param target The target file or directory where the source will be copied to + * @throws RuntimeException If an I/O error occurs while copying + */ + public static void copy(File source, File target) { + try { + if (IGNORE_FILES.contains(source.getName())) { + return; + } + + if (source.isDirectory()) { + copyDirectory(source, target); + } else { + copyFile(source, target); + } + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to copy " + source.getAbsolutePath() + " to " + target.getAbsolutePath(), e); + } + } + + /** + * Copies a directory from source to target. + * + * @param source The source directory to be copied + * @param target The target directory where the source directory will be copied to + * @throws IOException If an I/O error occurs while copying the directory + */ + private static void copyDirectory(File source, File target) throws IOException { + if (!target.exists() && !target.mkdirs()) { + LOGGER.log(Level.SEVERE, "Failed to create target directory: " + target.getAbsolutePath()); + return; + } + + for (String fileName : source.list()) { + if (IGNORE_FILES.contains(fileName)) { + continue; + } + + File sourceFile = new File(source, fileName); + File targetFile = new File(target, fileName); + copy(sourceFile, targetFile); + } + } + + /** + * Copies a file from the source to the target location. + * + * @param source The source file to be copied + * @param target The target file where the source file will be copied to + * @throws IOException If an I/O error occurs while copying the file + */ + private static void copyFile(File source, File target) throws IOException { + try ( + InputStream inputStream = Files.newInputStream(source.toPath()); + OutputStream outputStream = Files.newOutputStream(target.toPath()) + ) { + byte[] buffer = new byte[1024]; + int length; + while ((length = inputStream.read(buffer)) > 0) { + outputStream.write(buffer, 0, length); + } + } + } + + /** + * Deletes a directory recursively. + * + * @param directory Directory to delete + * @throws IOException If an I/O error occurs while deleting the directory + */ + public static void deleteDirectory(File directory) throws IOException { + deleteDirectory(directory.toPath()); + } + + /** + * Deletes a directory recursively. + * + * @param directoryPath The path of the directory to delete + * @throws IOException If an I/O error occurs while deleting the directory + */ + public static void deleteDirectory(Path directoryPath) throws IOException { + try (Stream walk = Files.walk(directoryPath)) { + walk.sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + /** + * Gets the creation date of a file. + * + * @param file The file to be checked + * @return The number of milliseconds that have passed since {@code January 1, 1970 UTC}, until the file was created + */ + public static long getDirectoryCreation(File file) { + long creation = System.currentTimeMillis(); + try { + BasicFileAttributes attrs = Files.readAttributes(file.toPath(), BasicFileAttributes.class); + FileTime time = attrs.creationTime(); + creation = time.toMillis(); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to read attributes from " + file.getAbsolutePath(), e); + } + return creation; + } + + @Nullable + public static File zipWorld(File storage, BuildWorld buildWorld) { + try (ZipFile zipFile = new ZipFile(storage.getAbsolutePath())) { + File worldContainer = new File(Bukkit.getWorldContainer(), buildWorld.getName()); + + ExcludeFileFilter excludeFileFilter = Sets.newHashSet( + new File(worldContainer, "uid.dat"), + new File(worldContainer, "session.lock") + )::contains; + ZipParameters zipParameters = new ZipParameters(); + zipParameters.setExcludeFileFilter(excludeFileFilter); + + zipFile.addFolder(worldContainer, zipParameters); + return zipFile.getFile(); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to zip world " + storage.getAbsolutePath(), e); + } + return null; + } + + public static byte[] zipWorldToMemory(BuildWorld buildWorld) throws IOException { + Path worldPath = Path.of(new File(Bukkit.getWorldContainer(), buildWorld.getName()).getPath()); + ByteArrayOutputStream byteOut = new ByteArrayOutputStream(); + + try ( + ZipOutputStream zipOut = new ZipOutputStream(byteOut); + Stream walk = Files.walk(worldPath) + ) { + walk.filter(Files::isRegularFile).forEach(file -> { + try { + Path relativePath = worldPath.relativize(file); + zipOut.putNextEntry(new ZipEntry(relativePath.toString().replace("\\", "/"))); + Files.copy(file, zipOut); + zipOut.closeEntry(); + } catch (IOException e) { + LOGGER.log(Level.SEVERE, "Failed to zip world " + worldPath + " to memory", e); + } + }); + } + + return byteOut.toByteArray(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/NumberUtils.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/NumberUtils.java new file mode 100644 index 0000000..371c99f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/NumberUtils.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + *

    Provides extra functionality for Java Number classes.

    + * + * @since 2.0 + */ +@NullMarked +public final class NumberUtils { + + private NumberUtils() { + } + + /** + *

    Convert a {@code String} to an {@code int}, returning + * {@code zero} if the conversion fails.

    + * + *

    If the string is {@code null}, {@code zero} is returned.

    + * + *
    +     *   NumberUtils.toInt(null) = 0
    +     *   NumberUtils.toInt("")   = 0
    +     *   NumberUtils.toInt("1")  = 1
    +     * 
    + * + * @param str the string to convert, may be null + * @return the int represented by the string, or {@code zero} if conversion fails + * @since 2.1 + */ + public static int toInt(final String str) { + return toInt(str, 0); + } + + /** + *

    Convert a {@code String} to an {@code int}, returning a + * default value if the conversion fails.

    + * + *

    If the string is {@code null}, the default value is returned.

    + * + *
    +     *   NumberUtils.toInt(null, 1) = 1
    +     *   NumberUtils.toInt("", 1)   = 1
    +     *   NumberUtils.toInt("1", 0)  = 1
    +     * 
    + * + * @param str the string to convert, may be null + * @param defaultValue the default value + * @return the int represented by the string, or the default if conversion fails + * @since 2.1 + */ + public static int toInt(@Nullable final String str, final int defaultValue) { + if (str == null) { + return defaultValue; + } + try { + return Integer.parseInt(str); + } catch (final NumberFormatException nfe) { + return defaultValue; + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/PlayerChatInput.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/PlayerChatInput.java new file mode 100644 index 0000000..17b64bb --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/PlayerChatInput.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.HandlerList; +import org.bukkit.event.Listener; +import org.bukkit.event.player.AsyncPlayerChatEvent; +import org.bukkit.scheduler.BukkitRunnable; +import org.bukkit.scheduler.BukkitTask; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class PlayerChatInput implements Listener { + + private final BuildSystemPlugin plugin; + + private final BukkitTask taskId; + private final InputRunnable runWhenComplete; + private final UUID playerUuid; + private final boolean inputMode; + + private final Map inputs = new HashMap<>(); + + public PlayerChatInput(BuildSystemPlugin plugin, Player player, String titleKey, InputRunnable runWhenComplete) { + this.plugin = plugin; + + String title = Messages.getString(titleKey, player); + String subtitle = Messages.getString("cancel_subtitle", player); + + this.taskId = new BukkitRunnable() { + public void run() { + player.sendTitle(title, subtitle, 0, 30, 0); + } + }.runTaskTimer(plugin, 0L, 20L); + + this.playerUuid = player.getUniqueId(); + this.runWhenComplete = runWhenComplete; + this.inputMode = true; + + player.closeInventory(); + XSound.ENTITY_PLAYER_LEVELUP.play(player); + + this.register(); + } + + @EventHandler + public void onPlayerChat(AsyncPlayerChatEvent event) { + String input = event.getMessage(); + Player player = event.getPlayer(); + UUID playerUuid = player.getUniqueId(); + + if (!inputs.containsKey(playerUuid)) { + return; + } + + PlayerChatInput current = inputs.get(playerUuid); + if (!current.inputMode) { + return; + } + + event.setCancelled(true); + + if (input.equalsIgnoreCase("cancel")) { + current.taskId.cancel(); + current.unregister(); + + XSound.ENTITY_ITEM_BREAK.play(player); + player.resetTitle(); + Messages.sendMessage(player, "input_cancelled"); + return; + } + + current.taskId.cancel(); + Bukkit.getScheduler().runTask(current.plugin, () -> current.runWhenComplete.run(input)); + player.resetTitle(); + current.unregister(); + } + + private void register() { + inputs.put(this.playerUuid, this); + plugin.getServer().getPluginManager().registerEvents(this, plugin); + } + + private void unregister() { + inputs.remove(this.playerUuid); + HandlerList.unregisterAll(this); + } + + @FunctionalInterface + public interface InputRunnable { + + void run(String input); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ServerModeChecker.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ServerModeChecker.java new file mode 100644 index 0000000..e525bb6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/ServerModeChecker.java @@ -0,0 +1,93 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import org.bukkit.Bukkit; +import org.jspecify.annotations.NullMarked; + +/** + * Utility class to determine the mode in which the server is running. The server can be in one of the following modes: + *
      + *
    • {@link ServerMode#ONLINE} - The server is running in online mode
    • + *
    • {@link ServerMode#OFFLINE} - The server is running in offline mode
    • + *
    + */ +@NullMarked +public final class ServerModeChecker { + + private static final ServerMode SERVER_MODE = determineServerMode(); + + private ServerModeChecker() { + } + + /** + * Determines the server mode based on configuration settings. + * + * @return The detected server mode + */ + private static ServerMode determineServerMode() { + try { + Class CLASS_GlobalConfiguration = Class.forName("io.papermc.paper.configuration.GlobalConfiguration"); + Class CLASS_Proxies = Class.forName("io.papermc.paper.configuration.GlobalConfiguration$Proxies"); + + Field FIELD_instance = CLASS_GlobalConfiguration.getDeclaredField("instance"); + FIELD_instance.setAccessible(true); + + Field FIELD_proxies = CLASS_GlobalConfiguration.getDeclaredField("proxies"); + FIELD_proxies.setAccessible(true); + + Method METHOD_isProxyOnlineMode = CLASS_Proxies.getDeclaredMethod("isProxyOnlineMode"); + METHOD_isProxyOnlineMode.setAccessible(true); + + Object OBJECT_instance = FIELD_instance.get(null); + Object OBJECT_proxies = FIELD_proxies.get(OBJECT_instance); + + boolean isOnline = (boolean) METHOD_isProxyOnlineMode.invoke(OBJECT_proxies); + return isOnline ? ServerMode.ONLINE : ServerMode.OFFLINE; + } catch (Exception e) { + return Bukkit.getOnlineMode() ? ServerMode.ONLINE : ServerMode.OFFLINE; + } + } + + /** + * Retrieves the current server mode. + * + * @return The detected server mode + */ + public static ServerMode getServerMode() { + return SERVER_MODE; + } + + /** + * Enum representing the possible server modes. + */ + public enum ServerMode { + + /** + * The server is online. + */ + ONLINE, + + /** + * The server is offline. + */ + OFFLINE + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringCleaner.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringCleaner.java new file mode 100644 index 0000000..6b07dab --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringCleaner.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import de.eintosti.buildsystem.config.Config.World; +import java.util.Arrays; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public final class StringCleaner { + + public static final String INVALID_NAME_CHARACTERS = "[^A-Za-z\\d/_-]"; + + private StringCleaner() { + } + + /** + * Checks if the input string contains any invalid characters as defined by {@link #INVALID_NAME_CHARACTERS} and {@link World#invalidCharacters}. + * + * @param input The input string to check + * @return {@code true} if the input contains invalid characters, {@code false} otherwise + */ + public static boolean hasInvalidNameCharacters(String input) { + return Arrays.stream(input.split("")).anyMatch(c -> c.matches(INVALID_NAME_CHARACTERS) || c.matches(World.invalidCharacters)); + } + + /** + * Finds the first invalid character in the input string based on the defined invalid characters. + * + * @param input The input string to check for invalid characters + * @return The first invalid character found, or {@code null} if no invalid characters are present + */ + @Nullable + public static String firstInvalidChar(String input) { + return Arrays.stream(input.split("")) + .filter(c -> c.matches(INVALID_NAME_CHARACTERS) || c.matches(World.invalidCharacters)) + .findFirst() + .orElse(null); + } + + /** + * Sanitizes the input string by removing invalid characters, replacing spaces with underscores, and trimming whitespace. + * + * @param input The input string to sanitize + * @return A sanitized version of the input string + */ + public static String sanitize(String input) { + return input + .replaceAll(INVALID_NAME_CHARACTERS, "") + .replaceAll(World.invalidCharacters, "") + .replace(" ", "_") + .trim(); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringUtils.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringUtils.java new file mode 100644 index 0000000..6740a03 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/StringUtils.java @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import de.eintosti.buildsystem.config.Config.Messages; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.Date; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class StringUtils { + + private StringUtils() { + } + + /** + * Formats a given time in milliseconds to a human-readable string. + * + * @param millis The time in milliseconds to format + * @return A formatted string representing the date and time + */ + public static String formatTime(long millis) { + Date date = new Date(millis); + DateFormat formatter = new SimpleDateFormat(Messages.dateFormat + " HH:mm:ss"); + return formatter.format(date); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UUIDFetcher.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UUIDFetcher.java new file mode 100644 index 0000000..8adb233 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UUIDFetcher.java @@ -0,0 +1,162 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import com.google.gson.TypeAdapter; +import com.google.gson.stream.JsonReader; +import com.google.gson.stream.JsonWriter; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.util.ServerModeChecker.ServerMode; +import java.io.BufferedReader; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@SuppressWarnings("deprecation") +@NullMarked +public final class UUIDFetcher { + + private static final String UUID_URL = "https://api.mojang.com/users/profiles/minecraft/%s"; + private static final String NAME_URL = "https://api.mojang.com/user/profiles/%s/names"; + + private static final Map UUID_CACHE = new HashMap<>(); + private static final Map NAME_CACHE = new HashMap<>(); + + /** + * Fetches the uuid which belongs to the player with the give name synchronously and returns it. + * + * @param name The name of the player whose uuid is to be fetched + * @return The uuid which belongs to the player + */ + @Nullable + public static UUID getUUID(String name) { + String lowerCase = name.toLowerCase(Locale.ROOT); + if (UUID_CACHE.containsKey(lowerCase)) { + return UUID_CACHE.get(lowerCase); + } + + if (ServerModeChecker.getServerMode() == ServerMode.OFFLINE) { + UUID uuid = Bukkit.getOfflinePlayer(name).getUniqueId(); + cacheUser(uuid, name); + return uuid; + } + + try { + HttpURLConnection connection = (HttpURLConnection) new URL(UUID_URL.formatted(name)).openConnection(); + connection.setReadTimeout(5000); + + JsonObject jsonObject; + try { + // Support older versions of JSON used by Minecraft versions <1.18 + jsonObject = new JsonParser().parse(new BufferedReader(new InputStreamReader(connection.getInputStream()))).getAsJsonObject(); + } catch (IllegalStateException | FileNotFoundException ignored) { + return null; + } + + UUID uuid = UUIDTypeAdapter.fromString(jsonObject.get("id").getAsString()); + cacheUser(uuid, name); + return uuid; + } catch (Exception e) { + BuildSystemPlugin.get().getLogger().log(Level.SEVERE, "Failed to fetch UUID for player: " + name, e); + } + + return null; + } + + /** + * Fetches the name which belongs to the player with the give uuid synchronously and returns it. + * + * @param uuid The uuid of the player whose name is to be fetched + * @return The name which belongs to the player + */ + @Nullable + public static String getName(UUID uuid) { + if (NAME_CACHE.containsKey(uuid)) { + return NAME_CACHE.get(uuid); + } + + if (ServerModeChecker.getServerMode() == ServerMode.OFFLINE) { + String name = Bukkit.getOfflinePlayer(uuid).getName(); + if (name != null) { + cacheUser(uuid, name); + } + return name; + } + + try { + HttpURLConnection connection = (HttpURLConnection) new URL(NAME_URL.formatted(UUIDTypeAdapter.fromUUID(uuid))).openConnection(); + connection.setReadTimeout(5000); + + JsonArray nameHistory; + try { + // Support older versions of JSON used by Minecraft versions <1.18 + nameHistory = new JsonParser().parse(new BufferedReader(new InputStreamReader(connection.getInputStream()))).getAsJsonArray(); + } catch (IllegalStateException ignored) { + return null; + } + + JsonObject currentNameData = nameHistory.get(nameHistory.size() - 1).getAsJsonObject(); + String name = currentNameData.get("name").getAsString(); + cacheUser(uuid, name); + return name; + } catch (Exception e) { + BuildSystemPlugin.get().getLogger().log(Level.SEVERE, "Failed to fetch name for UUID: " + uuid, e); + } + + return null; + } + + public static void cacheUser(UUID uuid, String name) { + UUID_CACHE.put(name.toLowerCase(Locale.ROOT), uuid); + NAME_CACHE.put(uuid, name); + } + + public static final class UUIDTypeAdapter extends TypeAdapter { + + public static String fromUUID(final UUID value) { + return value.toString().replace("-", ""); + } + + public static UUID fromString(final String input) { + return UUID.fromString(input.replaceFirst("(\\w{8})(\\w{4})(\\w{4})(\\w{4})(\\w{12})", "$1-$2-$3-$4-$5")); + } + + @Override + public void write(JsonWriter out, final UUID value) throws IOException { + out.value(fromUUID(value)); + } + + @Override + public UUID read(JsonReader in) throws IOException { + return fromString(in.nextString()); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UpdateChecker.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UpdateChecker.java new file mode 100644 index 0000000..28a3fc2 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/UpdateChecker.java @@ -0,0 +1,317 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util; + +import com.google.common.base.Preconditions; +import com.google.gson.JsonElement; +import com.google.gson.JsonParser; +import com.google.gson.stream.JsonReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URI; +import java.net.URL; +import java.util.concurrent.CompletableFuture; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.bukkit.plugin.java.JavaPlugin; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * A utility class to assist in checking for updates for plugins uploaded to + * SpigotMC. Before any members of this + * class are accessed, {@link #init(JavaPlugin, int)} must be invoked by the plugin, preferably in its {@link JavaPlugin#onEnable()} method, though that is not a requirement. + *

    + * This class performs asynchronous queries to Spigot's API. If the results of {@link #requestUpdateCheck()} are inconsistent with what is published on SpigotMC, it may be due to + * the REST API cache. Results will be updated in due time. + * + * @author Parker Hawke - Choco + */ +@NullMarked +public final class UpdateChecker { + + private static final String USER_AGENT = "CHOCO-update-checker"; + private static final String UPDATE_URL = "https://api.spigotmc.org/simple/0.1/index.php?action=getResource&id=%d"; + private static final Pattern DECIMAL_SCHEME_PATTERN = Pattern.compile("\\d+(?:\\.\\d+)*"); + + /** + * The default version scheme for this update checker + */ + @Nullable + public static final VersionScheme VERSION_SCHEME_DECIMAL = (first, second) -> { + String[] firstSplit = splitVersionInfo(first), secondSplit = splitVersionInfo(second); + if (firstSplit == null || secondSplit == null) { + return null; + } + + for (int i = 0; i < Math.min(firstSplit.length, secondSplit.length); i++) { + int currentValue = NumberUtils.toInt(firstSplit[i]), newestValue = NumberUtils.toInt(secondSplit[i]); + + if (newestValue > currentValue) { + return second; + } else if (newestValue < currentValue) { + return first; + } + } + + return (secondSplit.length > firstSplit.length) ? second : first; + }; + + @Nullable + private static UpdateChecker instance; + + private final JavaPlugin plugin; + private final int pluginID; + private final VersionScheme versionScheme; + + @Nullable + private UpdateResult lastResult = null; + + private UpdateChecker(JavaPlugin plugin, int pluginID, VersionScheme versionScheme) { + this.plugin = plugin; + this.pluginID = pluginID; + this.versionScheme = versionScheme; + } + + private static String @Nullable [] splitVersionInfo(String version) { + Matcher matcher = DECIMAL_SCHEME_PATTERN.matcher(version); + return matcher.find() ? matcher.group().split("\\.") : null; + } + + /** + * Initialize this update checker with the specified values and return its instance. If an instance of UpdateChecker has already been initialized, this method will act + * similarly to {@link #get()} (which is recommended after initialization). + * + * @param plugin the plugin for which to check updates. Cannot be {@code null} + * @param pluginID the ID of the plugin as identified in the SpigotMC resource link. For example, "https://www.spigotmc.org/resources/veinminer.12038/" would expect + * "12038" as a value. The value must be greater than 0 + * @param versionScheme a custom version scheme parser. Cannot be {@code null} + * @return the UpdateChecker instance + */ + public static UpdateChecker init(JavaPlugin plugin, int pluginID, VersionScheme versionScheme) { + Preconditions.checkArgument(pluginID > 0, "Plugin ID must be greater than 0"); + + return (instance == null) ? instance = new UpdateChecker(plugin, pluginID, versionScheme) : instance; + } + + /** + * Initialize this update checker with the specified values and return its instance. If an instance of UpdateChecker has already been initialized, this method will act + * similarly to {@link #get()} (which is recommended after initialization). + * + * @param plugin the plugin for which to check updates. Cannot be {@code null} + * @param pluginID the ID of the plugin as identified in the SpigotMC resource link. For example, "https://www.spigotmc.org/resources/veinminer.12038/" would expect + * "12038" as a value. The value must be greater than 0 + * @return the UpdateChecker instance + */ + public static UpdateChecker init(JavaPlugin plugin, int pluginID) { + return init(plugin, pluginID, VERSION_SCHEME_DECIMAL); + } + + /** + * Get the initialized instance of UpdateChecker. If {@link #init(JavaPlugin, int)} has not yet been invoked, this method will throw an exception. + * + * @return the UpdateChecker instance + */ + public static UpdateChecker get() { + Preconditions.checkState(instance != null, "Instance has not yet been initialized. Be sure #init() has been invoked"); + return instance; + } + + /** + * Check whether the UpdateChecker has been initialized or not (if {@link #init(JavaPlugin, int)} has been invoked) and {@link #get()} is safe to use. + * + * @return {@code true} if initialized, {@code false} otherwise + */ + public static boolean isInitialized() { + return instance != null; + } + + /** + * Request an update check to Spigot. This request is asynchronous and may not complete immediately as an HTTP GET request is published to the Spigot API. + * + * @return a future update result + */ + @Contract("-> new") + public CompletableFuture requestUpdateCheck() { + return CompletableFuture.supplyAsync(() -> { + int responseCode; + + try { + URL url = URI.create(UPDATE_URL.formatted(pluginID)).toURL(); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.addRequestProperty("User-Agent", USER_AGENT); + responseCode = connection.getResponseCode(); + + JsonReader reader = new JsonReader(new InputStreamReader(connection.getInputStream())); + JsonElement json = JsonParser.parseReader(reader); + reader.close(); + + if (!json.isJsonObject()) { + return new UpdateResult(UpdateReason.INVALID_JSON); + } + + String currentVersion = json.getAsJsonObject().get("current_version").getAsString(); + String pluginVersion = plugin.getDescription().getVersion(); + String latest = versionScheme.compareVersions(pluginVersion, currentVersion); + + if (latest == null) { + return new UpdateResult(UpdateReason.UNSUPPORTED_VERSION_SCHEME); + } else if (latest.equals(pluginVersion)) { + return new UpdateResult(pluginVersion.equals(currentVersion) + ? UpdateReason.UP_TO_DATE + : UpdateReason.UNRELEASED_VERSION); + } else if (latest.equals(currentVersion)) { + return new UpdateResult(UpdateReason.NEW_UPDATE, latest); + } + } catch (IOException e) { + return new UpdateResult(UpdateReason.COULD_NOT_CONNECT); + } + + return new UpdateResult(responseCode == 401 ? UpdateReason.UNAUTHORIZED_QUERY : UpdateReason.UNKNOWN_ERROR); + }); + } + + /** + * Get the last update result that was queried by {@link #requestUpdateCheck()}. If no update check was performed since this class' initialization, this method will return + * {@code null}. + * + * @return the last update check result. {@code null} if none. + */ + @Nullable + public UpdateResult getLastResult() { + return lastResult; + } + + /** + * A constant reason for the result of {@link UpdateResult}. + */ + public enum UpdateReason { + + /** + * A new update is available for download on SpigotMC. + */ + NEW_UPDATE, // The only reason that requires an update + + /** + * A successful connection to the Spigot API could not be established. + */ + COULD_NOT_CONNECT, + + /** + * The JSON retrieved from Spigot was invalid or malformed. + */ + INVALID_JSON, + + /** + * A 401 error was returned by the Spigot API. + */ + UNAUTHORIZED_QUERY, + + /** + * The version of the plugin installed on the server is greater than the one uploaded to SpigotMC's resources section. + */ + UNRELEASED_VERSION, + + /** + * An unknown error occurred. + */ + UNKNOWN_ERROR, + + /** + * The plugin uses an unsupported version scheme, therefore a proper comparison between versions could not be made. + */ + UNSUPPORTED_VERSION_SCHEME, + + /** + * The plugin is up-to-date with the version released on SpigotMC's resources section. + */ + UP_TO_DATE + } + + /** + * A functional interface to compare two version Strings with similar version schemes. + */ + @FunctionalInterface + public interface VersionScheme { + + /** + * Compare two versions and return the higher of the two. If null is returned, it is assumed that at least one of the two versions are unsupported by this version scheme + * parser. + * + * @param first the first version to check + * @param second the second version to check + * @return the greater of the two versions. {@code null} if unsupported version schemes + */ + @Nullable + String compareVersions(String first, String second); + } + + /** + * Represents a result for an update query performed by {@link UpdateChecker#requestUpdateCheck()}. + */ + public final class UpdateResult { + + private final UpdateReason reason; + private final String newestVersion; + + { // An actual use for initializer blocks. This is madness! + UpdateChecker.this.lastResult = this; + } + + private UpdateResult(UpdateReason reason, String newestVersion) { + this.reason = reason; + this.newestVersion = newestVersion; + } + + private UpdateResult(UpdateReason reason) { + Preconditions.checkArgument(reason != UpdateReason.NEW_UPDATE, "Reasons that require updates must also provide the latest version String"); + + this.reason = reason; + this.newestVersion = plugin.getDescription().getVersion(); + } + + /** + * Get the constant reason of this result. + * + * @return the reason + */ + public UpdateReason getReason() { + return reason; + } + + /** + * Check whether this result requires the user to update. + * + * @return {@code true} if requires update, {@code false} otherwise + */ + public boolean requiresUpdate() { + return reason == UpdateReason.NEW_UPDATE; + } + + /** + * Get the latest version of the plugin. This may be the currently installed version, it may not be. This depends entirely on the result of the update. + * + * @return the newest version of the plugin + */ + public String getNewestVersion() { + return newestVersion; + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorAPI.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorAPI.java new file mode 100644 index 0000000..8033e3b --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorAPI.java @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.color; + +import com.google.common.collect.ImmutableMap; +import de.eintosti.buildsystem.util.color.patterns.GradientPattern; +import de.eintosti.buildsystem.util.color.patterns.HexPattern; +import de.eintosti.buildsystem.util.color.patterns.RainbowPattern; +import de.eintosti.buildsystem.util.color.patterns.SolidPattern; +import java.awt.Color; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import net.md_5.bungee.api.ChatColor; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class ColorAPI { + + private static final Map COLORS = ImmutableMap.builder() + .put(new Color(0), ChatColor.getByChar('0')) + .put(new Color(170), ChatColor.getByChar('1')) + .put(new Color(43520), ChatColor.getByChar('2')) + .put(new Color(43690), ChatColor.getByChar('3')) + .put(new Color(11141120), ChatColor.getByChar('4')) + .put(new Color(11141290), ChatColor.getByChar('5')) + .put(new Color(16755200), ChatColor.getByChar('6')) + .put(new Color(11184810), ChatColor.getByChar('7')) + .put(new Color(5592405), ChatColor.getByChar('8')) + .put(new Color(5592575), ChatColor.getByChar('9')) + .put(new Color(5635925), ChatColor.getByChar('a')) + .put(new Color(5636095), ChatColor.getByChar('b')) + .put(new Color(16733525), ChatColor.getByChar('c')) + .put(new Color(16733695), ChatColor.getByChar('d')) + .put(new Color(16777045), ChatColor.getByChar('e')) + .put(new Color(16777215), ChatColor.getByChar('f')) + .build(); + + private static final java.util.List SPECIAL_COLORS = Arrays.asList( + "&l", "&n", "&o", "&k", "&m", + "§l", "§n", "§o", "§k", "§m" + ); + + private static final java.util.List PATTERNS = Arrays.asList( + new GradientPattern(), + new HexPattern(), + new SolidPattern(), + new RainbowPattern() + ); + + /** + * Processes a string to add color to it. Thanks to Distressing for helping with the regex <3 + * + * @param string The string we want to process + */ + public static String process(String string) { + for (ColorPattern pattern : PATTERNS) { + string = pattern.process(string); + } + string = ChatColor.translateAlternateColorCodes('&', string); + return string; + } + + /** + * Processes multiple strings in a collection. + * + * @param strings The collection of the strings we are processing + * @return The list of processed strings + */ + @Unmodifiable + public static List process(Collection strings) { + return strings.stream().map(ColorAPI::process).toList(); + } + + /** + * Colors a String with a gradiant. + * + * @param string The string we want to color + * @param start The starting gradiant + * @param end The ending gradiant + */ + public static String color(String string, Color start, Color end) { + ChatColor[] colors = createGradient(start, end, withoutSpecialChar(string).length()); + return apply(string, colors); + } + + /** + * Colors a String with rainbow colors. + * + * @param string The string which should have rainbow colors + * @param saturation The saturation of the rainbow colors + */ + public static String rainbow(String string, float saturation) { + ChatColor[] colors = createRainbow(withoutSpecialChar(string).length(), saturation); + return apply(string, colors); + } + + /** + * Gets a color from hex code. + * + * @param string The hex code of the color + */ + public static ChatColor getColor(String string) { + return ChatColor.of(new Color(Integer.parseInt(string, 16))); + } + + private static String apply(String source, ChatColor[] colors) { + StringBuilder specialColors = new StringBuilder(); + StringBuilder stringBuilder = new StringBuilder(); + + String[] characters = source.split(""); + int outIndex = 0; + + for (int i = 0; i < characters.length; i++) { + if (characters[i].equals("&") || characters[i].equals("§")) { + if (i + 1 < characters.length) { + if (characters[i + 1].equals("r")) { + specialColors.setLength(0); + } else { + specialColors.append(characters[i]); + specialColors.append(characters[i + 1]); + } + i++; + } else { + stringBuilder.append(colors[outIndex++]).append(specialColors).append(characters[i]); + } + } else { + stringBuilder.append(colors[outIndex++]).append(specialColors).append(characters[i]); + } + } + + return stringBuilder.toString(); + } + + private static String withoutSpecialChar(String source) { + String workingString = source; + for (String color : SPECIAL_COLORS) { + if (workingString.contains(color)) { + workingString = workingString.replace(color, ""); + } + } + return workingString; + } + + /** + * Returns a rainbow array of chat colors. + * + * @param step How many colors we return + * @param saturation The saturation of the rainbow + * @return The array of colors + */ + private static ChatColor[] createRainbow(int step, float saturation) { + ChatColor[] colors = new ChatColor[step]; + double colorStep = (1.00 / step); + + for (int i = 0; i < step; i++) { + Color color = Color.getHSBColor((float) (colorStep * i), saturation, saturation); + colors[i] = ChatColor.of(color); + } + + return colors; + } + + /** + * Returns a gradient array of chat colors. + * + * @param start The starting color. + * @param end The ending color. + * @param step How many colors we return. + * @author TheViperShow + */ + private static ChatColor[] createGradient(Color start, Color end, int step) { + ChatColor[] colors = new ChatColor[step]; + int stepR = Math.abs(start.getRed() - end.getRed()) / (step - 1); + int stepG = Math.abs(start.getGreen() - end.getGreen()) / (step - 1); + int stepB = Math.abs(start.getBlue() - end.getBlue()) / (step - 1); + int[] direction = new int[]{ + start.getRed() < end.getRed() ? +1 : -1, + start.getGreen() < end.getGreen() ? +1 : -1, + start.getBlue() < end.getBlue() ? +1 : -1 + }; + + for (int i = 0; i < step; i++) { + Color color = new Color( + start.getRed() + ((stepR * i) * direction[0]), + start.getGreen() + ((stepG * i) * direction[1]), + start.getBlue() + ((stepB * i) * direction[2]) + ); + colors[i] = ChatColor.of(color); + } + + return colors; + } + + /** + * Returns the closest legacy color from an RGB color + * + * @param color The color we want to transform + */ + private static ChatColor getClosestColor(Color color) { + Color nearestColor = null; + double nearestDistance = Integer.MAX_VALUE; + + for (Color constantColor : COLORS.keySet()) { + double distance = Math.pow(color.getRed() - constantColor.getRed(), 2) + + Math.pow(color.getGreen() - constantColor.getGreen(), 2) + + Math.pow(color.getBlue() - constantColor.getBlue(), 2); + if (nearestDistance > distance) { + nearestColor = constantColor; + nearestDistance = distance; + } + } + + return COLORS.get(nearestColor); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorPattern.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorPattern.java new file mode 100644 index 0000000..c8b8792 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/ColorPattern.java @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.color; + +import org.jspecify.annotations.NullMarked; + +/** + * Represents a color pattern which can be applied to a String. + */ +@NullMarked +public interface ColorPattern { + + /** + * Applies this pattern to the provided String. Output might be the same as the input if this pattern is not present. + * + * @param string The String to which this pattern should be applied to + * @return The new String with the applied pattern + */ + String process(String string); +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/GradientPattern.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/GradientPattern.java new file mode 100644 index 0000000..daa1724 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/GradientPattern.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.color.patterns; + +import de.eintosti.buildsystem.util.color.ColorAPI; +import de.eintosti.buildsystem.util.color.ColorPattern; +import java.awt.Color; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class GradientPattern implements ColorPattern { + + private static final Pattern PATTERN = Pattern.compile("(.*?)"); + + @Override + public String process(String string) { + Matcher matcher = PATTERN.matcher(string); + while (matcher.find()) { + String start = matcher.group(1); + String end = matcher.group(3); + String content = matcher.group(2); + string = string.replace( + matcher.group(), ColorAPI.color(content, new Color(Integer.parseInt(start, 16)), + new Color(Integer.parseInt(end, 16)) + )); + } + return string; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/HexPattern.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/HexPattern.java new file mode 100644 index 0000000..5d473a5 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/HexPattern.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.color.patterns; + +import de.eintosti.buildsystem.util.color.ColorPattern; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class HexPattern implements ColorPattern { + + private static final Pattern DEFAULT_HEX = Pattern.compile("#[0-9a-fA-F]{6}"); + private static final Pattern BRACKET_HEX = Pattern.compile("\\{#[0-9a-fA-F]{6}}"); + private static final Pattern SPIGOT_HEX = Pattern.compile("&x[&0-9a-fA-F]{12}"); + + @Override + public String process(String input) { + String text = applyFormats(input); + Matcher matcher = DEFAULT_HEX.matcher(text); + while (matcher.find()) { + String hexCode = matcher.group(); + text = text.replace(hexCode, toChatColor(hexCode)); + } + return text; + } + + private String applyFormats(String input) { + String text = input; + + text = parseDefaultFormat(text); + text = parseBracketFormat(text); + text = parseSpigotFormat(text); + + return text; + } + + //&#RRGGBB + private String parseDefaultFormat(String input) { + return input.replace("&#", "#"); + } + + //{#RRGGBB} + private String parseBracketFormat(String input) { + String text = input; + Matcher matcher = BRACKET_HEX.matcher(text); + + while (matcher.find()) { + String hexCode = matcher.group(); + String fixed = hexCode.substring(2, 8); + text = text.replace(hexCode, "#" + fixed); + } + + return text; + } + + //&x&R&R&G&G&B&B + private String parseSpigotFormat(String input) { + String text = input.replace('§', '&'); + + Matcher matcher = SPIGOT_HEX.matcher(text); + while (matcher.find()) { + String hexCode = matcher.group(); + String fixed = hexCode.substring(3).replace("&", ""); + text = text.replace(hexCode, "#" + fixed); + } + + return text; + } + + private String toChatColor(String hexCode) { + StringBuilder magic = new StringBuilder("§x"); + char[] colorChars = hexCode.substring(1).toCharArray(); + + for (char c : colorChars) { + magic.append('§').append(c); + } + + return magic.toString(); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/RainbowPattern.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/RainbowPattern.java new file mode 100644 index 0000000..c0a900f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/RainbowPattern.java @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.color.patterns; + +import de.eintosti.buildsystem.util.color.ColorAPI; +import de.eintosti.buildsystem.util.color.ColorPattern; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class RainbowPattern implements ColorPattern { + + private static final Pattern PATTERN = Pattern.compile("(.*?)"); + + @Override + public String process(String string) { + Matcher matcher = PATTERN.matcher(string); + while (matcher.find()) { + String saturation = matcher.group(1); + String content = matcher.group(2); + string = string.replace(matcher.group(), ColorAPI.rainbow(content, Float.parseFloat(saturation))); + } + return string; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/SolidPattern.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/SolidPattern.java new file mode 100644 index 0000000..d97b342 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/color/patterns/SolidPattern.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.color.patterns; + +import de.eintosti.buildsystem.util.color.ColorAPI; +import de.eintosti.buildsystem.util.color.ColorPattern; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SolidPattern implements ColorPattern { + + private static final Pattern PATTERN = Pattern.compile("|#\\{([0-9A-Fa-f]{6})}"); + + @Override + public String process(String string) { + Matcher matcher = PATTERN.matcher(string); + while (matcher.find()) { + String color = matcher.group(1); + if (color == null) { + color = matcher.group(2); + } + + string = string.replace(matcher.group(), ColorAPI.getColor(color) + ""); + } + return string; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildSystemHolder.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildSystemHolder.java new file mode 100644 index 0000000..74f3812 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildSystemHolder.java @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.inventory; + +import org.bukkit.Bukkit; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.jspecify.annotations.NullMarked; + +/** + * Holder for custom Bukkit inventories used within the BuildSystem plugin. This class implements {@link InventoryHolder} to allow for custom inventory handling. + */ +@NullMarked +public class BuildSystemHolder implements InventoryHolder { + + private final Inventory bukkitInventory; + + /** + * Initializes a new {@link BuildSystemHolder} with the specified size and title. + * + * @param size The size of the inventory. + * @param title The title of the inventory. + */ + public BuildSystemHolder(int size, String title) { + this.bukkitInventory = Bukkit.createInventory(this, size, title); + } + + /** + * Returns the custom inventory held by this holder. + * + * @return The {@link Inventory} instance. + */ + @Override + public Inventory getInventory() { + return this.bukkitInventory; + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildWorldHolder.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildWorldHolder.java new file mode 100644 index 0000000..e7212c1 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/BuildWorldHolder.java @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.inventory; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import org.jspecify.annotations.NullMarked; + +/** + * Represents an inventory holder specifically for a {@link BuildWorld}, extending {@link BuildSystemHolder}. This allows inventories to be associated with a particular build + * world. + */ +@NullMarked +public class BuildWorldHolder extends BuildSystemHolder { + + private final BuildWorld buildWorld; + + /** + * Initializes a new {@link BuildWorldHolder} with the specified {@link BuildWorld}, size, and title. + * + * @param size The size of the inventory. + * @param title The title of the inventory. + */ + public BuildWorldHolder(BuildWorld buildWorld, int size, String title) { + super(size, title); + this.buildWorld = buildWorld; + } + + /** + * Returns the {@link BuildWorld} associated with this inventory holder. + * + * @return The associated {@link BuildWorld} instance. + */ + public BuildWorld getBuildWorld() { + return buildWorld; + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryHandler.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryHandler.java new file mode 100644 index 0000000..ef2dbc7 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryHandler.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.inventory; + +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.jspecify.annotations.NullMarked; + +/** + * Interface for custom inventory logic. + */ +@NullMarked +public interface InventoryHandler { + + /** + * Handles an {@link InventoryOpenEvent} for this custom inventory. + * + * @param event The {@link InventoryOpenEvent} to handle + */ + default void onOpen(InventoryOpenEvent event) { + } + + /** + * Handles an {@link InventoryClickEvent} for this custom inventory. + * + * @param event The {@link InventoryClickEvent} to handle + */ + default void onClick(InventoryClickEvent event) { + } + + /** + * Handles an {@link InventoryCloseEvent} for this custom inventory. + * + * @param event The {@link InventoryCloseEvent} to handle + */ + default void onClose(InventoryCloseEvent event) { + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryManager.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryManager.java new file mode 100644 index 0000000..10ebd32 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryManager.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.inventory; + +import java.util.HashMap; +import java.util.Map; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryOpenEvent; +import org.bukkit.inventory.Inventory; +import org.jspecify.annotations.NullMarked; + +/** + * Manages active inventories and dispatches inventory-related events to their respective {@link InventoryHandler}s. + *

    + * This class acts as a central point for handling custom inventory logic within the plugin. + */ +@NullMarked +public class InventoryManager { + + public final Map activeInventories; + + /** + * Constructs a new {@link InventoryManager} instance. + */ + public InventoryManager() { + this.activeInventories = new HashMap<>(); + } + + /** + * Registers an {@link InventoryHandler} for a specific {@link Inventory}. When events occur for the given inventory, the registered handler will be notified. + * + * @param inventory The inventory to register the handler for + * @param handler The handler to associate with the inventory + */ + public void registerInventoryHandler(Inventory inventory, InventoryHandler handler) { + this.activeInventories.put(inventory, handler); + } + + /** + * Unregisters the {@link InventoryHandler} associated with a specific {@link Inventory}. Events for this inventory will no longer be dispatched to its handler after + * unregistration. + * + * @param inventory The inventory whose handler is to be unregistered + */ + public void unregisterInventoryHandler(Inventory inventory) { + this.activeInventories.remove(inventory); + } + + /** + * Handles an {@link InventoryOpenEvent}, dispatching it to the appropriate {@link InventoryHandler} if one is registered for the opened inventory. + * + * @param event The open event to handle + */ + public void handleOpen(InventoryOpenEvent event) { + InventoryHandler handler = this.activeInventories.get(event.getInventory()); + if (handler != null) { + handler.onOpen(event); + } + } + + /** + * Handles an {@link InventoryClickEvent}, dispatching it to the appropriate {@link InventoryHandler} if one is registered for the clicked inventory. + * + * @param event The click event to handle + */ + public void handleClick(InventoryClickEvent event) { + InventoryHandler handler = this.activeInventories.get(event.getInventory()); + if (handler != null) { + handler.onClick(event); + } + } + + /** + * Handles an {@link InventoryCloseEvent}, dispatching it to the appropriate {@link InventoryHandler} if one is registered for the closed inventory. The handler for the closed + * inventory is also automatically unregistered upon closure. + * + * @param event The close event to handle + */ + public void handleClose(InventoryCloseEvent event) { + Inventory inventory = event.getInventory(); + InventoryHandler handler = this.activeInventories.remove(inventory); + if (handler != null) { + handler.onClose(event); + unregisterInventoryHandler(inventory); + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryUtils.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryUtils.java new file mode 100644 index 0000000..93d1e2d --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/InventoryUtils.java @@ -0,0 +1,326 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.inventory; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.profiles.builder.XSkull; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.settings.DesignColor; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.display.Displayable.DisplayableType; +import de.eintosti.buildsystem.config.Config.Settings.Navigator; +import java.util.Arrays; +import java.util.List; +import java.util.OptionalInt; +import java.util.logging.Logger; +import java.util.stream.IntStream; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.PlayerInventory; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Utility class for inventory-related operations. All methods are static and the class cannot be instantiated. + */ +@NullMarked +public final class InventoryUtils { + + private static final BuildSystemPlugin PLUGIN = BuildSystemPlugin.getPlugin(BuildSystemPlugin.class); + private static final Logger LOGGER = PLUGIN.getLogger(); + + public final static NamespacedKey DISPLAYABLE_NAME_KEY = new NamespacedKey(PLUGIN, "displayable_name"); + public final static NamespacedKey DISPLAYABLE_TYPE_KEY = new NamespacedKey(PLUGIN, "displayable_type"); + public static final NamespacedKey NAVIGATOR_KEY = new NamespacedKey(PLUGIN, "navigator"); + + private InventoryUtils() { + throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); + } + + /** + * Creates an ItemStack with the given material, display name and lore. + * + * @param material The material of the item + * @param displayName The display name of the item + * @param lore The lore of the item + * @return The created ItemStack + */ + public static ItemStack createItem(XMaterial material, String displayName, List lore) { + ItemStack itemStack = material.parseItem(); + if (itemStack == null) { + itemStack = XMaterial.BEDROCK.parseItem(); + LOGGER.warning("Unknown material found (" + material + "). Defaulting to " + itemStack.getType() + "."); + } + + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta == null) { + return itemStack; + } + + itemMeta.setDisplayName(displayName); + itemMeta.setLore(lore); + itemMeta.addItemFlags(ItemFlag.values()); + itemStack.setItemMeta(itemMeta); + + return itemStack; + } + + /** + * Creates an ItemStack with the given material, display name and lore. + * + * @param material The material of the item + * @param displayName The display name of the item + * @param lore The lore of the item as varargs + * @return The created ItemStack + */ + public static ItemStack createItem(XMaterial material, String displayName, String... lore) { + return createItem(material, displayName, Arrays.asList(lore)); + } + + /** + * Creates a player skull ItemStack with the given display name and lore. + * + * @param displayName The display name of the skull + * @param profileable The profile to use for the skull + * @param lore The lore of the skull + * @return The created skull ItemStack + */ + public static ItemStack createSkull(String displayName, Profileable profileable, List lore) { + ItemStack skull = XSkull.createItem() + .profile(profileable) + .lenient() + .apply(); + + ItemMeta itemMeta = skull.getItemMeta(); + if (itemMeta == null) { + return skull; + } + + itemMeta.setDisplayName(displayName); + itemMeta.setLore(lore); + skull.setItemMeta(itemMeta); + + return skull; + } + + /** + * Creates a player skull ItemStack with the given display name and lore. + * + * @param displayName The display name of the skull + * @param profileable The profile to use for the skull + * @param lore The lore of the skull as varargs + * @return The created skull ItemStack + */ + public static ItemStack createSkull(String displayName, Profileable profileable, String... lore) { + return createSkull(displayName, profileable, Arrays.asList(lore)); + } + + /** + * Adds a glass pane to the given inventory at the specified position. + * + * @param player The player viewing the inventory + * @param inventory The inventory to add the glass pane to + * @param position The position to add the glass pane at + */ + public static void addGlassPane(Player player, Inventory inventory, int position) { + inventory.setItem(position, createItem(getColoredGlassPane(player), " ")); + } + + /** + * Gets the colored glass pane material based on the player's settings. + * + * @param player The player to get the glass pane for + * @return The colored glass pane material + */ + public static XMaterial getColoredGlassPane(Player player) { + Settings settings = PLUGIN.getSettingsManager().getSettings(player); + DesignColor color = settings.getDesignColor(); + String paneItemName = color.name() + "_STAINED_GLASS_PANE"; + return XMaterial.matchXMaterial(paneItemName).orElse(XMaterial.BLACK_STAINED_GLASS_PANE); + } + + /** + * Adds a world item to the given inventory at the specified slot. + * + * @param inventory The inventory to add the item to + * @param slot The slot to add the item at + * @param buildWorld The world to create the item for + * @param displayName The display name of the item + * @param lore The lore of the item + */ + public static void addWorldItem(Inventory inventory, int slot, BuildWorld buildWorld, String displayName, List lore) { + XMaterial material = buildWorld.getData().material().get(); + if (material != XMaterial.PLAYER_HEAD) { + inventory.setItem(slot, createItem(material, displayName, lore)); + return; + } + + // Initially set a default head + ItemStack defaultHead = createItem(XMaterial.PLAYER_HEAD, displayName, lore); + storeWorldInformation(defaultHead, buildWorld); + inventory.setItem(slot, defaultHead); + + // Then try to set texture asynchronously + XSkull.createItem() + .profile(buildWorld.getData().privateWorld().get() + ? buildWorld.asProfilable() + : Profileable.username(buildWorld.getName()) + ) + .fallback(buildWorld.asProfilable()) + .lenient() + .applyAsync() + .thenAcceptAsync(itemStack -> { + ItemMeta itemMeta = itemStack.getItemMeta(); + itemMeta.setDisplayName(displayName); + itemMeta.setLore(lore); + itemStack.setItemMeta(itemMeta); + storeWorldInformation(itemStack, buildWorld); + inventory.setItem(slot, itemStack); + }); + } + + /** + * Stores information about the given {@link BuildWorld} in the given item's {@link PersistentDataContainer}. + * + * @param itemStack The item stack to store the world information in + * @param buildWorld The world to store information about + */ + private static void storeWorldInformation(ItemStack itemStack, BuildWorld buildWorld) { + ItemMeta itemMeta = itemStack.getItemMeta(); + PersistentDataContainer pdc = itemMeta.getPersistentDataContainer(); + pdc.set(DISPLAYABLE_TYPE_KEY, PersistentDataType.STRING, DisplayableType.BUILD_WORLD.name()); + pdc.set(DISPLAYABLE_NAME_KEY, PersistentDataType.STRING, buildWorld.getName()); + itemStack.setItemMeta(itemMeta); + } + + /** + * Checks if an {@link ItemStack} is a navigator item. + * + * @param itemStack The item to check + * @return true if the item is a navigator, false otherwise + */ + public static boolean isNavigator(@Nullable ItemStack itemStack) { + if (itemStack == null || itemStack.getType() != Navigator.item.get()) { + return false; + } + + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta == null) { + return false; + } + + return Boolean.TRUE.equals(itemStack.getItemMeta().getPersistentDataContainer().get(NAVIGATOR_KEY, PersistentDataType.BOOLEAN)); + } + + @Contract("_ -> new") + public static ItemStack createNavigatorItem(Player player) { + ItemStack itemStack = InventoryUtils.createItem(Navigator.item, Messages.getString("navigator_item", player)); + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta == null) { + return itemStack; + } + + itemMeta.getPersistentDataContainer().set(NAVIGATOR_KEY, PersistentDataType.BOOLEAN, true); + itemStack.setItemMeta(itemMeta); + return itemStack; + } + + /** + * Gets all slots containing a navigator item in a player's inventory. + * + * @param player The player to check + * @return A list of slot numbers containing navigator items + */ + @Unmodifiable + public static List getNavigatorSlots(Player player) { + PlayerInventory playerInventory = player.getInventory(); + return IntStream.range(0, playerInventory.getSize()) + .filter(i -> isNavigator(playerInventory.getItem(i))) + .boxed() + .toList(); + } + + /** + * Checks if a player's inventory contains a navigator item. + * + * @param player The player to check + * @return true if the inventory contains a navigator, false otherwise + */ + public static boolean hasNavigator(Player player) { + return Arrays.stream(player.getInventory().getContents()).anyMatch(InventoryUtils::isNavigator); + } + + /** + * Replaces an {@link ItemStack} in a player's inventory with another {@link ItemStack}. + * + * @param player The player whose inventory to modify + * @param findItemName The name of the item to find + * @param findItemType The type of the item to find + * @param replaceItem The item to replace with + */ + public static void replaceItem(Player player, String findItemName, XMaterial findItemType, ItemStack replaceItem) { + PlayerInventory inventory = player.getInventory(); + + OptionalInt slot = IntStream.range(0, inventory.getSize()) + .filter(i -> { + ItemStack currentItem = inventory.getItem(i); + if (currentItem == null || currentItem.getType() != findItemType.get()) { + return false; + } + + ItemMeta itemMeta = currentItem.getItemMeta(); + return itemMeta != null && itemMeta.getDisplayName().equals(findItemName); + }) + .findFirst(); + + if (slot.isPresent()) { + inventory.setItem(slot.getAsInt(), replaceItem); + return; + } + + // If item not found, try to put in slot 8 or add to inventory + ItemStack slot8 = inventory.getItem(8); + if (slot8 == null || slot8.getType() == XMaterial.AIR.get()) { + inventory.setItem(8, replaceItem); + } else { + inventory.addItem(replaceItem); + } + } + + /** + * Fills the top and bottom rows of an {@link Inventory} with glass panes. + * + * @param inventory The inventory to fill + * @param player The player viewing the inventory + */ + public static void fillWithGlass(Inventory inventory, Player player) { + IntStream.rangeClosed(0, 8).forEach(i -> addGlassPane(player, inventory, i)); + IntStream.rangeClosed(45, 53).forEach(i -> addGlassPane(player, inventory, i)); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/MaterialUtils.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/MaterialUtils.java new file mode 100644 index 0000000..c798658 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/MaterialUtils.java @@ -0,0 +1,80 @@ +/* + * Copyright (c) 2018-2023, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.inventory; + +import com.cryptomorin.xseries.XMaterial; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public final class MaterialUtils { + + private MaterialUtils() { + } + + /** + * Checks if this Material can be interacted with. + *

    + * Interactable materials include those with functionality when they are interacted with by a player such as chests, furnaces, etc. Some blocks such as piston heads and stairs + * are considered interactable though may not perform any additional functionality. Note that the interactability of some materials may be dependent on their state as well. + * This method will return true if there is at least one state in which additional interact handling is performed for the material. + * + * @param material The material to check + * @return {@code true} if this material can be interacted with. + */ + public static boolean isInteractable(XMaterial material) { + return switch (material) { + case ACACIA_BUTTON, ACACIA_DOOR, ACACIA_FENCE, ACACIA_FENCE_GATE, ACACIA_HANGING_SIGN, ACACIA_SIGN, ACACIA_STAIRS, ACACIA_TRAPDOOR, ACACIA_WALL_HANGING_SIGN, + ACACIA_WALL_SIGN, ANDESITE_STAIRS, ANVIL, BAMBOO_BUTTON, BAMBOO_DOOR, BAMBOO_FENCE, BAMBOO_FENCE_GATE, BAMBOO_HANGING_SIGN, BAMBOO_MOSAIC_STAIRS, BAMBOO_SIGN, + BAMBOO_STAIRS, BAMBOO_TRAPDOOR, BAMBOO_WALL_HANGING_SIGN, BAMBOO_WALL_SIGN, BARREL, BEACON, BEEHIVE, BEE_NEST, BELL, BIRCH_BUTTON, BIRCH_DOOR, BIRCH_FENCE, + BIRCH_FENCE_GATE, BIRCH_HANGING_SIGN, BIRCH_SIGN, BIRCH_STAIRS, BIRCH_TRAPDOOR, BIRCH_WALL_HANGING_SIGN, BIRCH_WALL_SIGN, BLACKSTONE_STAIRS, BLACK_BED, + BLACK_CANDLE, BLACK_CANDLE_CAKE, BLACK_SHULKER_BOX, BLAST_FURNACE, BLUE_BED, BLUE_CANDLE, BLUE_CANDLE_CAKE, BLUE_SHULKER_BOX, BREWING_STAND, BRICK_STAIRS, + BROWN_BED, BROWN_CANDLE, BROWN_CANDLE_CAKE, BROWN_SHULKER_BOX, CAKE, CAMPFIRE, CANDLE, CANDLE_CAKE, CARTOGRAPHY_TABLE, CAULDRON, CAVE_VINES, CAVE_VINES_PLANT, + CHAIN_COMMAND_BLOCK, CHERRY_BUTTON, CHERRY_DOOR, CHERRY_FENCE, CHERRY_FENCE_GATE, CHERRY_HANGING_SIGN, CHERRY_SIGN, CHERRY_STAIRS, CHERRY_TRAPDOOR, + CHERRY_WALL_HANGING_SIGN, CHERRY_WALL_SIGN, CHEST, CHIPPED_ANVIL, CHISELED_BOOKSHELF, COBBLED_DEEPSLATE_STAIRS, COBBLESTONE_STAIRS, COMMAND_BLOCK, COMPARATOR, + COMPOSTER, CRAFTING_TABLE, CRIMSON_BUTTON, CRIMSON_DOOR, CRIMSON_FENCE, CRIMSON_FENCE_GATE, CRIMSON_HANGING_SIGN, CRIMSON_SIGN, CRIMSON_STAIRS, CRIMSON_TRAPDOOR, + CRIMSON_WALL_HANGING_SIGN, CRIMSON_WALL_SIGN, CUT_COPPER_STAIRS, CYAN_BED, CYAN_CANDLE, CYAN_CANDLE_CAKE, CYAN_SHULKER_BOX, DAMAGED_ANVIL, DARK_OAK_BUTTON, + DARK_OAK_DOOR, DARK_OAK_FENCE, DARK_OAK_FENCE_GATE, DARK_OAK_HANGING_SIGN, DARK_OAK_SIGN, DARK_OAK_STAIRS, DARK_OAK_TRAPDOOR, DARK_OAK_WALL_HANGING_SIGN, + DARK_OAK_WALL_SIGN, DARK_PRISMARINE_STAIRS, DAYLIGHT_DETECTOR, DEEPSLATE_BRICK_STAIRS, DEEPSLATE_REDSTONE_ORE, DEEPSLATE_TILE_STAIRS, DIORITE_STAIRS, DISPENSER, + DRAGON_EGG, DROPPER, ENCHANTING_TABLE, ENDER_CHEST, END_STONE_BRICK_STAIRS, EXPOSED_CUT_COPPER_STAIRS, FLETCHING_TABLE, FLOWER_POT, FURNACE, GRANITE_STAIRS, + GRAY_BED, GRAY_CANDLE, GRAY_CANDLE_CAKE, GRAY_SHULKER_BOX, GREEN_BED, GREEN_CANDLE, GREEN_CANDLE_CAKE, GREEN_SHULKER_BOX, GRINDSTONE, HOPPER, IRON_DOOR, + IRON_TRAPDOOR, JIGSAW, JUKEBOX, JUNGLE_BUTTON, JUNGLE_DOOR, JUNGLE_FENCE, JUNGLE_FENCE_GATE, JUNGLE_HANGING_SIGN, JUNGLE_SIGN, JUNGLE_STAIRS, JUNGLE_TRAPDOOR, + JUNGLE_WALL_HANGING_SIGN, JUNGLE_WALL_SIGN, LAVA_CAULDRON, LECTERN, LEVER, LIGHT, LIGHT_BLUE_BED, LIGHT_BLUE_CANDLE, LIGHT_BLUE_CANDLE_CAKE, + LIGHT_BLUE_SHULKER_BOX, LIGHT_GRAY_BED, LIGHT_GRAY_CANDLE, LIGHT_GRAY_CANDLE_CAKE, LIGHT_GRAY_SHULKER_BOX, LIME_BED, LIME_CANDLE, LIME_CANDLE_CAKE, + LIME_SHULKER_BOX, LOOM, MAGENTA_BED, MAGENTA_CANDLE, MAGENTA_CANDLE_CAKE, MAGENTA_SHULKER_BOX, MANGROVE_BUTTON, MANGROVE_DOOR, MANGROVE_FENCE, MANGROVE_FENCE_GATE, + MANGROVE_HANGING_SIGN, MANGROVE_SIGN, MANGROVE_STAIRS, MANGROVE_TRAPDOOR, MANGROVE_WALL_HANGING_SIGN, MANGROVE_WALL_SIGN, MOSSY_COBBLESTONE_STAIRS, + MOSSY_STONE_BRICK_STAIRS, MOVING_PISTON, MUD_BRICK_STAIRS, NETHER_BRICK_FENCE, NETHER_BRICK_STAIRS, NOTE_BLOCK, OAK_BUTTON, OAK_DOOR, OAK_FENCE, OAK_FENCE_GATE, + OAK_HANGING_SIGN, OAK_SIGN, OAK_STAIRS, OAK_TRAPDOOR, OAK_WALL_HANGING_SIGN, OAK_WALL_SIGN, ORANGE_BED, ORANGE_CANDLE, ORANGE_CANDLE_CAKE, ORANGE_SHULKER_BOX, + OXIDIZED_CUT_COPPER_STAIRS, PINK_BED, PINK_CANDLE, PINK_CANDLE_CAKE, PINK_SHULKER_BOX, POLISHED_ANDESITE_STAIRS, POLISHED_BLACKSTONE_BRICK_STAIRS, + POLISHED_BLACKSTONE_BUTTON, POLISHED_BLACKSTONE_STAIRS, POLISHED_DEEPSLATE_STAIRS, POLISHED_DIORITE_STAIRS, POLISHED_GRANITE_STAIRS, POTTED_ACACIA_SAPLING, + POTTED_ALLIUM, POTTED_AZALEA_BUSH, POTTED_AZURE_BLUET, POTTED_BAMBOO, POTTED_BIRCH_SAPLING, POTTED_BLUE_ORCHID, POTTED_BROWN_MUSHROOM, POTTED_CACTUS, + POTTED_CHERRY_SAPLING, POTTED_CORNFLOWER, POTTED_CRIMSON_FUNGUS, POTTED_CRIMSON_ROOTS, POTTED_DANDELION, POTTED_DARK_OAK_SAPLING, POTTED_DEAD_BUSH, POTTED_FERN, + POTTED_FLOWERING_AZALEA_BUSH, POTTED_JUNGLE_SAPLING, POTTED_LILY_OF_THE_VALLEY, POTTED_MANGROVE_PROPAGULE, POTTED_OAK_SAPLING, POTTED_ORANGE_TULIP, + POTTED_OXEYE_DAISY, POTTED_PINK_TULIP, POTTED_POPPY, POTTED_RED_MUSHROOM, POTTED_RED_TULIP, POTTED_SPRUCE_SAPLING, POTTED_TORCHFLOWER, POTTED_WARPED_FUNGUS, + POTTED_WARPED_ROOTS, POTTED_WHITE_TULIP, POTTED_WITHER_ROSE, POWDER_SNOW_CAULDRON, PRISMARINE_BRICK_STAIRS, PRISMARINE_STAIRS, PUMPKIN, PURPLE_BED, PURPLE_CANDLE, + PURPLE_CANDLE_CAKE, PURPLE_SHULKER_BOX, PURPUR_STAIRS, QUARTZ_STAIRS, REDSTONE_ORE, REDSTONE_WIRE, RED_BED, RED_CANDLE, RED_CANDLE_CAKE, RED_NETHER_BRICK_STAIRS, + RED_SANDSTONE_STAIRS, RED_SHULKER_BOX, REPEATER, REPEATING_COMMAND_BLOCK, RESPAWN_ANCHOR, SANDSTONE_STAIRS, SHULKER_BOX, SMITHING_TABLE, SMOKER, + SMOOTH_QUARTZ_STAIRS, SMOOTH_RED_SANDSTONE_STAIRS, SMOOTH_SANDSTONE_STAIRS, SOUL_CAMPFIRE, SPRUCE_BUTTON, SPRUCE_DOOR, SPRUCE_FENCE, SPRUCE_FENCE_GATE, + SPRUCE_HANGING_SIGN, SPRUCE_SIGN, SPRUCE_STAIRS, SPRUCE_TRAPDOOR, SPRUCE_WALL_HANGING_SIGN, SPRUCE_WALL_SIGN, STONECUTTER, STONE_BRICK_STAIRS, STONE_BUTTON, + STONE_STAIRS, STRUCTURE_BLOCK, SWEET_BERRY_BUSH, TNT, TRAPPED_CHEST, WARPED_BUTTON, WARPED_DOOR, WARPED_FENCE, WARPED_FENCE_GATE, WARPED_HANGING_SIGN, WARPED_SIGN, + WARPED_STAIRS, WARPED_TRAPDOOR, WARPED_WALL_HANGING_SIGN, WARPED_WALL_SIGN, WATER_CAULDRON, WAXED_CUT_COPPER_STAIRS, WAXED_EXPOSED_CUT_COPPER_STAIRS, + WAXED_OXIDIZED_CUT_COPPER_STAIRS, WAXED_WEATHERED_CUT_COPPER_STAIRS, WEATHERED_CUT_COPPER_STAIRS, WHITE_BED, WHITE_CANDLE, WHITE_CANDLE_CAKE, WHITE_SHULKER_BOX, + YELLOW_BED, YELLOW_CANDLE, YELLOW_CANDLE_CAKE, YELLOW_SHULKER_BOX -> true; + default -> false; + }; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/PaginatedInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/PaginatedInventory.java new file mode 100644 index 0000000..49a9e47 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/util/inventory/PaginatedInventory.java @@ -0,0 +1,104 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.util.inventory; + +import com.cryptomorin.xseries.XSound; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public abstract class PaginatedInventory implements InventoryHandler { + + protected final Map invIndex; + protected Inventory[] inventories; + + public PaginatedInventory() { + this.invIndex = new HashMap<>(); + this.inventories = new Inventory[0]; + } + + public int getInvIndex(Player player) { + UUID playerUUID = player.getUniqueId(); + if (!invIndex.containsKey(playerUUID)) { + resetInvIndex(player); + } + return invIndex.get(playerUUID); + } + + public void resetInvIndex(Player player) { + setInvIndex(player, 0); + } + + public void setInvIndex(Player player, int index) { + invIndex.put(player.getUniqueId(), index); + } + + /** + * Attempts to go to the previous page of an inventory. + * + * @param player The player + * @param totalNumObjects The number of objects to display in total + * @param numObjectsPerPage The maximum number of objects per page + * @return {@code true} if the index was decremented (i.e., the page was changed), otherwise {@code false}. + */ + public boolean decrementInv(Player player, int totalNumObjects, int numObjectsPerPage) { + int numOfPages = calculateNumPages(totalNumObjects, numObjectsPerPage); + UUID playerUUID = player.getUniqueId(); + + int index = getInvIndex(player); + if (numOfPages > 1 && index > 0) { + invIndex.put(playerUUID, index - 1); + XSound.ENTITY_CHICKEN_EGG.play(player); + return true; + } + + XSound.ENTITY_ITEM_BREAK.play(player); + return false; + } + + /** + * Attempts to go to the next page of an inventory. + * + * @param player The player + * @param totalNumObjects The number of objects to display in total + * @param numObjectsPerPage The maximum number of objects per page + * @return {@code true} if the index was incremented (i.e., the page was changed), otherwise {@code false}. + */ + public boolean incrementInv(Player player, int totalNumObjects, int numObjectsPerPage) { + int numOfPages = calculateNumPages(totalNumObjects, numObjectsPerPage); + UUID playerUUID = player.getUniqueId(); + + int index = getInvIndex(player); + if (numOfPages > 1 && index < (numOfPages - 1)) { + invIndex.put(playerUUID, index + 1); + XSound.ENTITY_CHICKEN_EGG.play(player); + return true; + } + + XSound.ENTITY_ITEM_BREAK.play(player); + return false; + } + + public int calculateNumPages(int totalNumObjects, int numObjectsPerPage) { + return totalNumObjects == 0 ? 1 : (int) Math.ceil((double) totalNumObjects / numObjectsPerPage); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/BuildWorldImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/BuildWorldImpl.java new file mode 100644 index 0000000..1ccc9a9 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/BuildWorldImpl.java @@ -0,0 +1,362 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.creation.generator.CustomGenerator; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.util.WorldPermissions; +import de.eintosti.buildsystem.api.world.util.WorldTeleporter; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter; +import de.eintosti.buildsystem.config.Config.World.Default.Permission; +import de.eintosti.buildsystem.config.Config.World.Default.Settings.BuildersEnabled; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.world.builder.BuildersImpl; +import de.eintosti.buildsystem.world.data.WorldDataImpl; +import de.eintosti.buildsystem.world.data.WorldDataImpl.WorldDataBuilder; +import de.eintosti.buildsystem.world.util.WorldLoaderImpl; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import de.eintosti.buildsystem.world.util.WorldTeleporterImpl; +import de.eintosti.buildsystem.world.util.WorldUnloaderImpl; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; +import org.bukkit.Bukkit; +import org.bukkit.Difficulty; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public final class BuildWorldImpl implements BuildWorld { + + private final UUID uuid; + private String name; + private boolean loaded; + @Nullable + private Folder folder; + + private final BuildWorldType worldType; + private final WorldDataImpl worldData; + private final BuildersImpl builders; + @Nullable + private final CustomGenerator customGenerator; + private final long creation; + + private final WorldLoaderImpl worldLoader; + private final WorldUnloaderImpl worldUnloader; + + public BuildWorldImpl( + String name, + Builder creator, + BuildWorldType worldType, + long creation, + boolean privateWorld, + @Nullable CustomGenerator customGenerator, + @Nullable Folder folder + ) { + this( + UUID.randomUUID(), + name, + worldType, + new WorldDataBuilder(name) + .withPrivateWorld(privateWorld) + .withMaterial(privateWorld ? XMaterial.PLAYER_HEAD : BuildSystemPlugin.get().getCustomizableIcons().getIcon(worldType)) + .withPermission((privateWorld ? Permission.privatePermission : Permission.publicPermission).replace("%world%", name)) + .withBuildersEnabled(privateWorld ? BuildersEnabled.privateBuilders : BuildersEnabled.publicBuilders) + .build(), + creator, + new ArrayList<>(), + creation, + customGenerator, + folder + ); + } + + public BuildWorldImpl( + UUID uuid, + String name, + BuildWorldType worldType, + WorldDataImpl worldData, + @Nullable Builder creator, + List builders, + long creation, + @Nullable CustomGenerator customGenerator, + @Nullable Folder folder + ) { + this.uuid = uuid; + this.name = name; + this.worldType = worldType; + this.worldData = worldData; + this.builders = new BuildersImpl(creator, builders); + this.creation = creation; + this.customGenerator = customGenerator; + this.folder = folder; + + this.worldLoader = WorldLoaderImpl.of(this); + this.worldUnloader = WorldUnloaderImpl.of(this); + this.worldUnloader.manageUnload(); + } + + /** + * Gets the Bukkit {@link World} associated with this {@link BuildWorld}. + * + * @return The Bukkit world, or {@code null} if not loaded + */ + @Nullable + public World getWorld() { + return Bukkit.getWorld(name); + } + + @Override + public UUID getUniqueId() { + return this.uuid; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public void setName(String name) { + this.name = name; + this.worldData.setWorldName(name); + } + + @Override + public XMaterial getIcon() { + return this.worldData.material().get(); + } + + @Override + public void setIcon(XMaterial material) { + this.worldData.material().set(material); + } + + @Override + public String getDisplayName(Player player) { + return Messages.getString("world_item_title", player, + Map.entry("%world%", this.name) + ); + } + + @Override + public List getLore(Player player) { + @SuppressWarnings("unchecked") + Map.Entry[] placeholders = List.of( + Map.entry("%status%", Messages.getString(Messages.getMessageKey(worldData.status().get()), player)), + Map.entry("%project%", worldData.project().get()), + Map.entry("%permission%", worldData.permission().get()), + Map.entry("%creator%", builders.hasCreator() ? builders.getCreator().getName() : "-"), + Map.entry("%creation%", Messages.formatDate(getCreation())), + Map.entry("%lastedited%", Messages.formatDate(worldData.lastEdited().get())), + Map.entry("%lastloaded%", Messages.formatDate(worldData.lastLoaded().get())), + Map.entry("%lastunloaded%", Messages.formatDate(worldData.lastUnloaded().get())) + ).toArray(Map.Entry[]::new); + + List messageList = getPermissions().canPerformCommand(player, WorldsTabCompleter.WorldsArgument.EDIT.getPermission()) + ? Messages.getStringList("world_item_lore_edit", player, placeholders) + : Messages.getStringList("world_item_lore_normal", player, placeholders); + + List lore = new ArrayList<>(); + + for (String line : messageList) { + // If the current line does not contain the %builders% placeholder, add it directly + if (!line.contains("%builders%")) { + lore.add(line); + continue; + } + + List builderLines = builders.formatBuildersForLore(player, 3); + + // Replace the %builders% placeholder in the current line with the first generated builder line + lore.add(line.replace("%builders%", builderLines.getFirst())); + + // Add any further builder lines as new separate lore entries + // Start from the second element (index 1) of builderLines + for (int i = 1; i < builderLines.size(); i++) { + lore.add(builderLines.get(i)); + } + } + + return lore; + } + + @Override + public void addToInventory(Inventory inventory, int slot, Player player) { + if (getIcon() == XMaterial.PLAYER_HEAD) { + InventoryUtils.addWorldItem(inventory, slot, this, getDisplayName(player), getLore(player)); + return; + } + BuildWorld.super.addToInventory(inventory, slot, player); + } + + @Override + public Profileable asProfilable() { + return builders.hasCreator() + ? Profileable.of(builders.getCreator().getUniqueId()) + : Profileable.username(name); + } + + @Override + public BuildWorldType getType() { + return worldType; + } + + @Override + public WorldData getData() { + return worldData; + } + + @Override + public long getCreation() { + return creation; + } + + @Override + @Nullable + public CustomGenerator getCustomGenerator() { + return customGenerator; + } + + @Override + public Difficulty cycleDifficulty() { + Difficulty newDifficulty = switch (worldData.difficulty().get()) { + case PEACEFUL -> Difficulty.EASY; + case EASY -> Difficulty.NORMAL; + case NORMAL -> Difficulty.HARD; + case HARD -> Difficulty.PEACEFUL; + }; + worldData.difficulty().set(newDifficulty); + return newDifficulty; + } + + @Override + public Builders getBuilders() { + return builders; + } + + @Override + public String getWorldTime() { + World bukkitWorld = getWorld(); + if (bukkitWorld == null) { + return "?"; + } + return String.valueOf(bukkitWorld.getTime()); + } + + @Override + public boolean isLoaded() { + return loaded; + } + + @Override + public void setLoaded(boolean loaded) { + this.loaded = loaded; + } + + @Override + public WorldLoaderImpl getLoader() { + return worldLoader; + } + + @Override + public WorldUnloaderImpl getUnloader() { + return worldUnloader; + } + + @Override + public WorldTeleporter getTeleporter() { + return WorldTeleporterImpl.of(this); + } + + @Override + public WorldPermissions getPermissions() { + return WorldPermissionsImpl.of(this); + } + + @Override + @Nullable + public Folder getFolder() { + return folder; + } + + @Override + public boolean isAssignedToFolder() { + return getFolder() != null; + } + + @Override + public void setFolder(@Nullable Folder folder) { + if (Objects.equals(this.folder, folder)) { + // No change in folder assignment, do nothing + return; + } + + Folder oldFolder = this.folder; + + // World is not assigned to any folder, assign it to the new folder + if (this.folder == null) { + this.folder = folder; + folder.addWorld(this); + return; + } + + // World is currently assigned to a folder, remove it from the old folder + if (folder == null) { + this.folder = null; + oldFolder.removeWorld(this); + return; + } + + // World is being moved to a new folder, remove it from the old folder and assign it to the new one + this.folder = folder; + oldFolder.removeWorld(this); + folder.addWorld(this); + } + + @Override + public boolean equals(Object o) { + if (this == o) { + return true; + } + if (o == null || getClass() != o.getClass()) { + return false; + } + BuildWorldImpl that = (BuildWorldImpl) o; + return name.equals(that.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/SpawnManager.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/SpawnManager.java new file mode 100644 index 0000000..ac50c21 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/SpawnManager.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.storage.yaml.YamlSpawnStorage; +import io.papermc.lib.PaperLib; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SpawnManager { + + private final BuildSystemPlugin plugin; + private final WorldStorage worldStorage; + private final YamlSpawnStorage spawnStorage; + + @Nullable + private String spawnName; + @Nullable + private Location spawn; + + public SpawnManager(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + this.spawnStorage = new YamlSpawnStorage(plugin); + load(); + } + + public boolean teleport(Player player) { + if (spawn == null || spawnName == null) { + return false; + } + + BuildWorld buildWorld = worldStorage.getBuildWorld(spawnName); + if (buildWorld != null) { + if (!buildWorld.isLoaded()) { + buildWorld.getLoader().loadForPlayer(player); + } + } + + player.setFallDistance(0); + PaperLib.teleportAsync(player, spawn) + .whenComplete((completed, throwable) -> { + if (!completed) { + return; + } + XSound.ENTITY_ZOMBIE_INFECT.play(player); + player.resetTitle(); + }); + return true; + } + + public boolean spawnExists() { + return spawn != null; + } + + @Nullable + public Location getSpawn() { + return spawn; + } + + @Nullable + public World getSpawnWorld() { + if (this.spawn == null) { + return null; + } + return spawn.getWorld(); + } + + public void set(Location location, String worldName) { + this.spawn = location; + this.spawnName = worldName; + } + + public void remove() { + this.spawn = null; + } + + public void save() { + spawnStorage.saveSpawn(spawn); + } + + private void load() { + FileConfiguration configuration = spawnStorage.getFile(); + String string = configuration.getString("spawn"); + if (string == null || string.trim().isEmpty()) { + return; + } + + String[] parts = string.split(":"); + if (parts.length != 6) { + return; + } + + String worldName = parts[0]; + double x = Double.parseDouble(parts[1]); + double y = Double.parseDouble(parts[2]); + double z = Double.parseDouble(parts[3]); + float yaw = Float.parseFloat(parts[4]); + float pitch = Float.parseFloat(parts[5]); + + BuildWorld buildWorld = worldStorage.getBuildWorld(worldName); + if (buildWorld == null) { + plugin.getLogger().warning("Could load spawn world \"" + worldName + "\". Please check logs for possible errors."); + return; + } + + buildWorld.getLoader().load(); + this.spawnName = worldName; + this.spawn = new Location(Bukkit.getWorld(worldName), x, y, z, yaw, pitch); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/WorldServiceImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/WorldServiceImpl.java new file mode 100644 index 0000000..a6ab4bc --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/WorldServiceImpl.java @@ -0,0 +1,431 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.exception.WorldDeletionException; +import de.eintosti.buildsystem.api.exception.WorldDirectoryNotFoundException; +import de.eintosti.buildsystem.api.exception.WorldNotFoundException; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.WorldService; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.creation.generator.CustomGenerator; +import de.eintosti.buildsystem.api.world.creation.generator.Generator; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.storage.FolderStorageImpl; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.storage.factory.FolderStorageFactory; +import de.eintosti.buildsystem.storage.factory.WorldStorageFactory; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.util.StringCleaner; +import de.eintosti.buildsystem.world.creation.BuildWorldCreatorImpl; +import de.eintosti.buildsystem.world.creation.generator.CustomGeneratorImpl; +import io.papermc.lib.PaperLib; +import java.io.File; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitRunnable; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class WorldServiceImpl implements WorldService { + + private static boolean importingAllWorlds = false; + + private final BuildSystemPlugin plugin; + private final FolderStorageImpl folderStorage; + private final WorldStorageImpl worldStorage; + + public WorldServiceImpl(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.folderStorage = new FolderStorageFactory(plugin).createStorage(this); + this.worldStorage = new WorldStorageFactory(plugin).createStorage(this); + } + + public void init() { + this.folderStorage.loadFolders(); + this.worldStorage.loadWorlds(); + } + + @Override + public FolderStorageImpl getFolderStorage() { + return folderStorage; + } + + @Override + public WorldStorageImpl getWorldStorage() { + return worldStorage; + } + + @Override + @Contract("_ -> new") + public BuildWorldCreatorImpl createWorld(String name) { + return new BuildWorldCreatorImpl(plugin, name); + } + + public void startWorldNameInput(Player player, BuildWorldType worldType, @Nullable String template, boolean privateWorld, @Nullable Folder folder) { + player.closeInventory(); + new PlayerChatInput(plugin, player, "enter_world_name", input -> { + if (StringCleaner.hasInvalidNameCharacters(input)) { + Messages.sendMessage(player, "worlds_world_creation_invalid_characters"); + } + + String worldName = StringCleaner.sanitize(input); + if (worldName.isEmpty()) { + Messages.sendMessage(player, "worlds_world_creation_name_bank"); + return; + } + + if (worldType == BuildWorldType.CUSTOM) { + startCustomGeneratorInput(player, worldName, template, privateWorld, folder); + } else { + createWorld(worldName) + .setType(worldType) + .setTemplate(template) + .setPrivate(privateWorld) + .setFolder(folder) + .createWorld(player); + } + }); + } + + private void startCustomGeneratorInput(Player player, String worldName, @Nullable String template, boolean privateWorld, @Nullable Folder folder) { + new PlayerChatInput(plugin, player, "enter_generator_name", input -> { + CustomGenerator customGenerator = CustomGeneratorImpl.of(input, worldName); + if (customGenerator == null) { + Messages.sendMessage(player, "worlds_import_unknown_generator"); + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + + createWorld(worldName) + .setType(BuildWorldType.CUSTOM) + .setTemplate(template) + .setPrivate(privateWorld) + .setCustomGenerator(customGenerator) + .setFolder(folder) + .createWorld(player); + }); + } + + public boolean importWorld(Player player, String worldName, @Nullable Builder creator, BuildWorldType worldType, Generator generator, String generatorData, boolean single) { + CustomGenerator customGenerator = null; + if (generator == Generator.CUSTOM) { + customGenerator = CustomGeneratorImpl.of(generatorData, worldName); + if (customGenerator == null) { + Messages.sendMessage(player, "worlds_import_unknown_generator"); + return false; + } + } + + BuildWorldCreatorImpl worldCreator = createWorld(worldName) + .setType(worldType) + .setCreator(creator) + .setCustomGenerator(customGenerator != null ? customGenerator : new CustomGeneratorImpl("BuildSystem", generatorData, null)) + .setPrivate(false) + .setCreationDate(FileUtils.getDirectoryCreation(new File(Bukkit.getWorldContainer(), worldName))); + + if (worldCreator.isDataVersionTooHigh()) { + String key = single ? "import" : "importall"; + Messages.sendMessage(player, "worlds_" + key + "_newer_version", + Map.entry("%world%", worldName) + ); + return false; + } + + worldCreator.importWorld(player, single); + return true; + } + + public void importWorlds(Player player, String[] worldList, Generator generator, @Nullable Builder creator) { + int worlds = worldList.length; + int delay = Config.World.importAllDelay; + + Messages.sendMessage(player, "worlds_importall_started", + Map.entry("%amount%", String.valueOf(worlds)) + ); + Messages.sendMessage(player, "worlds_importall_delay", + Map.entry("%delay%", String.valueOf(delay)) + ); + importingAllWorlds = true; + + AtomicInteger worldsImported = new AtomicInteger(0); + new BukkitRunnable() { + @Override + public void run() { + int i = worldsImported.getAndIncrement(); + if (i >= worlds) { + this.cancel(); + importingAllWorlds = false; + Messages.sendMessage(player, "worlds_importall_finished"); + return; + } + + String worldName = worldList[i]; + if (worldStorage.worldExists(worldName)) { + Messages.sendMessage(player, "worlds_importall_world_already_imported", + Map.entry("%world%", worldName) + ); + return; + } + + String invalidChar = StringCleaner.firstInvalidChar(worldName); + if (invalidChar != null) { + Messages.sendMessage(player, "worlds_importall_invalid_character", + Map.entry("%world%", worldName), + Map.entry("%char%", invalidChar) + ); + return; + } + + if (importWorld(player, worldName, creator, BuildWorldType.IMPORTED, generator, "void", false)) { + Messages.sendMessage(player, "worlds_importall_world_imported", Map.entry("%world%", worldName)); + } + } + }.runTaskTimer(plugin, 0, 20L * delay); + } + + public boolean isImportingAllWorlds() { + return importingAllWorlds; + } + + @Override + public CompletableFuture unimportWorld(BuildWorld buildWorld, boolean save) { + buildWorld.getUnloader().forceUnload(save); + this.worldStorage.removeBuildWorld(buildWorld); + removePlayersFromWorld(buildWorld.getName(), "worlds_unimport_players_world"); + return this.worldStorage.delete(buildWorld); + } + + public void deleteWorld(Player player, BuildWorld buildWorld) { + String worldName = buildWorld.getName(); + Messages.sendMessage(player, "worlds_delete_started", Map.entry("%world%", worldName)); + deleteWorld(buildWorld) + .thenRun(() -> Messages.sendMessage(player, "worlds_delete_finished")) + .exceptionally(e -> { + Throwable cause = e.getCause() != null ? e.getCause() : e; + switch (cause) { + case WorldNotFoundException ignored -> Messages.sendMessage(player, "worlds_delete_unknown_world"); + case WorldDirectoryNotFoundException ignored -> Messages.sendMessage(player, "worlds_delete_unknown_directory"); + default -> { + Messages.sendMessage(player, "worlds_delete_error", Map.entry("%world%", worldName)); + plugin.getLogger().log(Level.SEVERE, "An unexpected error occurred while deleting the world: " + worldName, cause); + } + } + return null; + }); + } + + @Override + public CompletableFuture deleteWorld(BuildWorld buildWorld) { + String worldName = buildWorld.getName(); + if (!this.worldStorage.worldExists(worldName)) { + return CompletableFuture.failedFuture(new WorldNotFoundException(worldName)); + } + + File deleteFolder = new File(Bukkit.getWorldContainer(), worldName); + if (!deleteFolder.exists()) { + return CompletableFuture.failedFuture(new WorldDirectoryNotFoundException(worldName, deleteFolder.getAbsolutePath())); + } + + buildWorld.setFolder(null); + removePlayersFromWorld(worldName, "worlds_delete_players_world"); + + return CompletableFuture.allOf( + unimportWorld(buildWorld, false), + CompletableFuture.runAsync(() -> { + try { + FileUtils.deleteDirectory(deleteFolder); + } catch (IOException e) { + throw new CompletionException(new WorldDeletionException("An unexpected error occurred during directory deletion for world: " + worldName, e)); + } + }) + ); + } + + /** + * Change the name of a {@link BuildWorld} to a given name. + * + * @param player The player who issued the world renaming + * @param buildWorld The build world object + * @param newName The name the world should be renamed to + */ + public void renameWorld(Player player, BuildWorld buildWorld, String newName) { + player.closeInventory(); + if (worldStorage.worldAndFolderExist(newName)) { + Messages.sendMessage(player, "worlds_world_exists"); + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + + String oldName = buildWorld.getName(); + if (oldName.equalsIgnoreCase(newName)) { + Messages.sendMessage(player, "worlds_rename_same_name"); + return; + } + + if (StringCleaner.hasInvalidNameCharacters(newName)) { + Messages.sendMessage(player, "worlds_world_creation_invalid_characters"); + } + String sanitizedNewName = StringCleaner.sanitize(newName); + if (sanitizedNewName.isEmpty()) { + Messages.sendMessage(player, "worlds_world_creation_name_bank"); + return; + } + + if (Bukkit.getWorld(oldName) == null && !buildWorld.isLoaded()) { + buildWorld.getLoader().load(); + } + + World oldWorld = Bukkit.getWorld(oldName); + if (oldWorld == null) { + Messages.sendMessage(player, "worlds_rename_unknown_world"); + return; + } + + List<@Nullable Player> removedPlayers = removePlayersFromWorld(oldName, "worlds_rename_players_world"); + for (Chunk chunk : oldWorld.getLoadedChunks()) { + chunk.unload(true); + } + Bukkit.unloadWorld(oldWorld, true); + Bukkit.getWorlds().remove(oldWorld); + this.worldStorage.removeBuildWorld(buildWorld); + Bukkit.getScheduler().runTaskAsynchronously(plugin, () -> this.worldStorage.delete(oldName)); + + File oldWorldFile = new File(Bukkit.getWorldContainer(), oldName); + File newWorldFile = new File(Bukkit.getWorldContainer(), sanitizedNewName); + CompletableFuture.runAsync(() -> { + try { + worldStorage.delete(oldName).join(); + FileUtils.copy(oldWorldFile, newWorldFile); + FileUtils.deleteDirectory(oldWorldFile); + } catch (Exception e) { + throw new RuntimeException("Failed to rename world directory", e); + } + }).thenRunAsync(() -> { + buildWorld.setName(sanitizedNewName); + worldStorage.addBuildWorld(buildWorld); + worldStorage.save(buildWorld); + }).thenRun(() -> Bukkit.getScheduler().runTask(plugin, () -> { + World newWorld = new BuildWorldCreatorImpl(plugin, buildWorld).generateBukkitWorld(false); + Location spawnLocation = oldWorld.getSpawnLocation(); + spawnLocation.setWorld(newWorld); + + removedPlayers.stream() + .filter(Objects::nonNull) + .forEach(pl -> PaperLib.teleportAsync(pl, spawnLocation.clone().add(0.5, 0, 0.5))); + + SpawnManager spawnManager = plugin.getSpawnManager(); + Location oldSpawn = spawnManager.getSpawn(); + if (oldSpawn != null && Objects.equals(spawnManager.getSpawnWorld(), oldWorld)) { + Location newSpawn = new Location( + newWorld, + oldSpawn.getX(), + oldSpawn.getY(), + oldSpawn.getZ(), + oldSpawn.getYaw(), + oldSpawn.getPitch() + ); + spawnManager.set(newSpawn, sanitizedNewName); + } + + Messages.sendMessage(player, "worlds_rename_set", + Map.entry("%oldName%", oldName), + Map.entry("%newName%", sanitizedNewName) + ); + })); + } + + public List removePlayersFromWorld(String worldName, String messageKey) { + World worldToRemove = Bukkit.getWorld(worldName); + if (worldToRemove == null) { + return List.of(); + } + + World fallbackWorld = Bukkit.getWorlds().getFirst(); + Location fallbackSpawn = fallbackWorld.getHighestBlockAt(fallbackWorld.getSpawnLocation()).getLocation().add(0.5, 1, 0.5); + + SpawnManager spawnManager = plugin.getSpawnManager(); + List affectedPlayers = new ArrayList<>(); + + Bukkit.getOnlinePlayers().forEach(player -> { + if (!player.getWorld().equals(worldToRemove)) { + return; + } + + boolean teleported = false; + + if (spawnManager.spawnExists()) { + // Spawn exists -> always teleport to it + spawnManager.teleport(player); + teleported = true; + } else if (!fallbackWorld.equals(worldToRemove)) { + // Spawn doesn't exist, the fallback world is usable -> teleport + player.teleport(fallbackSpawn); + teleported = true; + } + + if (!teleported) { + // No valid spawn and fallback world is the one being deleted -> kick + spawnManager.remove(); + player.kickPlayer(Messages.getString(messageKey, player)); + return; + } + + Messages.sendMessage(player, messageKey); + affectedPlayers.add(player); + }); + + return affectedPlayers; + } + + public void save() { + this.worldStorage + .save(this.worldStorage.getBuildWorlds()) + .exceptionally(e -> { + plugin.getLogger().log(Level.SEVERE, "Failed to save world data", e); + throw new CompletionException("Failed to save world data", e); + }); + + this.folderStorage + .save(this.folderStorage.getFolders()) + .exceptionally(e -> { + plugin.getLogger().log(Level.SEVERE, "Failed to save folder data", e); + throw new CompletionException("Failed to save folder data", e); + }); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupImpl.java new file mode 100644 index 0000000..74c30ae --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupImpl.java @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.backup; + +import de.eintosti.buildsystem.api.world.backup.Backup; +import de.eintosti.buildsystem.api.world.backup.BackupProfile; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public record BackupImpl(BackupProfile owner, long creationTime, String key) implements Backup { + +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java new file mode 100644 index 0000000..f72f0c6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupProfileImpl.java @@ -0,0 +1,151 @@ +/* + * Copyright (c) 2023-2025, Thomas Meaney + * All rights reserved. + * + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + */ +package de.eintosti.buildsystem.world.backup; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.backup.Backup; +import de.eintosti.buildsystem.api.world.backup.BackupProfile; +import de.eintosti.buildsystem.api.world.backup.BackupStorage; +import de.eintosti.buildsystem.api.world.util.WorldTeleporter; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.util.StringUtils; +import de.eintosti.buildsystem.world.SpawnManager; +import java.io.File; +import java.io.IOException; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import java.util.logging.Level; +import net.lingala.zip4j.ZipFile; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class BackupProfileImpl implements BackupProfile { + + private final BuildSystemPlugin plugin; + private final BackupStorage storage; + private final BuildWorld buildWorld; + protected final Object backupLock; + + public BackupProfileImpl(BuildSystemPlugin plugin, BackupStorage storage, BuildWorld buildWorld) { + this.plugin = plugin; + this.storage = storage; + this.buildWorld = buildWorld; + this.backupLock = new Object(); + } + + @Override + public CompletableFuture> listBackups() { + synchronized (this.backupLock) { + return this.storage.listBackups(this.buildWorld); + } + } + + @Override + public CompletableFuture createBackup() { + World world = this.buildWorld.getWorld(); + if (world != null) { + world.save(); + } + + CompletableFuture resultFuture = new CompletableFuture<>(); + this.listBackups() + .thenComposeAsync(backups -> { + synchronized (this.backupLock) { + int maxBackups = Config.World.Backup.maxBackupsPerWorld; + int excess = backups.size() - maxBackups + 1; + + List> deleteFutures = Collections.emptyList(); + + if (excess > 0) { + deleteFutures = backups.stream() + .sorted(Comparator.comparingLong(Backup::creationTime)) + .limit(excess) + .map(storage::deleteBackup) + .toList(); + } + + return CompletableFuture + .allOf(deleteFutures.toArray(new CompletableFuture[0])) + .thenCompose(v -> storage.storeBackup(this.buildWorld)); + } + }) + .whenComplete((backup, throwable) -> { + if (throwable != null) { + resultFuture.completeExceptionally(throwable); + } else { + resultFuture.complete(backup); + } + }); + + return resultFuture; + } + + @Override + public void restoreBackup(Backup backup, Player player) { + String worldName = this.buildWorld.getName(); + World world = this.buildWorld.getWorld(); + if (world == null) { + Messages.sendMessage(player, "worlds_backup_unknown_world"); + return; + } + + List<@Nullable Player> removedPlayers = plugin.getWorldService().removePlayersFromWorld(worldName, "worlds_backup_restoration_in_progress"); + + File backupFile; + try { + backupFile = this.storage.downloadBackup(backup).get(); + } catch (InterruptedException | ExecutionException e) { + plugin.getLogger().log(Level.SEVERE, "Error while downloading backup", e); + return; + } + + SpawnManager spawnManager = plugin.getSpawnManager(); + Location spawn = spawnManager.getSpawn(); + boolean isSpawn = spawnManager.spawnExists() && spawnManager.getSpawnWorld().equals(world); + + this.buildWorld.getUnloader().forceUnload(false); + try { + FileUtils.deleteDirectory(new File(Bukkit.getWorldContainer(), worldName)); + } catch (IOException e) { + plugin.getLogger().log(Level.SEVERE, "Error while deleting backup file", e); + } + + try (ZipFile zip = new ZipFile(backupFile)) { + zip.extractAll(FileUtils.resolve(Bukkit.getWorldContainer(), this.buildWorld.getName()).toString()); + } catch (IOException e) { + plugin.getLogger().warning("Failed to restore backup at: " + backupFile.getAbsolutePath()); + return; + } + + this.buildWorld.getLoader().load(); + WorldTeleporter worldTeleporter = this.buildWorld.getTeleporter(); + removedPlayers.stream().filter(Objects::nonNull).forEach(worldTeleporter::teleport); + + if (isSpawn) { + spawn.setWorld(Bukkit.getWorld(worldName)); + spawnManager.set(spawn, worldName); + } + + Messages.sendMessage(player, "worlds_backup_restoration_successful", + Map.entry("%timestamp%", StringUtils.formatTime(backup.creationTime())) + ); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupService.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupService.java new file mode 100644 index 0000000..8257c11 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupService.java @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2023-2025, Thomas Meaney + * All rights reserved. + * + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + */ +package de.eintosti.buildsystem.world.backup; + +import com.google.common.cache.Cache; +import com.google.common.cache.CacheBuilder; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.data.Type; +import de.eintosti.buildsystem.api.storage.WorldStorage; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.backup.BackupProfile; +import de.eintosti.buildsystem.api.world.backup.BackupStorage; +import de.eintosti.buildsystem.config.Config.World.Backup; +import de.eintosti.buildsystem.config.Config.World.Backup.AutoBackup; +import java.time.Duration; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BackupService { + + public static final Executor BACKUP_EXECUTOR = Executors.newCachedThreadPool(); + private static final long UPDATE_PERIOD = Duration.ofSeconds(5).getSeconds(); + + private final BuildSystemPlugin plugin; + private final BackupStorage backupStorage; + private final WorldStorage worldStorage; + + private final Cache backupProfileCache = CacheBuilder.newBuilder().expireAfterAccess(3, TimeUnit.MINUTES).build(); + + public BackupService(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.backupStorage = Backup.storage; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + + if (AutoBackup.enabled) { + Bukkit.getScheduler().runTaskTimer(plugin, this::incrementTimeSinceBackup, UPDATE_PERIOD * 20, UPDATE_PERIOD * 20); + } + } + + public BackupStorage getStorage() { + return this.backupStorage; + } + + /** + * Increments the time since a {@link BuildWorld} was backed-up by {@link #UPDATE_PERIOD} seconds. If the time has surpassed {@link AutoBackup#interval}, a backup will + * automatically be created. + */ + private void incrementTimeSinceBackup() { + Set worlds = new HashSet<>(); + + if (AutoBackup.onlyActiveWorlds) { + for (Player pl : Bukkit.getOnlinePlayers()) { + BuildWorld buildWorld = worldStorage.getBuildWorld(pl.getWorld().getName()); + if (buildWorld != null && buildWorld.getPermissions().canModify(pl, Type.TRUE)) { + worlds.add(buildWorld); + } + } + } else { + worlds.addAll(worldStorage.getBuildWorlds()); + } + + worlds.forEach(buildWorld -> { + Type timeSinceBackup = buildWorld.getData().timeSinceBackup(); + timeSinceBackup.set((int) (timeSinceBackup.get() + UPDATE_PERIOD)); + + if (timeSinceBackup.get() > AutoBackup.interval) { + getProfile(buildWorld).createBackup(); + timeSinceBackup.set(0); + } + }); + } + + /** + * Performs a backup of the {@link BuildWorld}. + * + * @param buildWorld The world to create a backup of + * @param onSuccess Action to run if backup was successful + * @param onFailure Action to run if backup failed + */ + public void backup(BuildWorld buildWorld, Runnable onSuccess, Runnable onFailure) { + getProfile(buildWorld).createBackup().whenComplete((backup, throwable) -> { + if (throwable != null) { + plugin.getLogger().log(Level.SEVERE, "Backup failed", throwable); + Bukkit.getScheduler().runTask(plugin, onFailure); + } else { + Bukkit.getScheduler().runTask(plugin, onSuccess); + } + }); + } + + /** + * Gets the backup profile for a {@link BuildWorld}. + * + * @param buildWorld The world to get the backup profile for + * @return The backup profile + */ + public BackupProfile getProfile(BuildWorld buildWorld) { + try { + return this.backupProfileCache.get( + buildWorld.getUniqueId(), + () -> new BackupProfileImpl(this.plugin, this.backupStorage, buildWorld) + ); + } catch (ExecutionException e) { + BackupProfileImpl profile = new BackupProfileImpl(this.plugin, this.backupStorage, buildWorld); + this.backupProfileCache.put(buildWorld.getUniqueId(), profile); + return profile; + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupsInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupsInventory.java new file mode 100644 index 0000000..895222a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/BackupsInventory.java @@ -0,0 +1,214 @@ +/* + * Copyright (c) 2023-2025, Thomas Meaney + * All rights reserved. + * + * Unauthorized copying of this file, via any medium is strictly prohibited + * Proprietary and confidential + */ +package de.eintosti.buildsystem.world.backup; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.backup.Backup; +import de.eintosti.buildsystem.config.Config.World.Backup.AutoBackup; +import de.eintosti.buildsystem.util.StringUtils; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.BuildWorldHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import java.text.DateFormat; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Map; +import org.bukkit.Material; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BackupsInventory implements InventoryHandler { + + private static final int FIRST_BACKUP_SLOT = 9; + + private final InventoryManager inventoryManager; + private final BackupService backupService; + + public BackupsInventory(BuildSystemPlugin plugin) { + this.inventoryManager = plugin.getInventoryManager(); + this.backupService = plugin.getBackupService(); + } + + public void openBackupsInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = getBackupsInventory(player, buildWorld); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getBackupsInventory(Player player, BuildWorld buildWorld) { + BackupsHolder backupsHolder = new BackupsHolder(buildWorld, player, new ArrayList<>()); + Inventory inventory = backupsHolder.getInventory(); + loadBackups(backupsHolder, buildWorld, player); + + for (int i = 0; i <= 8; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + + inventory.setItem(4, InventoryUtils.createItem(XMaterial.OAK_HANGING_SIGN, + Messages.getString("backups_information_name", player), + Messages.getStringList("backups_information_lore", player, + Map.entry("%interval%", AutoBackup.interval / 60), + Map.entry("%remaining%", getDurationUntilBackup(buildWorld)) + ) + )); + + for (int i = 27; i <= 35; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + + return inventory; + } + + /** + * Loads the {@link Backup} for a given world and adds them to the inventory on completion. + * + * @param backupsHolder The holder to populate + * @param buildWorld The world to load backups for + * @param player The player to display the backups to + */ + private void loadBackups(BackupsHolder backupsHolder, BuildWorld buildWorld, Player player) { + backupService.getProfile(buildWorld).listBackups().thenAcceptAsync(backups -> { + backupsHolder.getBackups().addAll(backups); + + Inventory inventory = backupsHolder.getInventory(); + for (int i = 0; i < backups.size(); i++) { + inventory.setItem(FIRST_BACKUP_SLOT + i, InventoryUtils.createItem(XMaterial.GRASS_BLOCK, + Messages.getString("backups_backup_name", player, + Map.entry("%timestamp%", StringUtils.formatTime(backups.get(i).creationTime())) + ) + )); + } + }); + } + + /** + * Gets the duration until the next backup is due as a string formatted in {@code mm:ss}. + * + * @param buildWorld The world to get the backup duration for + * @return A string representing the duration until the next backup + */ + private String getDurationUntilBackup(BuildWorld buildWorld) { + int timeUntilBackup = AutoBackup.interval; + int timeSinceBackup = buildWorld.getData().timeSinceBackup().get(); + + Date date = new Date((timeUntilBackup - timeSinceBackup) * 1000L); + DateFormat formatter = new SimpleDateFormat("mm:ss"); + return formatter.format(date); + } + + public void openConfirmationInventory(Player player, Backup backup) { + Inventory inventory = getConfirmationInventory(player, backup); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getConfirmationInventory(Player player, Backup backup) { + Inventory inventory = new ConfirmationHolder(backup, player).getInventory(); + + for (int slot : new int[]{0, 1, 2, 3, 9, 10, 12, 18, 19, 20, 21}) { + inventory.setItem(slot, InventoryUtils.createItem(XMaterial.LIME_STAINED_GLASS_PANE, "§a")); + } + for (int slot : new int[]{4, 13, 22}) { + inventory.setItem(slot, InventoryUtils.createItem(XMaterial.BLACK_STAINED_GLASS_PANE, "§0")); + } + for (int slot : new int[]{5, 6, 7, 8, 14, 16, 17, 23, 24, 25, 26}) { + inventory.setItem(slot, InventoryUtils.createItem(XMaterial.RED_STAINED_GLASS_PANE, "§c")); + } + + inventory.setItem(11, InventoryUtils.createItem(XMaterial.LIME_DYE, + Messages.getString("restore_backup_confirm_name", player), + Messages.getStringList("restore_backup_confirm_lore", player, + Map.entry("%timestamp%", StringUtils.formatTime(backup.creationTime())) + ) + )); + inventory.setItem(15, InventoryUtils.createItem(XMaterial.RED_DYE, + Messages.getString("restore_backup_cancel_name", player) + )); + return inventory; + } + + @Override + public void onClick(InventoryClickEvent event) { + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null || itemStack.getType() == Material.AIR || !itemStack.hasItemMeta()) { + return; + } + + Player player = (Player) event.getWhoClicked(); + Inventory inventory = event.getInventory(); + InventoryHolder holder = inventory.getHolder(); + if (holder instanceof BackupsHolder backupsHolder) { + event.setCancelled(true); + + int slot = event.getSlot(); + if (slot >= FIRST_BACKUP_SLOT && slot < FIRST_BACKUP_SLOT + 18 && itemStack.getType() == XMaterial.GRASS_BLOCK.get()) { + Backup backup = backupsHolder.getBackups().get(slot - FIRST_BACKUP_SLOT); + player.closeInventory(); + openConfirmationInventory(player, backup); + } + } + + if (holder instanceof ConfirmationHolder confirmationHolder) { + event.setCancelled(true); + + switch (event.getSlot()) { + case 11 -> { + player.closeInventory(); + player.playSound(player, Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + Backup backup = confirmationHolder.getBackup(); + backup.owner().restoreBackup(backup, player); + } + case 15 -> { + player.closeInventory(); + player.playSound(player, Sound.ENTITY_ZOMBIE_BREAK_WOODEN_DOOR, 1f, 1f); + } + } + } + } + + private static class BackupsHolder extends BuildWorldHolder { + + private final List backups; + + public BackupsHolder(BuildWorld buildWorld, Player player, List backups) { + super(buildWorld, 36, Messages.getString("backups_title", player)); + this.backups = backups; + } + + public List getBackups() { + return backups; + } + } + + private static class ConfirmationHolder extends BuildSystemHolder { + + private final Backup backup; + + public ConfirmationHolder(Backup backup, Player player) { + super(27, Messages.getString("restore_backup_title", player)); + this.backup = backup; + } + + public Backup getBackup() { + return backup; + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/LocalBackupStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/LocalBackupStorage.java new file mode 100644 index 0000000..47fa933 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/LocalBackupStorage.java @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.backup.storage; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.backup.Backup; +import de.eintosti.buildsystem.api.world.backup.BackupStorage; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.world.backup.BackupImpl; +import de.eintosti.buildsystem.world.backup.BackupService; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import java.util.stream.Stream; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class LocalBackupStorage implements BackupStorage { + + private final BuildSystemPlugin plugin; + private final Path backupPath; + + public LocalBackupStorage(BuildSystemPlugin plugin) { + this.plugin = plugin; + + this.backupPath = plugin.getDataFolder().toPath().resolve("backups"); + if (!Files.exists(backupPath)) { + try { + Files.createDirectory(backupPath); + } catch (IOException e) { + plugin.getLogger().log(Level.SEVERE, "Unable to create backup folder", e); + } + } + } + + @Override + public CompletableFuture> listBackups(BuildWorld buildWorld) { + return CompletableFuture.supplyAsync(() -> { + List backups = new ArrayList<>(Config.World.Backup.maxBackupsPerWorld); + + try (Stream walk = Files.walk(getBackupDirectory(buildWorld))) { + walk.filter(LocalBackupStorage::isValidFile).forEach(path -> backups.add( + new BackupImpl( + plugin.getBackupService().getProfile(buildWorld), + FileUtils.getDirectoryCreation(path.toFile()), + path.toAbsolutePath().toString() + ) + )); + } catch (IOException e) { + plugin.getLogger().log(Level.SEVERE, "Error while listing backups", e); + return Collections.emptyList(); + } + + backups.sort(Comparator.comparingLong(Backup::creationTime).reversed()); + return backups; + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public CompletableFuture storeBackup(BuildWorld buildWorld) { + return CompletableFuture.supplyAsync(() -> { + long timestamp = System.currentTimeMillis(); + File storage = new File(getBackupDirectory(buildWorld).toFile(), getBackupName(timestamp)); + File zip = FileUtils.zipWorld(storage, buildWorld); + if (zip == null) { + throw new RuntimeException("Failed to complete the backup for " + buildWorld.getName()); + } + + plugin.getLogger().info("Backed up world '%s'. Took %sms".formatted(buildWorld.getName(), (System.currentTimeMillis() - timestamp))); + return new BackupImpl( + plugin.getBackupService().getProfile(buildWorld), + timestamp, + zip.getAbsolutePath() + ); + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public CompletableFuture downloadBackup(Backup backup) { + return CompletableFuture.completedFuture(new File(backup.key())); + } + + @Override + public CompletableFuture deleteBackup(Backup backup) { + return CompletableFuture.runAsync(() -> { + File file = new File(backup.key()); + try { + Files.deleteIfExists(file.toPath()); + } catch (IOException e) { + throw new RuntimeException("Unable to delete backup " + backup.key(), e); + } + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public void close() { + // Nothing to do for local storage + } + + /** + * Gets the directory containing the backups for this profile. This directory may not exist. + * + * @return Folder that contains the backups for this profile + */ + public Path getBackupDirectory(BuildWorld buildWorld) { + return FileUtils.resolve(this.backupPath, buildWorld.getUniqueId().toString()); + } + + private static boolean isValidFile(Path path) { + return path.getFileName().toString().endsWith(".zip"); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/S3BackupStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/S3BackupStorage.java new file mode 100644 index 0000000..40af797 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/S3BackupStorage.java @@ -0,0 +1,198 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.backup.storage; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.backup.Backup; +import de.eintosti.buildsystem.api.world.backup.BackupStorage; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.world.backup.BackupImpl; +import de.eintosti.buildsystem.world.backup.BackupService; +import java.io.File; +import java.io.IOException; +import java.net.URI; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Request; +import software.amazon.awssdk.services.s3.model.ListObjectsV2Response; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@NullMarked +public class S3BackupStorage implements BackupStorage { + + private final BuildSystemPlugin plugin; + + private final S3Client s3Client; + private final String bucket; + private final String pathPrefix; + private final Path tmpDownloadDirectory; + + public S3BackupStorage(BuildSystemPlugin plugin, @Nullable String url, String accessKey, String secretKey, String region, String bucket, String pathPrefix) { + this.plugin = plugin; + + this.bucket = bucket; + this.pathPrefix = pathPrefix.endsWith("/") ? pathPrefix : pathPrefix + "/"; + this.tmpDownloadDirectory = FileUtils.resolve(plugin.getDataFolder(), ".tmp_backup_downloads"); + + S3ClientBuilder builder = S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create(AwsBasicCredentials.create(accessKey, secretKey))) + .region(Region.of(region)); + + if (url != null && !url.isEmpty()) { + builder = builder + .region(Region.of(region)) + .endpointOverride(URI.create(url)); + } + + this.s3Client = builder.build(); + } + + private String getBackupDirectory(BuildWorld buildWorld) { + return this.pathPrefix + buildWorld.getUniqueId() + "/"; + } + + @Override + public CompletableFuture> listBackups(BuildWorld buildWorld) { + return CompletableFuture.supplyAsync(() -> { + List backups = new ArrayList<>(Config.World.Backup.maxBackupsPerWorld); + + try { + ListObjectsV2Response response = this.s3Client.listObjectsV2(ListObjectsV2Request.builder() + .bucket(this.bucket) + .prefix(getBackupDirectory(buildWorld)) + .build()); + + backups.addAll( + response.contents().stream() + .filter(object -> object.key().endsWith(".zip")) + .map(object -> new BackupImpl( + plugin.getBackupService().getProfile(buildWorld), + object.lastModified().toEpochMilli(), + object.key() + )) + .toList() + ); + } catch (S3Exception | SdkClientException e) { + throw new RuntimeException("Error while listing backups", e); + } + + backups.sort(Comparator.comparingLong(Backup::creationTime).reversed()); + return backups; + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public CompletableFuture storeBackup(BuildWorld buildWorld) { + return CompletableFuture.supplyAsync(() -> { + long timestamp = System.currentTimeMillis(); + String key = getBackupDirectory(buildWorld) + getBackupName(timestamp); + + byte[] zipBytes; + try { + zipBytes = FileUtils.zipWorldToMemory(buildWorld); + } catch (IOException e) { + throw new RuntimeException("Failed to complete the backup for " + buildWorld.getName()); + } + + try { + this.s3Client.putObject( + PutObjectRequest.builder() + .bucket(this.bucket) + .key(key) + .build(), + RequestBody.fromBytes(zipBytes) + ); + plugin.getLogger().info("Backed up world '%s'. Took %sms".formatted(buildWorld.getName(), (System.currentTimeMillis() - timestamp))); + return new BackupImpl(plugin.getBackupService().getProfile(buildWorld), timestamp, key); + } catch (S3Exception | SdkClientException e) { + throw new RuntimeException("Failed to upload backup for " + buildWorld.getName(), e); + } + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public CompletableFuture downloadBackup(Backup backup) { + return CompletableFuture.supplyAsync(() -> { + try { + Path target = this.tmpDownloadDirectory.resolve(UUID.randomUUID() + ".zip"); + this.s3Client.getObject( + GetObjectRequest.builder() + .bucket(this.bucket) + .key(backup.key()) + .build(), + target + ); + return target.toFile(); + } catch (S3Exception | SdkClientException e) { + throw new RuntimeException("Failed to download S3 backup: " + backup.key(), e); + } + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public CompletableFuture deleteBackup(Backup backup) { + return CompletableFuture.runAsync(() -> { + try { + this.s3Client.deleteObject(DeleteObjectRequest.builder() + .bucket(this.bucket) + .key(backup.key()) + .build()); + } catch (S3Exception | SdkClientException e) { + throw new RuntimeException("Unable to delete backup " + backup.key(), e); + } + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public void close() { + disconnect(); + + try { + FileUtils.deleteDirectory(this.tmpDownloadDirectory); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Failed to delete temporary download directory", e); + } + } + + private void disconnect() { + try { + this.s3Client.close(); + } catch (Exception e) { + plugin.getLogger().log(Level.SEVERE, "Error while closing S3 client", e); + } + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/SftpBackupStorage.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/SftpBackupStorage.java new file mode 100644 index 0000000..ccddc9b --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/backup/storage/SftpBackupStorage.java @@ -0,0 +1,340 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.backup.storage; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.backup.Backup; +import de.eintosti.buildsystem.api.world.backup.BackupStorage; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.world.backup.BackupImpl; +import de.eintosti.buildsystem.world.backup.BackupService; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; +import java.io.Closeable; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.Security; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Level; +import org.apache.sshd.client.SshClient; +import org.apache.sshd.client.session.ClientSession; +import org.apache.sshd.common.signature.BuiltinSignatures; +import org.apache.sshd.sftp.client.SftpClient; +import org.apache.sshd.sftp.client.SftpClient.Attributes; +import org.apache.sshd.sftp.client.SftpClient.DirEntry; +import org.apache.sshd.sftp.client.SftpClientFactory; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class SftpBackupStorage implements BackupStorage { + + private static final Duration CONNECTION_TIMEOUT = Duration.ofSeconds(10); + private static final Duration AUTH_TIMEOUT = Duration.ofSeconds(5); + private static final int BUFFER_SIZE = 8192; + + private final BuildSystemPlugin plugin; + + private final String host; + private final int port; + private final String username; + private final String password; + private final String remoteBasePath; + private final Path tmpDownloadPath; + + @Nullable + private volatile SshClient sshClient; + @Nullable + private volatile ClientSession clientSession; + @Nullable + private volatile SftpClient sftpClient; + + public SftpBackupStorage(BuildSystemPlugin plugin, String host, int port, String username, String password, String remoteBasePath) { + this.plugin = plugin; + + this.host = host; + this.port = validatePort(port); + this.username = username; + this.password = password; + this.remoteBasePath = normalizeBasePath(remoteBasePath); + this.tmpDownloadPath = FileUtils.resolve(plugin.getDataFolder().toPath(), ".tmp_backup_downloads"); + + Security.addProvider(new BouncyCastleProvider()); + establishConnection(); + } + + private static int validatePort(int port) { + if (port <= 0 || port > 65535) { + throw new IllegalArgumentException("Port must be between 1 and 65535, got: " + port); + } + return port; + } + + private static String normalizeBasePath(@Nullable String basePath) { + if (basePath == null || basePath.trim().isEmpty()) { + return "/"; + } + String normalized = basePath.trim(); + return normalized.endsWith("/") ? normalized : normalized + "/"; + } + + private synchronized void establishConnection() { + if (this.sshClient == null) { + initializeSshClient(); + } + + try { + this.clientSession = this.sshClient.connect(this.username, this.host, this.port) + .verify(CONNECTION_TIMEOUT.toMillis()) + .getSession(); + + if (this.clientSession == null) { + throw new IllegalStateException(); + } + + this.clientSession.addPasswordIdentity(this.password); + this.clientSession.auth().verify(AUTH_TIMEOUT.toMillis()); + + this.sftpClient = SftpClientFactory.instance().createSftpClient(this.clientSession); + plugin.getLogger().info("SFTP connection established successfully."); + } catch (Exception e) { + disconnectAll(); + plugin.getLogger().log(Level.SEVERE, "Failed to establish SFTP connection to " + this.host + ":" + this.port, e); + } + } + + private void initializeSshClient() { + this.sshClient = SshClient.setUpDefaultClient(); + this.sshClient.setSignatureFactories(Arrays.asList( + BuiltinSignatures.rsa, + BuiltinSignatures.ed25519 + )); + this.sshClient.start(); + } + + @Nullable + private SftpClient getSftpClient() { + if (sftpClient == null || !sftpClient.isOpen()) { + synchronized (this) { + if (sftpClient == null || !sftpClient.isOpen()) { + establishConnection(); + } + } + } + return sftpClient; + } + + private String getBackupDirectory(BuildWorld buildWorld) { + return this.remoteBasePath + buildWorld.getUniqueId() + "/"; + } + + @Override + public synchronized CompletableFuture> listBackups(BuildWorld buildWorld) { + return CompletableFuture.supplyAsync(() -> { + List backups = new ArrayList<>(Config.World.Backup.maxBackupsPerWorld); + String backupDirectory = getBackupDirectory(buildWorld); + + try { + SftpClient sftp = getSftpClient(); + createDirectoryIfNotExists(sftp, backupDirectory); + + Iterable files = sftp.readDir(backupDirectory); + for (SftpClient.DirEntry file : files) { + if (!file.getFilename().endsWith(".zip")) { + continue; + } + + Attributes attributes = file.getAttributes(); + long timestamp = Optional.ofNullable(attributes.getCreateTime()) + .orElse(attributes.getModifyTime()) + .toMillis(); + + backups.add(new BackupImpl( + plugin.getBackupService().getProfile(buildWorld), + timestamp, + backupDirectory + file.getFilename() + )); + } + } catch (IOException e) { + disconnectAll(); + throw new RuntimeException("Failed to list SFTP backups", e); + } + + backups.sort(Comparator.comparingLong(Backup::creationTime).reversed()); + return backups; + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public synchronized CompletableFuture storeBackup(BuildWorld buildWorld) { + return CompletableFuture.supplyAsync(() -> { + long timestamp = System.currentTimeMillis(); + String backupName = getBackupName(timestamp); + String backupDirectory = getBackupDirectory(buildWorld); + String remotePath = backupDirectory + backupName; + + byte[] zipBytes; + try { + zipBytes = FileUtils.zipWorldToMemory(buildWorld); + } catch (IOException e) { + throw new RuntimeException("Failed to zip world: " + buildWorld.getName()); + } + + try { + SftpClient sftp = getSftpClient(); + createDirectoryIfNotExists(sftp, backupDirectory); + + try ( + OutputStream out = sftp.write(remotePath); + BufferedOutputStream bufferedOut = new BufferedOutputStream(out, BUFFER_SIZE) + ) { + bufferedOut.write(zipBytes); + bufferedOut.flush(); + } + + plugin.getLogger().info("Backed up world '%s'. Took %sms".formatted(buildWorld.getName(), (System.currentTimeMillis() - timestamp))); + return new BackupImpl(plugin.getBackupService().getProfile(buildWorld), timestamp, remotePath); + } catch (IOException e) { + disconnectAll(); + throw new RuntimeException("Failed to upload SFTP backup", e); + } + }, BackupService.BACKUP_EXECUTOR); + } + + private void createDirectoryIfNotExists(SftpClient sftp, String path) throws IOException { + try { + if (!directoryExists(sftp, path)) { + createDirectoriesRecursively(sftp, path); + } + } catch (IOException e) { + throw new IOException("Failed to create backup directory: " + path, e); + } + } + + private void createDirectoriesRecursively(SftpClient sftp, @Nullable String path) throws IOException { + if (path == null || path.isEmpty() || path.equals("/")) { + return; + } + + String normalizedPath = path.replace("\\", "/"); + if (normalizedPath.endsWith("/")) { + normalizedPath = normalizedPath.substring(0, normalizedPath.length() - 1); + } + + if (directoryExists(sftp, normalizedPath)) { + return; + } + + int lastSeparator = normalizedPath.lastIndexOf('/'); + if (lastSeparator > 0) { + String parentPath = normalizedPath.substring(0, lastSeparator); + createDirectoriesRecursively(sftp, parentPath); + } + + sftp.mkdir(normalizedPath); + } + + private boolean directoryExists(SftpClient sftp, String path) { + try { + return sftp.stat(path).isDirectory(); + } catch (IOException e) { + return false; + } + } + + @Override + public synchronized CompletableFuture downloadBackup(Backup backup) { + return CompletableFuture.supplyAsync(() -> { + SftpClient sftp = getSftpClient(); + Path target = tmpDownloadPath.resolve(UUID.randomUUID() + ".zip"); + + try ( + InputStream in = sftp.read(backup.key()); + BufferedInputStream bufferedIn = new BufferedInputStream(in, BUFFER_SIZE); + OutputStream out = Files.newOutputStream(target); + BufferedOutputStream bufferedOut = new BufferedOutputStream(out, BUFFER_SIZE) + ) { + bufferedIn.transferTo(bufferedOut); + return target.toFile(); + } catch (IOException e) { + disconnectAll(); + throw new RuntimeException("Failed to download SFTP backup: " + backup.key(), e); + } + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public synchronized CompletableFuture deleteBackup(Backup backup) { + return CompletableFuture.runAsync(() -> { + try { + SftpClient sftp = getSftpClient(); + sftp.remove(backup.key()); + } catch (Exception e) { + throw new RuntimeException("Failed to delete SFTP backup: " + backup.key(), e); + } + }, BackupService.BACKUP_EXECUTOR); + } + + @Override + public void close() { + disconnectAll(); + + try { + FileUtils.deleteDirectory(this.tmpDownloadPath); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Failed to delete temporary download directory", e); + } + } + + private void disconnectAll() { + close(this.sshClient); + this.sshClient = null; + + close(this.sftpClient); + this.sftpClient = null; + + close(this.clientSession); + this.clientSession = null; + } + + private void close(@Nullable Closeable closeable) { + if (closeable == null) { + return; + } + + try { + closeable.close(); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Failed to close resource", e); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuilderInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuilderInventory.java new file mode 100644 index 0000000..af02f89 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuilderInventory.java @@ -0,0 +1,231 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.builder; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.command.subcommand.worlds.AddBuilderSubCommand; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.util.UUIDFetcher; +import de.eintosti.buildsystem.util.inventory.BuildWorldHolder; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.util.inventory.PaginatedInventory; +import de.eintosti.buildsystem.world.modification.EditInventory; +import java.util.Collection; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Material; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataType; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class BuilderInventory extends PaginatedInventory { + + private static final int MAX_BUILDERS_PER_PAGE = 9; + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + private final NamespacedKey builderNameKey; + + private int numBuilders = 0; + + public BuilderInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + this.builderNameKey = new NamespacedKey(plugin, "builder_name"); + } + + public void openInventory(BuildWorld buildWorld, Player player) { + Inventory inventory = getInventory(buildWorld, player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(BuildWorld buildWorld, Player player) { + addItems(buildWorld, player); + return inventories[getInvIndex(player)]; + } + + private void addItems(BuildWorld buildWorld, Player player) { + Collection builders = buildWorld.getBuilders().getAllBuilders(); + this.numBuilders = builders.size(); + int numInventories = calculateNumPages(this.numBuilders, MAX_BUILDERS_PER_PAGE); + + int index = 0; + Inventory inventory = createInventory(buildWorld, player); + inventories = new Inventory[numInventories]; + inventories[index] = inventory; + + int columnSkull = 9, maxColumnSkull = 17; + for (Builder builder : builders) { + inventory.setItem(columnSkull++, createBuilderItem(builder, player)); + + if (columnSkull > maxColumnSkull) { + columnSkull = 9; + inventory = createInventory(buildWorld, player); + inventories[++index] = inventory; + } + } + } + + private Inventory createInventory(BuildWorld buildWorld, Player player) { + Inventory inventory = new BuilderInventoryHolder(buildWorld, player).getInventory(); + fillGuiWithGlass(inventory, player); + + addCreatorInfoItem(inventory, buildWorld.getBuilders(), player); + addBuilderAddItem(inventory, buildWorld, player); + + inventory.setItem(18, InventoryUtils.createSkull(Messages.getString("gui_previous_page", player), Profileable.detect("f7aacad193e2226971ed95302dba433438be4644fbab5ebf818054061667fbe2"))); + inventory.setItem(26, InventoryUtils.createSkull(Messages.getString("gui_next_page", player), Profileable.detect("d34ef0638537222b20f480694dadc0f85fbe0759d581aa7fcdf2e43139377158"))); + + return inventory; + } + + private void addCreatorInfoItem(Inventory inventory, Builders builders, Player player) { + ItemStack creatorInfoItem; + Builder creator = builders.getCreator(); + + if (!builders.hasCreator()) { + creatorInfoItem = InventoryUtils.createItem(XMaterial.BARRIER, + Messages.getString("worldeditor_builders_no_creator_item", player) + ); + } else { + creatorInfoItem = InventoryUtils.createSkull(Messages.getString("worldeditor_builders_creator_item", player), Profileable.of(creator.getUniqueId()), + Messages.getString("worldeditor_builders_creator_lore", player, + Map.entry("%creator%", builders.getCreator().getName()) + ) + ); + } + inventory.setItem(4, creatorInfoItem); + } + + private void addBuilderAddItem(Inventory inventory, BuildWorld buildWorld, Player player) { + ItemStack builderAddItem; + if (buildWorld.getBuilders().isCreator(player) || player.hasPermission(BuildSystemPlugin.ADMIN_PERMISSION)) { + builderAddItem = InventoryUtils.createSkull(Messages.getString("worldeditor_builders_add_builder_item", player), Profileable.detect("3edd20be93520949e6ce789dc4f43efaeb28c717ee6bfcbbe02780142f716")); + } else { + builderAddItem = InventoryUtils.getColoredGlassPane(player).parseItem(); + } + inventory.setItem(22, builderAddItem); + } + + private ItemStack createBuilderItem(Builder builder, Player player) { + ItemStack itemStack = InventoryUtils.createSkull( + Messages.getString("worldeditor_builders_builder_item", player, + Map.entry("%builder%", builder.getName()) + ), + Profileable.username(builder.getName()), + Messages.getStringList("worldeditor_builders_builder_lore", player) + ); + ItemMeta itemMeta = itemStack.getItemMeta(); + itemMeta.getPersistentDataContainer().set(this.builderNameKey, PersistentDataType.STRING, builder.getName()); + itemStack.setItemMeta(itemMeta); + return itemStack; + } + + private void fillGuiWithGlass(Inventory inventory, Player player) { + for (int i = 0; i <= 8; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + for (int i = 18; i <= 26; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof BuilderInventoryHolder holder)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null || itemStack.getType() == Material.AIR || !itemStack.hasItemMeta()) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + BuildWorld buildWorld = holder.getBuildWorld(); + + if (itemStack.getType() != XMaterial.PLAYER_HEAD.get()) { + if (buildWorld.getPermissions().canPerformCommand(player, WorldsArgument.EDIT.getPermission())) { + XSound.BLOCK_CHEST_OPEN.play(player); + new EditInventory(plugin).openInventory(player, buildWorld); + } + return; + } + + int slot = event.getSlot(); + switch (slot) { + case 18: + if (decrementInv(player, numBuilders, MAX_BUILDERS_PER_PAGE)) { + openInventory(buildWorld, player); + } + break; + case 22: + XSound.ENTITY_CHICKEN_EGG.play(player); + new AddBuilderSubCommand(plugin, buildWorld.getName()).getAddBuilderInput(player, buildWorld, false); + return; + case 26: + if (incrementInv(player, numBuilders, MAX_BUILDERS_PER_PAGE)) { + openInventory(buildWorld, player); + } + break; + default: + if (slot == 4 || !event.isShiftClick()) { + return; + } + + String builderName = itemStack.getItemMeta().getPersistentDataContainer().get(this.builderNameKey, PersistentDataType.STRING); + UUID builderId; + if (builderName == null || (builderId = UUIDFetcher.getUUID(builderName)) == null) { + player.closeInventory(); + Messages.sendMessage(player, "worlds_removebuilder_error"); + plugin.getLogger().warning("Could not find UUID for " + builderName); + return; + } + + buildWorld.getBuilders().removeBuilder(builderId); + XSound.ENTITY_ENDERMAN_TELEPORT.play(player); + Messages.sendMessage(player, "worlds_removebuilder_removed", Map.entry("%builder%", builderName)); + } + + XSound.ENTITY_CHICKEN_EGG.play(player); + player.openInventory(getInventory(buildWorld, player)); + } + + private static class BuilderInventoryHolder extends BuildWorldHolder { + + public BuilderInventoryHolder(BuildWorld buildWorld, Player player) { + super(buildWorld, 27, Messages.getString("worldeditor_builders_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuildersImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuildersImpl.java new file mode 100644 index 0000000..ab88344 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/BuildersImpl.java @@ -0,0 +1,158 @@ +package de.eintosti.buildsystem.world.builder; + +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.builder.Builders; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class BuildersImpl implements Builders { + + @Nullable + private Builder creator; + private final Map buildersByUuid; + + public BuildersImpl(@Nullable Builder creator, List builders) { + this.creator = creator; + this.buildersByUuid = builders.stream().collect(Collectors.toMap(Builder::getUniqueId, Function.identity())); + } + + @Override + @Nullable + public Builder getCreator() { + return creator; + } + + @Override + public void setCreator(@Nullable Builder creator) { + this.creator = creator; + } + + @Override + public boolean hasCreator() { + return creator != null && !creator.getName().equals("-"); + } + + @Override + public boolean isCreator(Player player) { + return hasCreator() && player.getUniqueId().equals(creator.getUniqueId()); + } + + @Override + public Collection getAllBuilders() { + return Collections.unmodifiableCollection(buildersByUuid.values()); + } + + @Override + @Nullable + public Builder getBuilder(UUID uuid) { + return buildersByUuid.get(uuid); + } + + @Override + @Unmodifiable + public List getBuilderNames() { + return getAllBuilders().stream() + .map(Builder::getName) + .toList(); + } + + @Override + public boolean isBuilder(Player player) { + return isBuilder(player.getUniqueId()); + } + + @Override + public boolean isBuilder(UUID uuid) { + return buildersByUuid.containsKey(uuid); + } + + @Override + public void addBuilder(Builder builder) { + buildersByUuid.put(builder.getUniqueId(), builder); + } + + @Override + public void removeBuilder(Builder builder) { + removeBuilder(builder.getUniqueId()); + } + + @Override + public void removeBuilder(UUID uuid) { + buildersByUuid.remove(uuid); + } + + @Override + public String asPlaceholder(Player player) { + String template = Messages.getString("world_item_builders_builder_template", player); + List builderNames = getBuilderNames(); + + String string = ""; + if (builderNames.isEmpty()) { + string = template.replace("%builder%", "-").trim(); + } else { + for (String builderName : builderNames) { + string = string.concat(template.replace("%builder%", builderName)); + } + string = string.trim(); + } + + return string.substring(0, string.length() - 1); + } + + /** + * Formats the builders for the lore of the world item. + * + * @param player The player to display the lore to + * @param buildersPerLine The number of builders to display per line + * @return A list of formatted builder lines, each containing up to 3 builders + */ + public List formatBuildersForLore(Player player, int buildersPerLine) { + String template = Messages.getString("world_item_builders_builder_template", player); // e.g., "&b%builder%&7, " + + String[] templateParts = template.split("%builder%"); + String prefix = templateParts.length > 0 ? templateParts[0] : ""; + String suffix = templateParts.length > 1 ? templateParts[1] : ""; + + Collection allBuilders = buildersByUuid.values(); + if (allBuilders.isEmpty()) { + return List.of((prefix + "-").trim()); + } + + List loreLines = new ArrayList<>(); + List currentLineBuilders = new ArrayList<>(); + int builderCount = 0; + + for (Builder builder : allBuilders) { + String formattedBuilder = prefix + builder.getName(); + + boolean isLastBuilderInLine = (builderCount + 1) % buildersPerLine == 0; + boolean isLastOverallBuilder = (builderCount + 1) == allBuilders.size(); + + if (!isLastBuilderInLine && !isLastOverallBuilder) { + formattedBuilder += suffix; + } + + currentLineBuilders.add(formattedBuilder); + builderCount++; + + if (isLastBuilderInLine || isLastOverallBuilder) { + loreLines.add(String.join("", currentLineBuilders).trim()); + currentLineBuilders.clear(); // Clear for the next line + } + } + + return loreLines; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/package-info.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/package-info.java new file mode 100644 index 0000000..abe25f6 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/builder/package-info.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +/** + * Provides classes for managing world builders and their associated functionality. + */ +package de.eintosti.buildsystem.world.builder; \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/BuildWorldCreatorImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/BuildWorldCreatorImpl.java new file mode 100644 index 0000000..69d32af --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/BuildWorldCreatorImpl.java @@ -0,0 +1,593 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.creation; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.creation.BuildWorldCreator; +import de.eintosti.buildsystem.api.world.creation.generator.CustomGenerator; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.config.Config.World.Default; +import de.eintosti.buildsystem.config.Config.World.Default.Time; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.world.BuildWorldImpl; +import de.eintosti.buildsystem.world.creation.BuildWorldCreatorImpl.WorldGenerationData.CustomGeneratorData; +import de.eintosti.buildsystem.world.creation.BuildWorldCreatorImpl.WorldGenerationData.PredefinedGeneratorData; +import de.eintosti.buildsystem.world.creation.generator.CustomGeneratorImpl; +import de.eintosti.buildsystem.world.creation.generator.VoidGenerator; +import de.eintosti.buildsystem.world.modification.GameRuleEntry; +import dev.dewy.nbt.Nbt; +import dev.dewy.nbt.io.CompressionType; +import dev.dewy.nbt.tags.collection.CompoundTag; +import dev.dewy.nbt.tags.primitive.IntTag; +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Locale; +import java.util.Map; +import java.util.logging.Level; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Difficulty; +import org.bukkit.World; +import org.bukkit.WorldCreator; +import org.bukkit.WorldType; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class BuildWorldCreatorImpl implements BuildWorldCreator { + + private static final String LEVEL_DAT_FILE_NAME = "level.dat"; + private static final String TEMPLATES_DIRECTORY = "templates"; + private static final String WORLD_TYPE_FILE_NAME = ".buildsystem-generator-date.txt"; + private static final String CUSTOM_GENERATOR_PREFIX = "GENERATOR:"; + + private final BuildSystemPlugin plugin; + private final WorldStorageImpl worldStorage; + + private String worldName; + @Nullable + private Builder creator; + private boolean isPrivate = false; + private BuildWorldType worldType = BuildWorldType.NORMAL; + @Nullable + private CustomGenerator customGenerator = null; + private long creationDate = System.currentTimeMillis(); + @Nullable + private String template = null; + @Nullable + private Folder folder; + + @Nullable + private Difficulty difficulty; + @Nullable + private Integer time; + @Nullable + private Integer worldBoarderSize; + + @Nullable + private BuildWorld buildWorld; + + public BuildWorldCreatorImpl(BuildSystemPlugin plugin, String name) { + this.plugin = plugin; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + + this.worldName = name; + this.difficulty = Default.difficulty; + this.time = Time.noon; + this.worldBoarderSize = Default.worldBoarderSize; + } + + public BuildWorldCreatorImpl(BuildSystemPlugin plugin, BuildWorld buildWorld) { + this.plugin = plugin; + this.worldStorage = plugin.getWorldService().getWorldStorage(); + + this.buildWorld = buildWorld; + this.worldName = buildWorld.getName(); + this.worldType = buildWorld.getType(); + this.customGenerator = buildWorld.getCustomGenerator(); + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setName(String name) { + this.worldName = name; + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setCreator(@Nullable Builder creator) { + this.creator = creator; + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setTemplate(@Nullable String template) { + this.template = ChatColor.stripColor(template); + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setType(BuildWorldType type) { + this.worldType = type; + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setCustomGenerator(@Nullable CustomGenerator customGenerator) { + this.customGenerator = customGenerator; + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setFolder(@Nullable Folder folder) { + this.folder = folder; + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setPrivate(boolean isPrivate) { + this.isPrivate = isPrivate; + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setDifficulty(Difficulty difficulty) { + this.difficulty = difficulty; + return this; + } + + @Override + @Contract("_ -> this") + public BuildWorldCreatorImpl setCreationDate(long creationDate) { + this.creationDate = creationDate; + return this; + } + + @Override + public void createWorld(Player player) { + if (this.worldStorage.worldAndFolderExist(this.worldName)) { + Messages.sendMessage(player, "worlds_world_exists"); + return; + } + + boolean success = (this.worldType == BuildWorldType.TEMPLATE) + ? createWorldFromTemplate(player) + : createWorldFromGenerator(player); + + if (success) { + teleportAfterCreation(player); + Messages.sendMessage(player, "worlds_creation_finished"); + } + } + + @Override + public void importWorld(Player player, boolean teleport) { + this.buildWorld = createAndRegisterBuildWorld(player); + generateBukkitWorld(true); + if (teleport) { + teleportAfterCreation(player); + } + } + + /** + * Handles the creation of worlds that use a generator (i.e., not a template). + * + * @param player The player creating the world + * @return true if the process started successfully, false otherwise + */ + private boolean createWorldFromGenerator(Player player) { + Messages.sendMessage(player, "worlds_world_creation_started", + Map.entry("%world%", this.worldName), + Map.entry("%type%", Messages.getString(Messages.getMessageKey(this.worldType), player)) + ); + + this.buildWorld = createAndRegisterBuildWorld(player); + generateBukkitWorld(false); // Version check is not needed for new worlds. + return true; + } + + /** + * Handles the creation of worlds from a template file. + * + * @param player The player creating the world + * @return {@code true} if the creation was successful, {@code false} otherwise + */ + private boolean createWorldFromTemplate(Player player) { + if (this.template == null || this.template.isEmpty()) { + throw new IllegalStateException("Attempted to create a template world without a template name"); + } + + File templateFile = new File(this.plugin.getDataFolder(), TEMPLATES_DIRECTORY + File.separator + template); + if (!templateFile.exists()) { + Messages.sendMessage(player, "worlds_template_does_not_exist"); + return false; + } + + Messages.sendMessage(player, "worlds_template_creation_started", + Map.entry("%world%", this.worldName), + Map.entry("%template%", this.template) + ); + + File worldFile = new File(Bukkit.getWorldContainer(), this.worldName); + FileUtils.copy(templateFile, worldFile); + + this.buildWorld = createAndRegisterBuildWorld(player); + generateBukkitWorld(true); + return true; + } + + /** + * Creates the {@link BuildWorld} object and registers it. + * + * @param player The player creating the world + * @return The newly created {@link BuildWorld} instance + */ + private BuildWorld createAndRegisterBuildWorld(Player player) { + BuildWorldImpl buildWorld = new BuildWorldImpl( + this.worldName, + this.creator == null ? Builder.of(player) : this.creator, + this.worldType, + this.creationDate, + this.isPrivate, + this.customGenerator, + this.folder + ); + + // Also store the world in the folder + if (this.folder != null) { + this.folder.addWorld(buildWorld); + } + + buildWorld.getData().lastLoaded().set(System.currentTimeMillis()); + this.worldStorage.addBuildWorld(buildWorld); + + return buildWorld; + } + + /** + * Retrieves the server's data version. + * + * @return The server's data version + */ + @SuppressWarnings("deprecation") + private int getServerDataVersion() { + return Bukkit.getServer().getUnsafe().getDataVersion(); + } + + @Override + @Nullable + public World generateBukkitWorld(boolean checkVersion) { + if (this.buildWorld == null) { + throw new IllegalStateException("BuildWorld must be set before generating the Bukkit world."); + } + + if (checkVersion && isDataVersionTooHigh()) { + plugin.getLogger().warning( + "\"%s\" was created in a newer version of Minecraft (%s > %s). Skipping...".formatted(worldName, parseDataVersion(), getServerDataVersion()) + ); + return null; + } + + WorldCreator worldCreator = createBukkitWorldCreator(); + World bukkitWorld = Bukkit.createWorld(worldCreator); + + if (bukkitWorld != null) { + applyDefaultWorldSettings(bukkitWorld); + applyPostGenerationSettings(bukkitWorld, this.buildWorld.getType()); + updateWorldDataVersion(); + saveGenerationData(bukkitWorld, this.buildWorld.getType(), this.customGenerator); + } + + return bukkitWorld; + } + + /** + * Creates and configures a {@link WorldCreator} based on the specified {@link BuildWorldType}. + * + * @return A configured {@link WorldCreator} + */ + private WorldCreator createBukkitWorldCreator() { + WorldCreator worldCreator = new WorldCreator(this.worldName); + BuildWorldType worldType = this.worldType; + + if (worldType == BuildWorldType.IMPORTED && this.customGenerator != null) { + worldType = BuildWorldType.valueOf(this.customGenerator.chunkGeneratorName().toUpperCase(Locale.ROOT)); + } + + if (worldType == BuildWorldType.TEMPLATE) { + switch (loadGenerationData(this.worldName)) { + case PredefinedGeneratorData predefinedData -> { + worldType = predefinedData.type(); + } + case CustomGeneratorData customGeneratorData -> { + this.customGenerator = customGeneratorData.getCustomGenerator(this.worldName); + if (this.customGenerator == null) { + plugin.getLogger().warning( + "Custom generator '%s:%s' not found. Defaulting to NORMAL type.".formatted(customGeneratorData.pluginName(), customGeneratorData.chunkGeneratorName()) + ); + worldType = BuildWorldType.NORMAL; + } + } + default -> { + plugin.getLogger().warning("Failed to load world generation data for '%s'. Defaulting to NORMAL type.".formatted(this.worldName)); + worldType = BuildWorldType.NORMAL; + } + } + } + + if (this.customGenerator != null && this.customGenerator.chunkGenerator() != null) { + worldCreator.generator(this.customGenerator.chunkGenerator()); + plugin.getLogger().info("Using custom chunk generator '%s' for world '%s'".formatted(this.customGenerator.toString(), this.worldName)); + } + + switch (worldType) { + case VOID: + worldCreator.type(WorldType.FLAT); + worldCreator.generateStructures(false); + worldCreator.generator(new VoidGenerator()); + break; + case FLAT: + case PRIVATE: + worldCreator.type(WorldType.FLAT); + worldCreator.generateStructures(false); + break; + case NETHER: + worldCreator.generateStructures(true); + worldCreator.environment(World.Environment.NETHER); + break; + case END: + worldCreator.generateStructures(true); + worldCreator.environment(World.Environment.THE_END); + break; + default: // NORMAL + worldCreator.type(WorldType.NORMAL); + worldCreator.generateStructures(true); + worldCreator.environment(World.Environment.NORMAL); + break; + } + + return worldCreator; + } + + /** + * Applies standard server settings (difficulty, time, border, gamerules) to a newly created world. + * + * @param bukkitWorld The world to configure + */ + private void applyDefaultWorldSettings(World bukkitWorld) { + if (this.difficulty != null) { + bukkitWorld.setDifficulty(this.difficulty); + } + if (this.time != null) { + bukkitWorld.setTime(this.time); + } + if (this.worldBoarderSize != null) { + bukkitWorld.getWorldBorder().setSize(Default.worldBoarderSize); + } + bukkitWorld.setKeepSpawnInMemory(true); + Default.gameRules.forEach(gameRule -> applyGameRule(bukkitWorld, gameRule)); + } + + private static void applyGameRule(World world, GameRuleEntry entry) { + world.setGameRule(entry.rule(), entry.value()); + } + + /** + * Applies settings that are specific to the world type after it has been generated. + * + * @param bukkitWorld The world to modify + * @param worldType The type of the world + */ + private void applyPostGenerationSettings(World bukkitWorld, BuildWorldType worldType) { + switch (worldType) { + case VOID -> { + int voidBlockY = 64; + bukkitWorld.getBlockAt(0, voidBlockY, 0).setType(XMaterial.GOLD_BLOCK.get()); + bukkitWorld.setSpawnLocation(0, voidBlockY + 1, 0); + } + case FLAT -> { + bukkitWorld.setSpawnLocation(0, -60, 0); + } + default -> { + // No special post-generation steps for other types + } + } + } + + public boolean isDataVersionTooHigh() { + if (Boolean.getBoolean("Paper.ignoreWorldDataVersion")) { + return false; + } + int worldVersion = parseDataVersion(); + return worldVersion > getServerDataVersion(); + } + + private int parseDataVersion() { + File levelFile = new File(new File(Bukkit.getWorldContainer(), this.worldName), LEVEL_DAT_FILE_NAME); + if (!levelFile.exists()) { + return -1; + } + + try { + CompoundTag level = new Nbt().fromFile(levelFile); + CompoundTag data = level.get("Data"); + IntTag dataVersion = data.getInt("DataVersion"); + return dataVersion != null ? dataVersion.getValue() : -1; + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Failed to parse level.dat for world " + this.worldName, e); + return -1; + } + } + + private void updateWorldDataVersion() { + File levelFile = new File(new File(Bukkit.getWorldContainer(), this.worldName), LEVEL_DAT_FILE_NAME); + if (!levelFile.exists()) { + return; + } + + try { + Nbt nbt = new Nbt(); + CompoundTag level = nbt.fromFile(levelFile); + CompoundTag data = level.get("Data"); + IntTag dataVersionTag = data.getInt("DataVersion"); + if (dataVersionTag == null) { + return; + } + + int worldVersion = dataVersionTag.getValue(); + int serverVersion = getServerDataVersion(); + if (worldVersion < serverVersion) { + dataVersionTag.setValue(serverVersion); + nbt.toFile(level, levelFile, CompressionType.GZIP); + } + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Failed to update level.dat for world " + worldName, e); + } + } + + /** + * Saves the world generation setting for a given {@link World} to a dedicated file within the world's folder. + *

    + * If the {@link BuildWorldType} is {@link BuildWorldType#CUSTOM}, the specified chunk generator's name will be stored prefixed with "GENERATOR:". Otherwise, the + * {@link BuildWorldType}'s enum name will be stored directly. + *

    + * This method will only write the file if it does not already exist. If the file {@link #WORLD_TYPE_FILE_NAME} is already present in the world's folder, no action will be + * taken, and the existing world generation setting will not be overwritten. + * + * @param world The world for which to save the generation setting + * @param worldType The type representing the world's standard type + * @param customGenerator The custom chunk generator, if {@code worldType} is {@link BuildWorldType#CUSTOM}. This parameter is ignored if {@code worldType} is not + * {@link BuildWorldType#CUSTOM} + */ + private void saveGenerationData(World world, BuildWorldType worldType, @Nullable CustomGenerator customGenerator) { + Path path = Path.of(world.getWorldFolder() + File.separator + WORLD_TYPE_FILE_NAME); + if (path.toFile().exists()) { + return; + } + + String contentToSave; + if (worldType == BuildWorldType.CUSTOM) { + if (customGenerator == null) { + plugin.getLogger().warning("Attempted to save CUSTOM world type for world %s without a custom generator. Defaulting to NORMAL type.".formatted(world.getName())); + contentToSave = BuildWorldType.NORMAL.name(); + } else { + contentToSave = CUSTOM_GENERATOR_PREFIX + customGenerator; + } + } else { + contentToSave = worldType.name(); + } + + try { + Files.writeString(path, contentToSave, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); + } catch (IOException e) { + plugin.getLogger().log( + Level.WARNING, + "Failed to save world generation setting for world %s (type: %s, generator: %s)".formatted(world.getName(), worldType.name(), customGenerator), + e + ); + } + } + + /** + * Retrieves the world generation setting for a specific world identified by its name. It reads the setting from a dedicated file within the world's folder. + *

    + * The file's content is interpreted: if it starts with "GENERATOR:", the rest is considered the custom chunk generator name. Otherwise, the content is parsed as a + * {@link BuildWorldType} enum constant (case-insensitively). + * + * @param worldName The name of the world for which to retrieve the generation setting + * @return A {@link WorldGenerationData} object representing either a {@link PredefinedGeneratorData} or a {@link CustomGeneratorData}, or {@code null} if the file does not + * exist or an error occurs while loading + */ + @Contract("_ -> new") + private WorldGenerationData loadGenerationData(String worldName) { + BuildWorldType defaultType = BuildWorldType.NORMAL; + + Path filePath = Path.of(plugin.getServer().getWorldContainer().getAbsolutePath(), worldName, WORLD_TYPE_FILE_NAME); + if (!filePath.toFile().exists()) { + return new PredefinedGeneratorData(defaultType); + } + + String content; + try { + content = Files.readString(filePath).trim(); + } catch (IOException e) { + plugin.getLogger().log(Level.WARNING, "Failed to load world generation setting for world %s. Defaulting to %s.".formatted(worldName, defaultType), e); + return new PredefinedGeneratorData(defaultType); + } + + if (content.startsWith(CUSTOM_GENERATOR_PREFIX)) { + String[] generatorData = content.substring(CUSTOM_GENERATOR_PREFIX.length()).trim().split(":"); + if (generatorData.length != 2) { + plugin.getLogger().warning("Invalid custom generator name in file for world %s. Content: '%s'. Defaulting to %s.".formatted(worldName, content, defaultType)); + return new PredefinedGeneratorData(defaultType); + } + return new CustomGeneratorData(generatorData[0], generatorData[1]); + } else { + try { + BuildWorldType type = BuildWorldType.valueOf(content.toUpperCase()); + return new PredefinedGeneratorData(type); + } catch (IllegalArgumentException e) { + plugin.getLogger().warning("Invalid BuildWorldType in file for world %s. Content: '%s'. Defaulting to %s.".formatted(worldName, content, defaultType)); + return new PredefinedGeneratorData(defaultType); + } + } + } + + private void teleportAfterCreation(Player player) { + BuildWorld buildWorld = worldStorage.getBuildWorld(worldName); + if (buildWorld == null) { + return; + } + + buildWorld.getUnloader().manageUnload(); + buildWorld.getTeleporter().teleport(player); + } + + public interface WorldGenerationData { + + record PredefinedGeneratorData(BuildWorldType type) implements WorldGenerationData { + + } + + record CustomGeneratorData(String pluginName, String chunkGeneratorName) implements WorldGenerationData { + + @Nullable + public CustomGenerator getCustomGenerator(String worldName) { + return CustomGeneratorImpl.of("%s:%s".formatted(pluginName, chunkGeneratorName), worldName); + } + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/CreateInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/CreateInventory.java new file mode 100644 index 0000000..c6e4672 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/CreateInventory.java @@ -0,0 +1,311 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.creation; + +import com.cryptomorin.xseries.XEnchantment; +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.util.FileUtils; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.util.inventory.PaginatedInventory; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import java.io.File; +import java.util.Arrays; +import java.util.Locale; +import java.util.Map; +import org.bukkit.ChatColor; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class CreateInventory extends PaginatedInventory { + + private static final int MAX_TEMPLATES = 5; + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + private final WorldServiceImpl worldService; + + @Nullable + private final Folder folder; + private final boolean createPrivateWorld; + + private int numTemplates = 0; + + public CreateInventory(BuildSystemPlugin plugin, Visibility visibility) { + this(plugin, visibility, null); + } + + public CreateInventory(BuildSystemPlugin plugin, Visibility visibility, @Nullable Folder folder) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + this.worldService = plugin.getWorldService(); + + this.createPrivateWorld = visibility == Visibility.PRIVATE; + this.folder = folder; + } + + public void openInventory(Player player, Page page) { + Inventory inventory; + if (page == Page.TEMPLATES) { + addTemplates(player, page); + inventory = inventories[getInvIndex(player)]; + } else { + inventory = getBaseInventory(player, page); + } + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private void addTemplates(Player player, Page page) { + File[] templateFiles = FileUtils.resolve(plugin.getDataFolder(), "templates") + .toFile() + .listFiles((file) -> file.isDirectory() && !file.isHidden()); + + this.numTemplates = templateFiles != null ? templateFiles.length : 0; + int numPages = calculateNumPages(numTemplates, MAX_TEMPLATES); + + int index = 0; + Inventory inventory = getBaseInventory(player, page); + this.inventories = new Inventory[numPages]; + this.inventories[index] = inventory; + + if (numTemplates == 0) { + ItemStack barrier = InventoryUtils.createItem(XMaterial.BARRIER, Messages.getString("create_no_templates", player)); + for (int i = 29; i <= 33; i++) { + inventory.setItem(i, barrier); + } + return; + } + + if (templateFiles == null) { + return; + } + + int columnTemplate = 29, maxColumnTemplate = 33; + for (File templateFile : templateFiles) { + inventory.setItem(columnTemplate++, + InventoryUtils.createItem(XMaterial.FILLED_MAP, Messages.getString("create_template", player, + Map.entry("%template%", templateFile.getName())) + ) + ); + + if (columnTemplate > maxColumnTemplate) { + columnTemplate = 29; + inventory = getBaseInventory(player, page); + inventories[++index] = inventory; + } + } + } + + private Inventory getBaseInventory(Player player, Page page) { + Inventory inventory = new CreateInventoryHolder(player, page).getInventory(); + fillGuiWithGlass(player, inventory, page); + + addPageItem(inventory, page, Page.PREDEFINED, InventoryUtils.createSkull(Messages.getString("create_predefined_worlds", player), Profileable.detect("2cdc0feb7001e2c10fd5066e501b87e3d64793092b85a50c856d962f8be92c78"))); + addPageItem(inventory, page, Page.GENERATOR, InventoryUtils.createSkull(Messages.getString("create_generators", player), Profileable.detect("b2f79016cad84d1ae21609c4813782598e387961be13c15682752f126dce7a"))); + addPageItem(inventory, page, Page.TEMPLATES, InventoryUtils.createSkull(Messages.getString("create_templates", player), Profileable.detect("d17b8b43f8c4b5cfeb919c9f8fe93f26ceb6d2b133c2ab1eb339bd6621fd309c"))); + + switch (page) { + case PREDEFINED: + addPredefinedWorldItem(player, inventory, 29, BuildWorldType.NORMAL, Messages.getString("create_normal_world", player)); + addPredefinedWorldItem(player, inventory, 30, BuildWorldType.FLAT, Messages.getString("create_flat_world", player)); + addPredefinedWorldItem(player, inventory, 31, BuildWorldType.NETHER, Messages.getString("create_nether_world", player)); + addPredefinedWorldItem(player, inventory, 32, BuildWorldType.END, Messages.getString("create_end_world", player)); + addPredefinedWorldItem(player, inventory, 33, BuildWorldType.VOID, Messages.getString("create_void_world", player)); + break; + case GENERATOR: + inventory.setItem(31, InventoryUtils.createSkull(Messages.getString("create_generators_create_world", player), Profileable.detect("3edd20be93520949e6ce789dc4f43efaeb28c717ee6bfcbbe02780142f716"))); + break; + case TEMPLATES: + // Template stuff is done during inventory open + break; + } + + return inventory; + } + + private void addPageItem(Inventory inventory, Page currentPage, Page page, ItemStack itemStack) { + if (currentPage == page) { + itemStack.addUnsafeEnchantment(XEnchantment.UNBREAKING.get(), 1); + } + inventory.setItem(page.getSlot(), itemStack); + } + + private void addPredefinedWorldItem(Player player, Inventory inventory, int position, BuildWorldType worldType, String displayName) { + XMaterial material = plugin.getCustomizableIcons().getIcon(worldType); + + if (!player.hasPermission("buildsystem.create.type." + worldType.name().toLowerCase(Locale.ROOT))) { + material = XMaterial.BARRIER; + displayName = "§c§m" + ChatColor.stripColor(displayName); + } + + inventory.setItem(position, InventoryUtils.createItem(material, displayName)); + } + + private void fillGuiWithGlass(Player player, Inventory inventory, Page page) { + for (int i = 0; i <= 28; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + for (int i = 34; i <= 44; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + + switch (page) { + case GENERATOR: + InventoryUtils.addGlassPane(player, inventory, 29); + InventoryUtils.addGlassPane(player, inventory, 30); + InventoryUtils.addGlassPane(player, inventory, 32); + InventoryUtils.addGlassPane(player, inventory, 33); + break; + case TEMPLATES: + inventory.setItem(28, InventoryUtils.createSkull(Messages.getString("gui_previous_page", player), Profileable.detect("f7aacad193e2226971ed95302dba433438be4644fbab5ebf818054061667fbe2"))); + inventory.setItem(34, InventoryUtils.createSkull(Messages.getString("gui_next_page", player), Profileable.detect("d34ef0638537222b20f480694dadc0f85fbe0759d581aa7fcdf2e43139377158"))); + break; + } + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof CreateInventoryHolder holder)) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + + int slot = event.getSlot(); + CreateInventory.Page newPage = Page.of(slot); + if (newPage != null) { + openInventory(player, newPage); + XSound.ENTITY_CHICKEN_EGG.play(player); + return; + } + + switch (holder.getPage()) { + case PREDEFINED: { + BuildWorldType worldType = switch (slot) { + case 29 -> BuildWorldType.NORMAL; + case 30 -> BuildWorldType.FLAT; + case 31 -> BuildWorldType.NETHER; + case 32 -> BuildWorldType.END; + case 33 -> BuildWorldType.VOID; + default -> null; + }; + + if (worldType == null || !player.hasPermission("buildsystem.create.type." + worldType.name().toLowerCase(Locale.ROOT))) { + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + + worldService.startWorldNameInput(player, worldType, null, this.createPrivateWorld, this.folder); + XSound.ENTITY_CHICKEN_EGG.play(player); + break; + } + + case GENERATOR: { + if (slot == 31) { + worldService.startWorldNameInput(player, BuildWorldType.CUSTOM, null, this.createPrivateWorld, this.folder); + XSound.ENTITY_CHICKEN_EGG.play(player); + } + break; + } + + case TEMPLATES: { + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null) { + return; + } + + XMaterial xMaterial = XMaterial.matchXMaterial(itemStack); + switch (xMaterial) { + case FILLED_MAP: + this.worldService.startWorldNameInput(player, BuildWorldType.TEMPLATE, itemStack.getItemMeta().getDisplayName(), this.createPrivateWorld, this.folder); + break; + case PLAYER_HEAD: + if (slot == 28 && !decrementInv(player, numTemplates, MAX_TEMPLATES)) { + return; + } else if (slot == 34 && !incrementInv(player, numTemplates, MAX_TEMPLATES)) { + return; + } + openInventory(player, CreateInventory.Page.TEMPLATES); + break; + default: + return; + } + break; + } + } + } + + public enum Page { + PREDEFINED(12), + GENERATOR(13), + TEMPLATES(14); + + private final int slot; + + Page(int slot) { + this.slot = slot; + } + + /** + * Gets the {@link Page} for the given slot. + * + * @param slot The clicked slot + * @return The {@link Page} for the given slot, or {@code null} if the slot does not correspond to a page + */ + @Nullable + public static Page of(int slot) { + return Arrays.stream(values()) + .filter(value -> value.slot == slot) + .findFirst() + .orElse(null); + } + + public int getSlot() { + return slot; + } + } + + private static class CreateInventoryHolder extends BuildSystemHolder { + + private final Page page; + + public CreateInventoryHolder(Player player, Page page) { + super(45, Messages.getString("create_title", player)); + this.page = page; + } + + public Page getPage() { + return page; + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/CustomGeneratorImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/CustomGeneratorImpl.java new file mode 100644 index 0000000..2fa9183 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/CustomGeneratorImpl.java @@ -0,0 +1,78 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.creation.generator; + +import de.eintosti.buildsystem.api.world.creation.generator.CustomGenerator; +import org.bukkit.Bukkit; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.plugin.Plugin; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * Represents a custom chunk generator identified by its plugin name and generator name. This record provides immutable storage for these identifiers and an optional reference to + * the actual {@link ChunkGenerator} instance if it could be loaded. + *

    + * The {@code pluginName} typically refers to the name of the plugin that provides the custom chunk generator. The {@code chunkGeneratorName} is the specific name or ID of the + * generator within that plugin. + * + * @param pluginName The name of the plugin providing the chunk generator (e.g., "MyPlugin") + * @param chunkGeneratorName The specific name of the chunk generator within the plugin (e.g., "MyGenerator" or an empty string for default) + * @param chunkGenerator An optional reference to the actual Bukkit {@link ChunkGenerator} instance, or {@code null} if it could not be retrieved or is not needed + */ +@NullMarked +public record CustomGeneratorImpl(String pluginName, String chunkGeneratorName, @Nullable ChunkGenerator chunkGenerator) implements CustomGenerator { + + /** + * Attempts to create a {@link CustomGeneratorImpl} instance by parsing an identifier string and loading the corresponding {@link ChunkGenerator}. + *

    + * The {@code identifier} string is expected to be in one of two formats: + *

      + *
    • {@code "pluginName:chunkGeneratorName"} (e.g., "MyPlugin:MyGenerator")
    • + *
    • {@code "pluginName"} (e.g., "MyPlugin") - In this case, {@code chunkGeneratorName} is inferred to be the same as {@code pluginName}, which is a common convention for single-plugin generators.
    • + *
    + * The method attempts to find the specified plugin and then retrieve its default world generator. + * + * @param identifier The string identifier of the custom generator (e.g., "MyPlugin:MyGenerator" or "MyPlugin") + * @param worldName The name of the world for which the generator is being loaded + * @return A {@link CustomGeneratorImpl} instance if the plugin is found and the generator information is parsed; {@code null} if the plugin specified in the identifier does + * not exist or if the identifier format is unexpectedly empty + */ + @Nullable + public static CustomGeneratorImpl of(String identifier, String worldName) { + String[] generatorInfo = identifier.split(":"); + if (generatorInfo.length == 1) { + generatorInfo = new String[]{generatorInfo[0], generatorInfo[0]}; + } + + String pluginName = generatorInfo[0]; + String chunkGeneratorName = generatorInfo[1]; + + Plugin plugin = Bukkit.getPluginManager().getPlugin(pluginName); + if (plugin == null) { + return null; + } + + return new CustomGeneratorImpl(pluginName, chunkGeneratorName, plugin.getDefaultWorldGenerator(worldName, chunkGeneratorName)); + } + + @Override + public String toString() { + return "%s:%s".formatted(pluginName(), chunkGeneratorName()); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/VoidGenerator.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/VoidGenerator.java new file mode 100644 index 0000000..483f361 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/creation/generator/VoidGenerator.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.creation.generator; + +import java.util.Collections; +import java.util.List; +import java.util.Random; +import org.bukkit.block.Biome; +import org.bukkit.generator.BiomeProvider; +import org.bukkit.generator.ChunkGenerator; +import org.bukkit.generator.WorldInfo; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class VoidGenerator extends ChunkGenerator { + + private final Biome biome; + + public VoidGenerator() { + this.biome = Biome.PLAINS; + } + + @Override + public void generateNoise(WorldInfo worldInfo, Random random, int chunkX, int chunkZ, ChunkData chunkData) { + } + + @Override + public void generateSurface(WorldInfo worldInfo, Random random, int x, int z, ChunkData chunkData) { + } + + @Override + public void generateCaves(WorldInfo worldInfo, Random random, int chunkX, int chunkZ, ChunkData chunkData) { + } + + @Override + @Contract("_ -> new") + public BiomeProvider getDefaultBiomeProvider(WorldInfo worldInfo) { + return new SingleBiomeProvider(this.biome); + } + + public static class SingleBiomeProvider extends BiomeProvider { + + private final Biome biome; + + public SingleBiomeProvider(Biome biome) { + this.biome = biome; + } + + @Override + public Biome getBiome(WorldInfo worldInfo, int x, int y, int z) { + return this.biome; + } + + @Override + public List getBiomes(WorldInfo worldInfo) { + return Collections.singletonList(this.biome); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/StatusInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/StatusInventory.java new file mode 100644 index 0000000..853450b --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/StatusInventory.java @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.data; + +import com.cryptomorin.xseries.XEnchantment; +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.util.inventory.BuildWorldHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.world.modification.EditInventory; +import java.util.Map; +import org.bukkit.ChatColor; +import org.bukkit.Material; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class StatusInventory implements InventoryHandler { + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + private final PlayerServiceImpl playerService; + + public StatusInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + this.playerService = plugin.getPlayerService(); + } + + private Inventory getInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = new StatusInventoryHolder(buildWorld, player).getInventory(); + fillGuiWithGlass(player, inventory); + + addStatusItem(player, inventory, 10, BuildWorldStatus.NOT_STARTED, buildWorld); + addStatusItem(player, inventory, 11, BuildWorldStatus.IN_PROGRESS, buildWorld); + addStatusItem(player, inventory, 12, BuildWorldStatus.ALMOST_FINISHED, buildWorld); + addStatusItem(player, inventory, 13, BuildWorldStatus.FINISHED, buildWorld); + addStatusItem(player, inventory, 14, BuildWorldStatus.ARCHIVE, buildWorld); + addStatusItem(player, inventory, 16, BuildWorldStatus.HIDDEN, buildWorld); + + return inventory; + } + + public void openInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = getInventory(player, buildWorld); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + for (int i = 0; i <= 9; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + for (int i = 17; i <= 26; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + /** + * Adds a status item to the inventory at the specified position. + * + * @param player The player who will see the item + * @param inventory The inventory to add the item to + * @param position The position in the inventory to add the item + * @param status The status to represent with the item + * @param buildWorld The build world used to determine the current status + */ + private void addStatusItem(Player player, Inventory inventory, int position, BuildWorldStatus status, BuildWorld buildWorld) { + XMaterial material = plugin.getCustomizableIcons().getIcon(status); + String displayName = Messages.getString(Messages.getMessageKey(status), player); + + if (!player.hasPermission(status.getPermission())) { + material = XMaterial.BARRIER; + displayName = "§c§m" + ChatColor.stripColor(displayName); + } + + ItemStack itemStack = material.parseItem(); + ItemMeta itemMeta = itemStack.getItemMeta(); + itemMeta.setDisplayName(displayName); + itemMeta.addItemFlags(ItemFlag.HIDE_ENCHANTS); + itemStack.setItemMeta(itemMeta); + + if (buildWorld.getData().status().get() == status) { + itemStack.addUnsafeEnchantment(XEnchantment.UNBREAKING.get(), 1); + } + + inventory.setItem(position, itemStack); + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof StatusInventoryHolder holder)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null || itemStack.getType() == Material.AIR || !itemStack.hasItemMeta()) { + return; + } + + Material itemType = itemStack.getType(); + if (itemType == Material.AIR || !itemStack.hasItemMeta()) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + BuildWorld buildWorld = holder.getBuildWorld(); + + int slot = event.getSlot(); + if (slot < 10 || slot > 14 && slot != 16) { + XSound.BLOCK_CHEST_OPEN.play(player); + new EditInventory(plugin).openInventory(player, buildWorld); + return; + } + + BuildWorldStatus status = getStatusFromSlot(slot); + if (!player.hasPermission(status.getPermission())) { + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + + player.closeInventory(); + buildWorld.getData().status().set(status); + playerService.forceUpdateSidebar(buildWorld); + + XSound.ENTITY_CHICKEN_EGG.play(player); + Messages.sendMessage(player, "worlds_setstatus_set", + Map.entry("%world%", buildWorld.getName()), + Map.entry("%status%", Messages.getString(Messages.getMessageKey(status), player)) + ); + } + + /** + * Gets the {@link BuildWorldStatus} which is represented by the item at the given slot. + * + * @param slot The slot to get the status from + * @return The status which is represented by the item at the given slot + */ + private BuildWorldStatus getStatusFromSlot(int slot) { + return switch (slot) { + case 10 -> BuildWorldStatus.NOT_STARTED; + case 11 -> BuildWorldStatus.IN_PROGRESS; + case 12 -> BuildWorldStatus.ALMOST_FINISHED; + case 13 -> BuildWorldStatus.FINISHED; + case 14 -> BuildWorldStatus.ARCHIVE; + case 16 -> BuildWorldStatus.HIDDEN; + default -> throw new IllegalArgumentException("Slot " + slot + " does not correspond to status"); + }; + } + + private static class StatusInventoryHolder extends BuildWorldHolder { + + public StatusInventoryHolder(BuildWorld buildWorld, Player player) { + super(buildWorld, 27, Messages.getString("status_title", player, Map.entry("%world%", formatWorldName(buildWorld)))); + } + + private static String formatWorldName(BuildWorld buildWorld) { + String worldName = buildWorld.getName(); + if (worldName.length() > 17) { + worldName = worldName.substring(0, 14) + "..."; + } + return worldName; + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/WorldDataImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/WorldDataImpl.java new file mode 100644 index 0000000..bfaffd7 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/WorldDataImpl.java @@ -0,0 +1,397 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.data; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.data.Bypassable; +import de.eintosti.buildsystem.api.data.Overridable; +import de.eintosti.buildsystem.api.data.Type; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.WorldService; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.config.Config; +import de.eintosti.buildsystem.config.Config.World.Default; +import de.eintosti.buildsystem.config.Config.World.Default.Settings; +import de.eintosti.buildsystem.config.Config.World.Default.Settings.BuildersEnabled; +import de.eintosti.buildsystem.world.data.type.ConfigurableType; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; +import org.bukkit.Bukkit; +import org.bukkit.Difficulty; +import org.bukkit.Location; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class WorldDataImpl implements WorldData { + + private final Map> data = new HashMap<>(); + private String worldName; + + private final Type customSpawn; + private final Type permission; + private final Type project; + + private final Type difficulty; + private final Type material; + private final Type status; + + private final Type blockBreaking; + private final Type blockInteractions; + private final Type blockPlacement; + private final Type buildersEnabled; + private final Type explosions; + private final Type mobAi; + private final Type physics; + private final Type privateWorld; + + private final Type timeSinceBackup; + private final Type lastEdited; + private final Type lastLoaded; + private final Type lastUnloaded; + + private WorldDataImpl(WorldDataBuilder builder) { + this.worldName = builder.worldName; + + this.customSpawn = register("spawn", new ConfigurableType<>(builder.customSpawn)); + this.permission = register("permission", new ConfigurableType<>(builder.permission) + .withCapability(Bypassable.class, new Bypassable("buildsystem.bypass.permission")) + .withCapability(Overridable.class, new Overridable<>( + () -> Config.Folder.overridePermissions, + () -> { + Folder folder = getAssignedFolder(); + return (folder != null) ? folder.getPermission() : null; + } + )) + ); + this.project = register("project", new ConfigurableType<>(builder.project) + .withCapability(Overridable.class, new Overridable<>( + () -> Config.Folder.overrideProjects, + () -> { + Folder folder = getAssignedFolder(); + return (folder != null) ? folder.getProject() : null; + } + )) + ); + + this.difficulty = register("difficulty", new ConfigurableType<>(builder.difficulty) + .withConfigFormatter(Difficulty::name) + ); + this.material = register("material", new ConfigurableType<>(builder.material) + .withConfigFormatter(XMaterial::name) + ); + this.status = register("status", new ConfigurableType<>(builder.status) + .withConfigFormatter(BuildWorldStatus::name) + .withCapability(Bypassable.class, new Bypassable("buildsystem.bypass.archive")) + ); + + this.blockBreaking = register("block-breaking", new ConfigurableType<>(builder.blockBreaking) + .withCapability(Bypassable.class, new Bypassable("buildsystem.bypass.settings")) + ); + this.blockInteractions = register("block-interactions", new ConfigurableType<>(builder.blockInteractions) + .withCapability(Bypassable.class, new Bypassable("buildsystem.bypass.settings")) + ); + this.blockPlacement = register("block-placement", new ConfigurableType<>(builder.blockPlacement) + .withCapability(Bypassable.class, new Bypassable("buildsystem.bypass.settings")) + ); + this.buildersEnabled = register("builders-enabled", new ConfigurableType<>(builder.buildersEnabled)); + this.explosions = register("explosions", new ConfigurableType<>(builder.explosions)); + this.mobAi = register("mob-ai", new ConfigurableType<>(builder.mobAi)); + this.physics = register("physics", new ConfigurableType<>(builder.physics)); + this.privateWorld = register("private", new ConfigurableType<>(builder.privateWorld)); + + this.timeSinceBackup = register("time-since-backup", new ConfigurableType<>(builder.timeSinceBackup)); + this.lastEdited = register("last-edited", new ConfigurableType<>(builder.lastEdited)); + this.lastLoaded = register("last-loaded", new ConfigurableType<>(builder.lastLoaded)); + this.lastUnloaded = register("last-unloaded", new ConfigurableType<>(builder.lastUnloaded)); + } + + /** + * Gets the {@link Folder} assigned to this world, if any. + * + * @return The assigned folder, or {@code null} if not found or not loaded + */ + @Nullable + private Folder getAssignedFolder() { + WorldService worldService = BuildSystemPlugin.get().getWorldService(); + BuildWorld buildWorld = worldService.getWorldStorage().getBuildWorld(this.worldName); + if (buildWorld != null) { + return buildWorld.getFolder(); + } + return null; + } + + private ConfigurableType register(String key, ConfigurableType type) { + this.data.put(key, type); + return type; + } + + @Override + public Type customSpawn() { + return customSpawn; + } + + @Override + @Nullable + public Location getCustomSpawnLocation() { + String customSpawn = customSpawn().get(); + if (customSpawn.isBlank()) { + return null; + } + + String[] spawnString = customSpawn.split(";"); + return new Location( + Bukkit.getWorld(worldName), + Double.parseDouble(spawnString[0]), + Double.parseDouble(spawnString[1]), + Double.parseDouble(spawnString[2]), + Float.parseFloat(spawnString[3]), + Float.parseFloat(spawnString[4]) + ); + } + + @Override + public Type permission() { + return permission; + } + + @Override + public Type project() { + return project; + } + + @Override + public Type difficulty() { + return difficulty; + } + + @Override + public Type material() { + return material; + } + + @Override + public Type status() { + return status; + } + + @Override + public Type blockBreaking() { + return blockBreaking; + } + + @Override + public Type blockInteractions() { + return blockInteractions; + } + + @Override + public Type blockPlacement() { + return blockPlacement; + } + + @Override + public Type buildersEnabled() { + return buildersEnabled; + } + + @Override + public Type explosions() { + return explosions; + } + + @Override + public Type mobAi() { + return mobAi; + } + + @Override + public Type physics() { + return physics; + } + + @Override + public Type privateWorld() { + return privateWorld; + } + + @Override + public Type timeSinceBackup() { + return timeSinceBackup; + } + + @Override + public Type lastEdited() { + return lastEdited; + } + + @Override + public Type lastLoaded() { + return lastLoaded; + } + + @Override + public Type lastUnloaded() { + return lastUnloaded; + } + + public void setWorldName(String worldName) { + this.worldName = worldName; + } + + @Override + public Map> getAllData() { + return data; + } + + public static class WorldDataBuilder { + + private final String worldName; + + private String customSpawn = ""; + private String permission = "-"; + private String project = "-"; + private Difficulty difficulty = Default.difficulty; + private XMaterial material = XMaterial.GRASS_BLOCK; + private BuildWorldStatus status = BuildWorldStatus.NOT_STARTED; + private boolean blockBreaking = Settings.blockBreaking; + private boolean blockInteractions = Settings.blockInteractions; + private boolean blockPlacement = Settings.blockPlacement; + private boolean buildersEnabled = BuildersEnabled.publicBuilders; + private boolean explosions = Settings.explosions; + private boolean mobAi = Settings.mobAi; + private boolean physics = Settings.physics; + private boolean privateWorld = false; + private int timeSinceBackup = 0; + private long lastEdited = -1L; + private long lastLoaded = -1L; + private long lastUnloaded = -1L; + + /** + * Creates a new builder for {@link WorldData}. + * + * @param worldName The name of the world, which is required. + */ + public WorldDataBuilder(String worldName) { + this.worldName = Objects.requireNonNull(worldName, "World name cannot be null"); + } + + /** + * Builds the final {@link WorldDataImpl} instance. + * + * @return A new, immutable {@link WorldDataImpl} object + */ + public WorldDataImpl build() { + return new WorldDataImpl(this); + } + + public WorldDataBuilder withCustomSpawn(String customSpawn) { + this.customSpawn = customSpawn; + return this; + } + + public WorldDataBuilder withPermission(String permission) { + this.permission = permission; + return this; + } + + public WorldDataBuilder withProject(String project) { + this.project = project; + return this; + } + + public WorldDataBuilder withDifficulty(Difficulty difficulty) { + this.difficulty = difficulty; + return this; + } + + public WorldDataBuilder withMaterial(XMaterial material) { + this.material = material; + return this; + } + + public WorldDataBuilder withStatus(BuildWorldStatus status) { + this.status = status; + return this; + } + + public WorldDataBuilder withBlockBreaking(boolean blockBreaking) { + this.blockBreaking = blockBreaking; + return this; + } + + public WorldDataBuilder withBlockInteractions(boolean blockInteractions) { + this.blockInteractions = blockInteractions; + return this; + } + + public WorldDataBuilder withBlockPlacement(boolean blockPlacement) { + this.blockPlacement = blockPlacement; + return this; + } + + public WorldDataBuilder withBuildersEnabled(boolean buildersEnabled) { + this.buildersEnabled = buildersEnabled; + return this; + } + + public WorldDataBuilder withExplosions(boolean explosions) { + this.explosions = explosions; + return this; + } + + public WorldDataBuilder withMobAi(boolean mobAi) { + this.mobAi = mobAi; + return this; + } + + public WorldDataBuilder withPhysics(boolean physics) { + this.physics = physics; + return this; + } + + public WorldDataBuilder withPrivateWorld(boolean privateWorld) { + this.privateWorld = privateWorld; + return this; + } + + public WorldDataBuilder withTimeSinceBackup(int timeSinceBackup) { + this.timeSinceBackup = timeSinceBackup; + return this; + } + + public WorldDataBuilder withLastEdited(long lastEdited) { + this.lastEdited = lastEdited; + return this; + } + + public WorldDataBuilder withLastLoaded(long lastLoaded) { + this.lastLoaded = lastLoaded; + return this; + } + + public WorldDataBuilder withLastUnloaded(long lastUnloaded) { + this.lastUnloaded = lastUnloaded; + return this; + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/type/ConfigurableType.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/type/ConfigurableType.java new file mode 100644 index 0000000..e624657 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/data/type/ConfigurableType.java @@ -0,0 +1,119 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.data.type; + +import de.eintosti.buildsystem.api.data.Capability; +import de.eintosti.buildsystem.api.data.Overridable; +import de.eintosti.buildsystem.api.data.Type; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import org.jspecify.annotations.NullMarked; + +/** + * A single, concrete implementation of {@link Type} that uses a composition-based {@link Capability} model. + * + * @param The type of the value held. + */ +@NullMarked +public class ConfigurableType implements Type { + + private T value; + private Function configFormatter = (value) -> (Object) value; + + private final Map, Capability> capabilities = new HashMap<>(); + + /** + * Creates a simple type. + */ + public ConfigurableType(T defaultValue) { + this.value = defaultValue; + } + + /** + * Sets a custom config formatter for this type. + * + * @param configFormatter A function that formats the value for config storage + * @return This object, for fluent chaining + */ + public ConfigurableType withConfigFormatter(Function configFormatter) { + this.configFormatter = configFormatter; + return this; + } + + /** + * Attaches a new capability to this type. + * + * @param capabilityType The class of the capability + * @param capabilityInstance The instance of the capability + * @return This object, for fluent chaining + */ + public ConfigurableType withCapability(Class capabilityType, C capabilityInstance) { + this.capabilities.put(capabilityType, capabilityInstance); + return this; + } + + /** + * Checks if this type has a specific capability. + * + * @param capability The class of the capability + * @return {@code true} if the capability is present, {@code false} otherwise + */ + public boolean hasCapability(Class capability) { + return this.capabilities.containsKey(capability); + } + + /** + * Gets the capability instance if it exists. + * + * @param clazz The class of the capability + * @return An optional containing the capability, or empty + */ + public Optional getCapability(Class clazz) { + return Optional.ofNullable(this.capabilities.get(clazz)).map(clazz::cast); + } + + @Override + @SuppressWarnings({"unchecked"}) + public T get() { + Optional override = getCapability(Overridable.class) + .map(raw -> (Overridable) raw) + .filter(overridable -> overridable.isEnabled().getAsBoolean()) + .map(overridable -> overridable.provider().get()); + + return override.orElse(this.value); + } + + /** + * Sets the base value for this type. + *

    + * Note: This sets the underlying value. If an {@link Overridable} capability is active, {@link #get()} will still return the overridden value. + * + * @param value The new base value + */ + @Override + public void set(T value) { + this.value = value; + } + + @Override + public Object getConfigFormat() { + return this.configFormatter.apply(this.get()); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/CustomizableIcons.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/CustomizableIcons.java new file mode 100644 index 0000000..41cc748 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/CustomizableIcons.java @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.display; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.storage.yaml.YamlSetupStorage; +import java.util.EnumMap; +import java.util.Locale; +import java.util.Map; +import org.jspecify.annotations.NullMarked; + +/** + * User-customizable icons for different areas of the plugin. + */ +@NullMarked +public class CustomizableIcons { + + private final YamlSetupStorage setupStorage; + + private final Map typeIcons; + private final Map statusIcons; + + public CustomizableIcons(BuildSystemPlugin plugin) { + this.setupStorage = new YamlSetupStorage(plugin); + + this.typeIcons = loadTypeIcons(); + this.statusIcons = loadStatusIcons(); + } + + private Map loadTypeIcons() { + Map typeIcons = new EnumMap<>(Map.ofEntries( + Map.entry(BuildWorldType.NORMAL, XMaterial.OAK_LOG), + Map.entry(BuildWorldType.FLAT, XMaterial.GRASS_BLOCK), + Map.entry(BuildWorldType.NETHER, XMaterial.NETHERRACK), + Map.entry(BuildWorldType.END, XMaterial.END_STONE), + Map.entry(BuildWorldType.VOID, XMaterial.GLASS), + Map.entry(BuildWorldType.CUSTOM, XMaterial.FILLED_MAP), + Map.entry(BuildWorldType.TEMPLATE, XMaterial.FILLED_MAP), + Map.entry(BuildWorldType.IMPORTED, XMaterial.FURNACE) + )); + + Map loadedIcons = this.setupStorage.loadIcons(IconType.TYPE, type -> BuildWorldType.valueOf(type.toUpperCase(Locale.ROOT))); + if (loadedIcons != null) { + typeIcons.putAll(loadedIcons); + } + + setupStorage.saveIcons(IconType.TYPE, typeIcons); + return typeIcons; + } + + private Map loadStatusIcons() { + Map statusIcon = new EnumMap<>(Map.ofEntries( + Map.entry(BuildWorldStatus.NOT_STARTED, XMaterial.RED_DYE), + Map.entry(BuildWorldStatus.IN_PROGRESS, XMaterial.ORANGE_DYE), + Map.entry(BuildWorldStatus.ALMOST_FINISHED, XMaterial.LIME_DYE), + Map.entry(BuildWorldStatus.FINISHED, XMaterial.GREEN_DYE), + Map.entry(BuildWorldStatus.ARCHIVE, XMaterial.CYAN_DYE), + Map.entry(BuildWorldStatus.HIDDEN, XMaterial.BONE_MEAL) + )); + + Map loadedIcons = this.setupStorage.loadIcons(IconType.STATUS, type -> BuildWorldStatus.valueOf(type.toUpperCase(Locale.ROOT))); + if (loadedIcons != null) { + statusIcon.putAll(loadedIcons); + } + + setupStorage.saveIcons(IconType.STATUS, statusIcon); + return statusIcon; + } + + /** + * Gets the icon for a specific {@link BuildWorldType}. + * + * @param type The world type + * @return The material to use as an icon + */ + public XMaterial getIcon(BuildWorldType type) { + return this.typeIcons.get(type); + } + + /** + * Gets the icon for a specific {@link BuildWorldStatus}. + * + * @param status The world status + * @return The material to use as an icon + */ + public XMaterial getIcon(BuildWorldStatus status) { + return this.statusIcons.get(status); + } + + /** + * Sets a custom icon for a {@link BuildWorldType}. + * + * @param type The world type + * @param material The material to use as an icon + */ + public void setIcon(BuildWorldType type, XMaterial material) { + this.typeIcons.put(type, material); + this.setupStorage.saveIcon(IconType.TYPE, type, material); + } + + /** + * Sets a custom icon for a {@link BuildWorldStatus}. + * + * @param status The world status + * @param material The material to use as an icon + */ + public void setIcon(BuildWorldStatus status, XMaterial material) { + this.statusIcons.put(status, material); + this.setupStorage.saveIcon(IconType.STATUS, status, material); + } + + public enum IconType { + TYPE, + STATUS; + + public String getKey() { + return name().toLowerCase(Locale.ROOT); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/FolderImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/FolderImpl.java new file mode 100644 index 0000000..d9d8d6a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/display/FolderImpl.java @@ -0,0 +1,271 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.display; + +import com.cryptomorin.xseries.XMaterial; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.api.world.util.WorldPermissions; +import de.eintosti.buildsystem.world.util.WorldPermissionsImpl; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class FolderImpl implements Folder { + + private final String name; + private final Builder creator; + private final long creation; + private final NavigatorCategory category; + private final List worlds; + private final List subfolders; + + @Nullable + private Folder parent; + private XMaterial material; + private String permission; + private String project; + + public FolderImpl(String name, NavigatorCategory category, @Nullable Folder parent, Builder creator) { + this(name, System.currentTimeMillis(), category, parent, creator, XMaterial.CHEST, "-", "-", new ArrayList<>(), new ArrayList<>()); + } + + public FolderImpl( + String name, + long creation, + NavigatorCategory category, + @Nullable Folder parent, + Builder creator, + XMaterial material, + String permission, + String project, + List worlds, + List subfolders + ) { + this.name = name; + this.creation = creation; + this.category = category; + this.parent = parent; + if (parent != null && !parent.getSubFolders().contains(this)) { + ((FolderImpl) parent).subfolders.add(this); + } + this.creator = creator; + this.worlds = new ArrayList<>(worlds); + this.material = material; + this.permission = permission; + this.project = project; + this.subfolders = subfolders; + } + + @Override + public String getName() { + return this.name; + } + + @Override + public String getDisplayName(Player player) { + return Messages.getString("folder_item_title", player, + Map.entry("%folder%", name) + ); + } + + @Override + public long getCreation() { + return creation; + } + + @Override + public Builder getCreator() { + return this.creator; + } + + @Override + public XMaterial getIcon() { + return this.material; + } + + @Override + public void setIcon(XMaterial material) { + this.material = material; + } + + @Override + @Contract("_ -> new") + public List getLore(Player player) { + return new ArrayList<>(Messages.getStringList("folder_item_lore", player, + Map.entry("%permission%", this.permission), + Map.entry("%project%", this.project), + Map.entry("%worlds%", String.valueOf(getWorldCount()))) + ); + } + + @Override + public NavigatorCategory getCategory() { + return this.category; + } + + @Override + @Nullable + public Folder getParent() { + return this.parent; + } + + @Override + public void setParent(@Nullable Folder parent) { + if (parent != null && this.category != parent.getCategory()) { + throw new IllegalArgumentException("Cannot set parent folder: category mismatch (expected: %s, found: %s)".formatted(this.category, parent.getCategory())); + } + + if (parent != null && !parent.getSubFolders().contains(this)) { + ((FolderImpl) parent).subfolders.add(this); + } else if (parent == null && this.parent != null) { + ((FolderImpl) this.parent).subfolders.remove(this); + } + + this.parent = parent; + } + + @Override + public boolean hasParent() { + return this.parent != null; + } + + @Override + @Unmodifiable + public List getWorldUUIDs() { + return Collections.unmodifiableList(this.worlds); + } + + @Override + public boolean containsWorld(BuildWorld buildWorld) { + return containsWorld(buildWorld.getUniqueId()); + } + + @Override + public boolean containsWorld(UUID uuid) { + return this.worlds.contains(uuid); + } + + @Override + public void addWorld(BuildWorld buildWorld) { + if (containsWorld(buildWorld)) { + return; + } + this.worlds.add(buildWorld.getUniqueId()); + buildWorld.setFolder(this); + } + + @Override + public void removeWorld(BuildWorld buildWorld) { + removeWorld(buildWorld.getUniqueId()); + } + + @Override + public void removeWorld(UUID uuid) { + if (!containsWorld(uuid)) { + return; + } + + this.worlds.remove(uuid); + BuildWorld buildWorld = BuildSystemPlugin.get().getWorldService().getWorldStorage().getBuildWorld(uuid); + if (buildWorld != null) { + buildWorld.setFolder(null); + } + } + + @Override + @Unmodifiable + public List getSubFolders() { + return Collections.unmodifiableList(this.subfolders); + } + + @Override + public int getWorldCount() { + int total = this.worlds.size(); + for (Folder subfolder : this.subfolders) { + total += subfolder.getWorldCount(); + } + return total; + } + + @Override + public String getPermission() { + return this.permission; + } + + @Override + public void setPermission(String permission) { + this.permission = permission; + } + + @Override + public String getProject() { + return project; + } + + @Override + public void setProject(String project) { + this.project = project; + } + + @Override + public boolean canView(Player player) { + // We can pass null as a world since we are only checking for bypass permissions + WorldPermissions permissions = WorldPermissionsImpl.of(null); + if (permissions.hasAdminPermission(player) || permissions.canBypassViewPermission(player)) { + return true; + } + + if (this.permission.equals("-")) { + return true; + } + + return player.hasPermission(this.permission); + } + + @Override + public boolean equals(Object other) { + if (this == other) { + return true; + } + + if (other == null || getClass() != other.getClass()) { + return false; + } + + FolderImpl folder = (FolderImpl) other; + return name.equals(folder.name); + } + + @Override + public int hashCode() { + return name.hashCode(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/DeleteInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/DeleteInventory.java new file mode 100644 index 0000000..e0206f7 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/DeleteInventory.java @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.modification; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.util.inventory.BuildWorldHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import java.util.Map; +import java.util.stream.IntStream; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class DeleteInventory implements InventoryHandler { + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + + public DeleteInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + } + + public void openInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = getInventory(player, buildWorld); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = new DeleteInventoryHolder(buildWorld, player).getInventory(); + fillGuiWithGlass(inventory); + + inventory.setItem(11, InventoryUtils.createItem(XMaterial.LIME_DYE, + Messages.getString("delete_world_confirm", player)) + ); + inventory.setItem(13, InventoryUtils.createItem(XMaterial.FILLED_MAP, + Messages.getString("delete_world_name", player, Map.entry("%world%", buildWorld.getName())), + Messages.getStringList("delete_world_name_lore", player) + )); + inventory.setItem(15, InventoryUtils.createItem(XMaterial.RED_DYE, + Messages.getString("delete_world_cancel", player)) + ); + + return inventory; + } + + private void fillGuiWithGlass(Inventory inventory) { + final int[] greenSlots = {0, 1, 2, 3, 9, 10, 12, 18, 19, 20, 21}; + final int[] blackSlots = {4, 22}; + final int[] redSlots = {5, 6, 7, 8, 14, 16, 17, 23, 24, 25, 26}; + + IntStream.of(greenSlots).forEach(slot -> inventory.setItem(slot, InventoryUtils.createItem(XMaterial.LIME_STAINED_GLASS_PANE, "§f"))); + IntStream.of(blackSlots).forEach(slot -> inventory.setItem(slot, InventoryUtils.createItem(XMaterial.BLACK_STAINED_GLASS_PANE, "§f"))); + IntStream.of(redSlots).forEach(slot -> inventory.setItem(slot, InventoryUtils.createItem(XMaterial.RED_STAINED_GLASS_PANE, "§f"))); + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof DeleteInventoryHolder holder)) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + BuildWorld buildWorld = holder.getBuildWorld(); + + switch (event.getSlot()) { + case 11 -> { + XSound.ENTITY_PLAYER_LEVELUP.play(player); + player.closeInventory(); + plugin.getWorldService().deleteWorld(player, buildWorld); + } + case 15 -> { + XSound.ENTITY_ZOMBIE_BREAK_WOODEN_DOOR.play(player); + player.closeInventory(); + Messages.sendMessage(player, "worlds_delete_canceled", Map.entry("%world%", buildWorld.getName())); + } + } + } + + private static class DeleteInventoryHolder extends BuildWorldHolder { + + public DeleteInventoryHolder(BuildWorld buildWorld, Player player) { + super(buildWorld, 27, Messages.getString("delete_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/EditInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/EditInventory.java new file mode 100644 index 0000000..7c6d9d9 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/EditInventory.java @@ -0,0 +1,450 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.modification; + +import com.cryptomorin.xseries.XEnchantment; +import com.cryptomorin.xseries.XEntityType; +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import com.google.common.collect.Sets; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.command.subcommand.worlds.SetPermissionSubCommand; +import de.eintosti.buildsystem.command.subcommand.worlds.SetProjectSubCommand; +import de.eintosti.buildsystem.config.Config.World.Default; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.util.inventory.BuildWorldHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.world.builder.BuilderInventory; +import de.eintosti.buildsystem.world.data.StatusInventory; +import de.eintosti.buildsystem.world.data.WorldDataImpl; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.IntStream; +import org.bukkit.Bukkit; +import org.bukkit.ChatColor; +import org.bukkit.Difficulty; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class EditInventory implements InventoryHandler { + + /** + * A set of entities which are ignored when the butcher item is used. + */ + private static final Set IGNORED_ENTITIES = Sets.newHashSet( + XEntityType.ARMOR_STAND, XEntityType.END_CRYSTAL, XEntityType.ITEM_FRAME, + XEntityType.FALLING_BLOCK, XEntityType.MINECART, XEntityType.CHEST_MINECART, + XEntityType.COMMAND_BLOCK_MINECART, XEntityType.FURNACE_MINECART, + XEntityType.HOPPER_MINECART, XEntityType.SPAWNER_MINECART, XEntityType.TNT_MINECART, + XEntityType.PLAYER + ); + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + private final PlayerServiceImpl playerManager; + + public EditInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + this.playerManager = plugin.getPlayerService(); + } + + public void openInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = getInventory(player, buildWorld); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = new EditInventoryHolder(buildWorld, player).getInventory(); + WorldData worldData = buildWorld.getData(); + + fillGuiWithGlass(player, inventory); + addBuildWorldInfoItem(player, inventory, buildWorld); + + addSettingsItem(player, inventory, 20, XMaterial.OAK_PLANKS, + worldData.blockBreaking().get(), "worldeditor_blockbreaking_item", "worldeditor_blockbreaking_lore" + ); + addSettingsItem(player, inventory, 21, XMaterial.POLISHED_ANDESITE, + worldData.blockPlacement().get(), "worldeditor_blockplacement_item", "worldeditor_blockplacement_lore" + ); + addSettingsItem(player, inventory, 22, XMaterial.SAND, + worldData.physics().get(), "worldeditor_physics_item", "worldeditor_physics_lore" + ); + addTimeItem(player, inventory, buildWorld); + addSettingsItem(player, inventory, 24, XMaterial.TNT, + worldData.explosions().get(), "worldeditor_explosions_item", "worldeditor_explosions_lore" + ); + inventory.setItem(29, InventoryUtils.createItem(XMaterial.DIAMOND_SWORD, + Messages.getString("worldeditor_butcher_item", player), Messages.getStringList("worldeditor_butcher_lore", player) + )); + addBuildersItem(player, inventory, buildWorld); + addSettingsItem(player, inventory, 31, XMaterial.ARMOR_STAND, + worldData.mobAi().get(), "worldeditor_mobai_item", "worldeditor_mobai_lore" + ); + addVisibilityItem(player, inventory, buildWorld); + addSettingsItem(player, inventory, 33, XMaterial.TRIPWIRE_HOOK, + worldData.blockInteractions().get(), "worldeditor_blockinteractions_item", "worldeditor_blockinteractions_lore" + ); + inventory.setItem(38, InventoryUtils.createItem(XMaterial.FILLED_MAP, + Messages.getString("worldeditor_gamerules_item", player), Messages.getStringList("worldeditor_gamerules_lore", player) + )); + addDifficultyItem(player, inventory, buildWorld); + inventory.setItem(40, InventoryUtils.createItem(plugin.getCustomizableIcons().getIcon(worldData.status().get()), Messages.getString("worldeditor_status_item", player), + Messages.getStringList("worldeditor_status_lore", player, + Map.entry("%status%", Messages.getString(Messages.getMessageKey(buildWorld.getData().status().get()), player)) + ) + )); + inventory.setItem(41, InventoryUtils.createItem(XMaterial.ANVIL, Messages.getString("worldeditor_project_item", player), + Messages.getStringList("worldeditor_project_lore", player, + Map.entry("%project%", buildWorld.getData().project().get()) + ) + )); + inventory.setItem(42, InventoryUtils.createItem(XMaterial.PAPER, Messages.getString("worldeditor_permission_item", player), + Messages.getStringList("worldeditor_permission_lore", player, + Map.entry("%permission%", buildWorld.getData().permission().get()) + ) + )); + + return inventory; + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + IntStream.range(0, inventory.getSize()).forEach(i -> InventoryUtils.addGlassPane(player, inventory, i)); + } + + private void addBuildWorldInfoItem(Player player, Inventory inventory, BuildWorld buildWorld) { + String worldName = buildWorld.getName(); + String displayName = Messages.getString("worldeditor_world_item", player, Map.entry("%world%", worldName)); + XMaterial material = buildWorld.getData().material().get(); + + if (material == XMaterial.PLAYER_HEAD) { + InventoryUtils.addWorldItem(inventory, 4, buildWorld, displayName, new ArrayList<>()); + } else { + inventory.setItem(4, InventoryUtils.createItem(material, displayName)); + } + } + + private void addSettingsItem(Player player, Inventory inventory, int position, XMaterial material, boolean isEnabled, String displayNameKey, String loreKey) { + ItemStack itemStack = material.parseItem(); + ItemMeta itemMeta = itemStack.getItemMeta(); + + itemMeta.setDisplayName(Messages.getString(displayNameKey, player)); + itemMeta.setLore(Messages.getStringList(loreKey, player)); + itemMeta.addItemFlags(ItemFlag.values()); + + itemStack.setItemMeta(itemMeta); + if (isEnabled) { + itemStack.addUnsafeEnchantment(XEnchantment.UNBREAKING.get(), 1); + } + + inventory.setItem(position, itemStack); + } + + private void addTimeItem(Player player, Inventory inventory, BuildWorld buildWorld) { + XMaterial xMaterial; + String value; + switch (getWorldTime(buildWorld)) { + case NIGHT -> { + xMaterial = XMaterial.BLUE_STAINED_GLASS; + value = Messages.getString("worldeditor_time_lore_night", player); + } + case NOON -> { + xMaterial = XMaterial.YELLOW_STAINED_GLASS; + value = Messages.getString("worldeditor_time_lore_noon", player); + } + default -> { + xMaterial = XMaterial.ORANGE_STAINED_GLASS; + value = Messages.getString("worldeditor_time_lore_sunrise", player); + } + } + + inventory.setItem(23, InventoryUtils.createItem(xMaterial, Messages.getString("worldeditor_time_item", player), + Messages.getStringList("worldeditor_time_lore", player, Map.entry("%time%", value)) + )); + } + + public Time getWorldTime(BuildWorld buildWorld) { + int worldTime = (int) buildWorld.getWorld().getTime(); + int noonTime = Default.Time.noon; + + if (worldTime >= 0 && worldTime < noonTime) { + return Time.SUNRISE; + } else if (worldTime >= noonTime && worldTime < 13000) { + return Time.NOON; + } else { + return Time.NIGHT; + } + } + + private void addBuildersItem(Player player, Inventory inventory, BuildWorld buildWorld) { + if (buildWorld.getBuilders().isCreator(player) || player.hasPermission(BuildSystemPlugin.ADMIN_PERMISSION)) { + addSettingsItem(player, inventory, 30, XMaterial.IRON_PICKAXE, buildWorld.getData().buildersEnabled().get(), + "worldeditor_builders_item", "worldeditor_builders_lore" + ); + } else { + inventory.setItem(30, InventoryUtils.createItem(XMaterial.BARRIER, + Messages.getString("worldeditor_builders_not_creator_item", player), + Messages.getStringList("worldeditor_builders_not_creator_lore", player) + )); + } + } + + private void addVisibilityItem(Player player, Inventory inventory, BuildWorld buildWorld) { + int slot = 32; + String displayName = Messages.getString("worldeditor_visibility_item", player); + boolean isPrivate = buildWorld.getData().privateWorld().get(); + + if (!playerManager.canCreateWorld(player, Visibility.matchVisibility(isPrivate))) { + inventory.setItem(slot, InventoryUtils.createItem(XMaterial.BARRIER, "§c§m" + ChatColor.stripColor(displayName))); + return; + } + + XMaterial xMaterial = XMaterial.ENDER_EYE; + List lore = Messages.getStringList("worldeditor_visibility_lore_public", player); + + if (isPrivate) { + xMaterial = XMaterial.ENDER_PEARL; + lore = Messages.getStringList("worldeditor_visibility_lore_private", player); + } + + inventory.setItem(slot, InventoryUtils.createItem(xMaterial, displayName, lore)); + } + + private void addDifficultyItem(Player player, Inventory inventory, BuildWorld buildWorld) { + XMaterial material = switch (buildWorld.getData().difficulty().get()) { + case EASY -> XMaterial.GOLDEN_HELMET; + case NORMAL -> XMaterial.IRON_HELMET; + case HARD -> XMaterial.DIAMOND_HELMET; + default -> XMaterial.LEATHER_HELMET; + }; + + inventory.setItem(39, InventoryUtils.createItem(material, + Messages.getString("worldeditor_difficulty_item", player), + Messages.getStringList("worldeditor_difficulty_lore", player, + Map.entry("%difficulty%", getDifficultyName(buildWorld, player)) + ) + )); + } + + /** + * Get the display name of a {@link Difficulty}. + * + * @param player The player to parse the placeholders against + * @return The difficulty's display name + * @see WorldDataImpl#difficulty() + */ + private String getDifficultyName(BuildWorld buildWorld, Player player) { + return switch (buildWorld.getData().difficulty().get()) { + case PEACEFUL -> Messages.getString("difficulty_peaceful", player); + case EASY -> Messages.getString("difficulty_easy", player); + case NORMAL -> Messages.getString("difficulty_normal", player); + case HARD -> Messages.getString("difficulty_hard", player); + }; + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof EditInventoryHolder holder)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null || itemStack.getType() == Material.AIR || !itemStack.hasItemMeta()) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + BuildWorld buildWorld = holder.getBuildWorld(); + + WorldData worldData = buildWorld.getData(); + switch (event.getSlot()) { + case 20 -> { + if (hasPermission(player, "buildsystem.edit.breaking")) { + worldData.blockBreaking().set(!worldData.blockBreaking().get()); + } + } + case 21 -> { + if (hasPermission(player, "buildsystem.edit.placement")) { + worldData.blockPlacement().set(!worldData.blockPlacement().get()); + } + } + case 22 -> { + if (hasPermission(player, "buildsystem.edit.physics")) { + worldData.physics().set(!worldData.physics().get()); + } + } + case 23 -> { + if (hasPermission(player, "buildsystem.edit.time")) { + changeTime(player, buildWorld); + } + } + case 24 -> { + if (hasPermission(player, "buildsystem.edit.explosions")) { + worldData.explosions().set(!worldData.explosions().get()); + } + } + case 29 -> { + if (hasPermission(player, "buildsystem.edit.entities")) { + removeEntities(player, buildWorld); + } + } + case 30 -> { + if (itemStack.getType() == XMaterial.BARRIER.get()) { + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + if (!hasPermission(player, "buildsystem.edit.builders")) { + return; + } + if (event.isRightClick()) { + XSound.BLOCK_CHEST_OPEN.play(player); + new BuilderInventory(plugin).openInventory(buildWorld, player); + return; + } + worldData.buildersEnabled().set(!worldData.buildersEnabled().get()); + } + case 31 -> { + if (hasPermission(player, "buildsystem.edit.mobai")) { + worldData.mobAi().set(!worldData.mobAi().get()); + } + } + case 32 -> { + if (itemStack.getType() == XMaterial.BARRIER.get()) { + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + if (hasPermission(player, "buildsystem.edit.visibility")) { + worldData.privateWorld().set(!worldData.privateWorld().get()); + } + } + case 33 -> { + if (hasPermission(player, "buildsystem.edit.interactions")) { + worldData.blockInteractions().set(!worldData.blockInteractions().get()); + } + } + case 38 -> { + if (hasPermission(player, "buildsystem.edit.gamerules")) { + XSound.BLOCK_CHEST_OPEN.play(player); + new GameRulesInventory(plugin).openInventory(player, buildWorld); + } + return; + } + case 39 -> { + if (hasPermission(player, "buildsystem.edit.difficulty")) { + Difficulty newDifficulty = buildWorld.cycleDifficulty(); + buildWorld.getWorld().setDifficulty(newDifficulty); + } + } + case 40 -> { + if (hasPermission(player, "buildsystem.edit.status")) { + XSound.ENTITY_CHICKEN_EGG.play(player); + new StatusInventory(plugin).openInventory(player, buildWorld); + } + return; + } + case 41 -> { + if (hasPermission(player, "buildsystem.edit.project")) { + XSound.ENTITY_CHICKEN_EGG.play(player); + new SetProjectSubCommand(plugin, buildWorld.getName()).getProjectInput(player, buildWorld, false); + } + return; + } + case 42 -> { + if (hasPermission(player, "buildsystem.edit.permission")) { + XSound.ENTITY_CHICKEN_EGG.play(player); + new SetPermissionSubCommand(plugin, buildWorld.getName()).getPermissionInput(player, buildWorld, false); + } + return; + } + default -> { + return; + } + } + + XSound.ENTITY_CHICKEN_EGG.play(player); + openInventory(player, buildWorld); + } + + private boolean hasPermission(Player player, String permission) { + if (player.hasPermission(permission)) { + return true; + } + player.closeInventory(); + Messages.sendPermissionError(player); + XSound.ENTITY_ITEM_BREAK.play(player); + return false; + } + + private void changeTime(Player player, BuildWorld buildWorld) { + int time = switch (getWorldTime(buildWorld)) { + case SUNRISE -> Default.Time.noon; + case NOON -> Default.Time.night; + case NIGHT -> Default.Time.sunrise; + }; + buildWorld.getWorld().setTime(time); + openInventory(player, buildWorld); + } + + private void removeEntities(Player player, BuildWorld buildWorld) { + World bukkitWorld = Bukkit.getWorld(buildWorld.getName()); + if (bukkitWorld == null) { + return; + } + + AtomicInteger entitiesRemoved = new AtomicInteger(); + bukkitWorld.getEntities().stream() + .filter(entity -> !IGNORED_ENTITIES.contains(XEntityType.of(entity))) + .forEach(entity -> { + entity.remove(); + entitiesRemoved.incrementAndGet(); + }); + + player.closeInventory(); + Messages.sendMessage(player, "worldeditor_butcher_removed", Map.entry("%amount%", entitiesRemoved.get())); + } + + public enum Time { + SUNRISE, NOON, NIGHT + } + + private static class EditInventoryHolder extends BuildWorldHolder { + + public EditInventoryHolder(BuildWorld buildWorld, Player player) { + super(buildWorld, 54, Messages.getString("worldeditor_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRuleEntry.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRuleEntry.java new file mode 100644 index 0000000..ec53f91 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRuleEntry.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.modification; + +import org.bukkit.GameRule; + +/** + * Represents a game rule entry consisting of a {@link GameRule} and its value. + * + * @param rule The game rule + * @param value The value of the game rule + * @param The type of the game rule value; must be either {@link Boolean} or {@link Integer} + */ +public record GameRuleEntry(GameRule rule, T value) { + +} + diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRulesInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRulesInventory.java new file mode 100644 index 0000000..fae27ee --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/GameRulesInventory.java @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package de.eintosti.buildsystem.world.modification; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.util.inventory.BuildWorldHolder; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.util.inventory.PaginatedInventory; +import java.util.Arrays; +import java.util.List; +import java.util.Objects; +import org.bukkit.ChatColor; +import org.bukkit.GameRule; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class GameRulesInventory extends PaginatedInventory { + + private static final int[] SLOTS = {11, 12, 13, 14, 15, 20, 21, 22, 23, 24, 29, 30, 31, 32, 33}; + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + + private int numGameRules = 0; + + public GameRulesInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + } + + public void openInventory(Player player, BuildWorld buildWorld) { + Inventory inventory = getInventory(buildWorld, player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + public Inventory getInventory(BuildWorld buildWorld, Player player) { + addGameRules(buildWorld, player); + Inventory inventory = inventories[getInvIndex(player)]; + fillGuiWithGlass(player, inventory); + return inventory; + } + + public void addGameRules(BuildWorld buildWorld, Player player) { + World world = buildWorld.getWorld(); + if (world == null) { + player.closeInventory(); + plugin.getLogger().severe("World '" + buildWorld.getName() + "' does not exist."); + return; + } + + this.numGameRules = world.getGameRules().length; + int numPages = calculateNumPages(this.numGameRules, SLOTS.length); + inventories = new Inventory[numPages]; + Inventory inventory = createInventory(buildWorld, player); + + int index = 0; + inventories[index] = inventory; + + int columnGameRule = 0, maxColumnGameRule = 14; + for (String gameRuleName : world.getGameRules()) { + addGameRuleItem(inventory, SLOTS[columnGameRule++], world, gameRuleName, player); + + if (columnGameRule > maxColumnGameRule) { + columnGameRule = 0; + inventory = createInventory(buildWorld, player); + inventories[++index] = inventory; + } + } + } + + @Contract("_, _ -> new") + private Inventory createInventory(BuildWorld buildWorld, Player player) { + return new GameRulesInventoryHolder(buildWorld, player).getInventory(); + } + + private void addGameRuleItem(Inventory inventory, int slot, World world, String gameRuleName, Player player) { + GameRule gameRule = GameRule.getByName(gameRuleName); + if (gameRule == null) { + plugin.getLogger().severe("GameRule '" + gameRuleName + "' does not exist in world '" + world.getName() + "'."); + return; + } + + ItemStack itemStack = new ItemStack(isEnabled(world, gameRule) ? Material.FILLED_MAP : Material.MAP); + ItemMeta itemMeta = itemStack.getItemMeta(); + + itemMeta.setDisplayName(ChatColor.YELLOW + gameRule.getName()); + itemMeta.setLore(getLore(world, gameRule, player)); + itemMeta.addItemFlags(ItemFlag.values()); + itemStack.setItemMeta(itemMeta); + + inventory.setItem(slot, itemStack); + } + + private List getLore(World world, GameRule gameRule, Player player) { + List lore; + if (isOfType(gameRule, Boolean.class)) { + lore = isEnabled(world, gameRule) + ? Messages.getStringList("worldeditor_gamerules_boolean_enabled", player) + : Messages.getStringList("worldeditor_gamerules_boolean_disabled", player); + } else { + lore = Messages.getStringList("worldeditor_gamerules_integer", player).stream() + .map(line -> line.replace("%value%", world.getGameRuleValue(gameRule).toString())) + .toList(); + } + return lore; + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + for (int i = 0; i < inventory.getSize(); i++) { + if (!isValidSlot(i)) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + int invIndex = getInvIndex(player); + + if (numGameRules > 1 && invIndex > 0) { + inventory.setItem(36, InventoryUtils.createSkull(Messages.getString("gui_previous_page", player), Profileable.detect("f7aacad193e2226971ed95302dba433438be4644fbab5ebf818054061667fbe2"))); + } else { + InventoryUtils.addGlassPane(player, inventory, 36); + } + + if (numGameRules > 1 && invIndex < (numGameRules - 1)) { + inventory.setItem(44, InventoryUtils.createSkull(Messages.getString("gui_next_page", player), Profileable.detect("d34ef0638537222b20f480694dadc0f85fbe0759d581aa7fcdf2e43139377158"))); + } else { + InventoryUtils.addGlassPane(player, inventory, 44); + } + } + + public boolean isValidSlot(int slot) { + return Arrays.stream(SLOTS).anyMatch(i -> i == slot); + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof GameRulesInventoryHolder holder)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null || itemStack.getType() == Material.AIR || !itemStack.hasItemMeta()) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + BuildWorld buildWorld = holder.getBuildWorld(); + + switch (XMaterial.matchXMaterial(event.getCurrentItem())) { + case PLAYER_HEAD: + boolean pageChanged = false; + int slot = event.getSlot(); + if (slot == 36) { + pageChanged = decrementInv(player, this.numGameRules, SLOTS.length); + } else if (slot == 44) { + pageChanged = incrementInv(player, this.numGameRules, SLOTS.length); + } + if (!pageChanged) { + return; + } + break; + + case FILLED_MAP: + case MAP: + XSound.ENTITY_CHICKEN_EGG.play(player); + modifyGameRule(event, buildWorld.getWorld()); + break; + + default: + XSound.BLOCK_CHEST_OPEN.play(player); + new EditInventory(plugin).openInventory(player, buildWorld); + return; + } + + openInventory(player, buildWorld); + } + + private void modifyGameRule(InventoryClickEvent event, World world) { + int slot = event.getSlot(); + if (!isValidSlot(slot)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null) { + return; + } + + ItemMeta itemMeta = itemStack.getItemMeta(); + if (itemMeta == null || !itemMeta.hasDisplayName()) { + return; + } + + String rawName = ChatColor.stripColor(itemMeta.getDisplayName()); + GameRule gameRule = GameRule.getByName(rawName); + if (gameRule == null) { + plugin.getLogger().warning("GameRule '%s' does not exist in world '%s'.".formatted(rawName, world.getName())); + return; + } + + if (isOfType(gameRule, Boolean.class)) { + GameRule booleanRule = castRule(gameRule, Boolean.class); + boolean currentValue = Boolean.TRUE.equals(world.getGameRuleValue(booleanRule)); + world.setGameRule(booleanRule, !currentValue); + } else if (isOfType(gameRule, Integer.class)) { + GameRule integerRule = castRule(gameRule, Integer.class); + int value = world.getGameRuleValue(integerRule); + int delta = event.isShiftClick() + ? (event.isRightClick() ? 10 : event.isLeftClick() ? -10 : 0) + : (event.isRightClick() ? 1 : event.isLeftClick() ? -1 : 0); + world.setGameRule(integerRule, value + delta); + } else { + plugin.getLogger().warning("GameRule '%s' is not a boolean or integer type and cannot be modified.".formatted(gameRule.getName())); + } + } + + /** + * Casts a {@link GameRule} to the specified type if it matches. + * + * @param rule The game rule to cast + * @param type The type to cast the game rule to + * @param The type to cast the game rule to + * @return The casted game rule if it matches the type, or {@code null} if it does not match + */ + @SuppressWarnings("unchecked") + @Nullable + private static GameRule castRule(GameRule rule, Class type) { + return type.equals(rule.getType()) ? (GameRule) rule : null; + } + + /** + * Gets whether the given {@link GameRule} is of the given type. + * + * @param gameRule The game rule to check + * @param type The type to check against + * @param The type to check against + * @return {@code true} if the game rule is of the given type, {@code false} otherwise + */ + private static boolean isOfType(@Nullable GameRule gameRule, Class type) { + return gameRule != null && Objects.equals(gameRule.getType(), type); + } + + /** + * Checks if the {@link GameRule} is enabled in the given {@link World}. + *

    + * If a game rule is not a boolean type, it is considered enabled by default. + * + * @param world The world to check the game rule in + * @param gameRule The game rule to check + * @return {@code true} if the game rule is enabled, {@code false} otherwise + */ + private boolean isEnabled(World world, GameRule gameRule) { + GameRule booleanGameRule = castRule(gameRule, Boolean.class); + if (booleanGameRule != null) { + return Boolean.TRUE.equals(world.getGameRuleValue(booleanGameRule)); + } + return true; + } + + private static class GameRulesInventoryHolder extends BuildWorldHolder { + + public GameRulesInventoryHolder(BuildWorld buildWorld, Player player) { + super(buildWorld, 45, Messages.getString("worldeditor_gamerules_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/SetupInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/SetupInventory.java new file mode 100644 index 0000000..8961a46 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/modification/SetupInventory.java @@ -0,0 +1,179 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.modification; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.BuildWorldType; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.world.display.CustomizableIcons; +import java.util.Map; +import java.util.Optional; +import java.util.function.BiConsumer; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryAction; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryCloseEvent; +import org.bukkit.event.inventory.InventoryType; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class SetupInventory implements InventoryHandler { + + private static final Map CREATE_ITEM_SLOTS = Map.of( + BuildWorldType.NORMAL, 11, + BuildWorldType.FLAT, 12, + BuildWorldType.NETHER, 13, + BuildWorldType.END, 14, + BuildWorldType.VOID, 15, + BuildWorldType.IMPORTED, 16 + ); + + private static final Map STATUS_ITEM_SLOTS = Map.of( + BuildWorldStatus.NOT_STARTED, 20, + BuildWorldStatus.IN_PROGRESS, 21, + BuildWorldStatus.ALMOST_FINISHED, 22, + BuildWorldStatus.FINISHED, 23, + BuildWorldStatus.ARCHIVE, 24, + BuildWorldStatus.HIDDEN, 25 + ); + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + private final CustomizableIcons icons; + + public SetupInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + this.icons = plugin.getCustomizableIcons(); + } + + private Inventory getInventory(Player player) { + Inventory inventory = new SetupInventoryHolder(player).getInventory(); + fillGuiWithGlass(player, inventory); + + inventory.setItem(10, InventoryUtils.createSkull(Messages.getString("setup_default_item_name", player), Profileable.detect("d34ef0638537222b20f480694dadc0f85fbe0759d581aa7fcdf2e43139377158"), Messages.getStringList("setup_default_item_lore", player))); + inventory.setItem(19, InventoryUtils.createSkull(Messages.getString("setup_status_item_name", player), Profileable.detect("d34ef0638537222b20f480694dadc0f85fbe0759d581aa7fcdf2e43139377158"), Messages.getStringList("setup_status_item_name_lore", player))); + + inventory.setItem(11, InventoryUtils.createItem(icons.getIcon(BuildWorldType.NORMAL), Messages.getString("setup_normal_world", player))); + inventory.setItem(12, InventoryUtils.createItem(icons.getIcon(BuildWorldType.FLAT), Messages.getString("setup_flat_world", player))); + inventory.setItem(13, InventoryUtils.createItem(icons.getIcon(BuildWorldType.NETHER), Messages.getString("setup_nether_world", player))); + inventory.setItem(14, InventoryUtils.createItem(icons.getIcon(BuildWorldType.END), Messages.getString("setup_end_world", player))); + inventory.setItem(15, InventoryUtils.createItem(icons.getIcon(BuildWorldType.VOID), Messages.getString("setup_void_world", player))); + inventory.setItem(16, InventoryUtils.createItem(icons.getIcon(BuildWorldType.IMPORTED), Messages.getString("setup_imported_world", player))); + + inventory.setItem(20, InventoryUtils.createItem(icons.getIcon(BuildWorldStatus.NOT_STARTED), Messages.getString("status_not_started", player))); + inventory.setItem(21, InventoryUtils.createItem(icons.getIcon(BuildWorldStatus.IN_PROGRESS), Messages.getString("status_in_progress", player))); + inventory.setItem(22, InventoryUtils.createItem(icons.getIcon(BuildWorldStatus.ALMOST_FINISHED), Messages.getString("status_almost_finished", player))); + inventory.setItem(23, InventoryUtils.createItem(icons.getIcon(BuildWorldStatus.FINISHED), Messages.getString("status_finished", player))); + inventory.setItem(24, InventoryUtils.createItem(icons.getIcon(BuildWorldStatus.ARCHIVE), Messages.getString("status_archive", player))); + inventory.setItem(25, InventoryUtils.createItem(icons.getIcon(BuildWorldStatus.HIDDEN), Messages.getString("status_hidden", player))); + + return inventory; + } + + public void openInventory(Player player) { + Inventory inventory = getInventory(player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + for (int i = 0; i < inventory.getSize(); i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof SetupInventoryHolder)) { + return; + } + + InventoryAction action = event.getAction(); + switch (action) { + case PICKUP_ALL, PICKUP_ONE, PICKUP_SOME, PICKUP_HALF, PLACE_ALL, PLACE_SOME, PLACE_ONE, SWAP_WITH_CURSOR -> { + if (event.getInventory().getType() != InventoryType.CHEST) { + return; + } + + int slot = event.getRawSlot(); + event.setCancelled(slot < 36 || slot > 80); + + if (action != InventoryAction.SWAP_WITH_CURSOR) { + return; + } + + if (!(slot >= 36 && slot <= 80)) { + if ((slot >= 11 && slot <= 15) || (slot >= 20 && slot <= 25)) { + ItemStack itemStack = event.getCursor(); + event.setCurrentItem(itemStack); + event.getWhoClicked().setItemOnCursor(null); + } + } + } + default -> event.setCancelled(true); + } + } + + @Override + public void onClose(InventoryCloseEvent event) { + Inventory inventory = event.getInventory(); + if (!(inventory.getHolder() instanceof SetupInventoryHolder)) { + return; + } + + processIconMapping(inventory, CREATE_ITEM_SLOTS, icons::setIcon); + processIconMapping(inventory, STATUS_ITEM_SLOTS, icons::setIcon); + } + + /** + * A generic helper method that iterates over a map of Enum-to-Slot, extracts the {@link ItemStack}, and sets the corresponding icon. + * + * @param inventory The inventory to get items from + * @param slotMapping A map from an Enum constant to its inventory slot index + * @param The type of the Enum (e.g., {@link BuildWorldType}, {@link BuildWorldStatus}) + */ + private > void processIconMapping(Inventory inventory, Map slotMapping, BiConsumer setter) { + slotMapping.forEach((enumConstant, slot) -> { + XMaterial material = Optional.ofNullable(inventory.getItem(slot)) + .map(XMaterial::matchXMaterial) + .orElse(null); + if (material == null) { + plugin.getLogger().warning("Failed to set icon for " + enumConstant.name() + " in setup inventory. ItemStack is null or not a valid Material."); + return; + } + setter.accept(enumConstant, material); + }); + } + + private static class SetupInventoryHolder extends BuildSystemHolder { + + public SetupInventoryHolder(Player player) { + super(36, Messages.getString("setup_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/ArmorStandManager.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/ArmorStandManager.java new file mode 100644 index 0000000..2c641d4 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/ArmorStandManager.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator; + +import com.cryptomorin.xseries.profiles.builder.XSkull; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import org.bukkit.Location; +import org.bukkit.NamespacedKey; +import org.bukkit.entity.ArmorStand; +import org.bukkit.entity.Player; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.bukkit.plugin.java.JavaPlugin; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class ArmorStandManager { + + private static final JavaPlugin PLUGIN = JavaPlugin.getProvidingPlugin(ArmorStandManager.class); + private static final NamespacedKey OWNER_KEY = new NamespacedKey(PLUGIN, "owner"); + private static final NamespacedKey CATEGORY_KEY = new NamespacedKey(PLUGIN, "category"); + + private static final float RADIUS = 2.2f; + private static final float SPREAD = 90.0f; + + private final Map armorStands; + + public ArmorStandManager() { + this.armorStands = new HashMap<>(); + } + + @Nullable + public static NavigatorCategory matchNavigatorCategory(ArmorStand armorStand) { + String categoryName = armorStand.getPersistentDataContainer().get(CATEGORY_KEY, PersistentDataType.STRING); + return categoryName != null ? NavigatorCategory.valueOf(categoryName) : null; + } + + @Nullable + public static UUID getOwner(ArmorStand armorStand) { + String ownerUUID = armorStand.getPersistentDataContainer().get(OWNER_KEY, PersistentDataType.STRING); + return ownerUUID != null ? UUID.fromString(ownerUUID) : null; + } + + private Location calculatePosition(Player player, float angle) { + Location playerLocation = player.getLocation(); + float centerX = (float) playerLocation.getX(); + float centerZ = (float) playerLocation.getZ(); + float yaw = playerLocation.getYaw() + 180 + angle; + float xPos = RADIUS * (float) Math.cos(Math.toRadians(yaw - 90)) + centerX; + double yPos = playerLocation.getY() - 0.1; + float zPos = RADIUS * (float) Math.sin(Math.toRadians(yaw - 90)) + centerZ; + + Location location = new Location(player.getWorld(), xPos, yPos, zPos); + location.setYaw(yaw); + return location; + } + + private ArmorStand spawnArmorStand(Player player, Location location, NavigatorCategory category, boolean customSkull, String skullUrl) { + ArmorStand armorStand = location.getWorld().spawn(location, ArmorStand.class); + armorStand.setVisible(false); + armorStand.setGravity(false); + armorStand.setCanPickupItems(false); + armorStand.getEquipment().setHelmet( + XSkull.createItem() + .profile(Profileable.detect(customSkull ? skullUrl : player.getName())) + .apply() + ); + + PersistentDataContainer pdc = armorStand.getPersistentDataContainer(); + pdc.set(OWNER_KEY, PersistentDataType.STRING, player.getUniqueId().toString()); + pdc.set(CATEGORY_KEY, PersistentDataType.STRING, category.name()); + + return armorStand; + } + + public void spawnArmorStands(Player player) { + ArmorStand worldNavigator = spawnWorldNavigator(player); + ArmorStand worldArchive = spawnWorldArchive(player); + ArmorStand privateWorlds = spawnPrivateWorlds(player); + + this.armorStands.put(player.getUniqueId(), new ArmorStand[]{worldNavigator, worldArchive, privateWorlds}); + } + + private ArmorStand spawnWorldNavigator(Player player) { + Location navigatorLocation = calculatePosition(player, SPREAD / 2 * -1); + return spawnArmorStand(player, navigatorLocation, NavigatorCategory.PUBLIC, true, "d5c6dc2bbf51c36cfc7714585a6a5683ef2b14d47d8ff714654a893f5da622"); + } + + private ArmorStand spawnWorldArchive(Player player) { + Location archiveLocation = calculatePosition(player, 0); + return spawnArmorStand(player, archiveLocation, NavigatorCategory.ARCHIVE, true, "7f6bf958abd78295eed6ffc293b1aa59526e80f54976829ea068337c2f5e8"); + } + + private ArmorStand spawnPrivateWorlds(Player player) { + Location privateLocation = calculatePosition(player, SPREAD / 2); + return spawnArmorStand(player, privateLocation, NavigatorCategory.PRIVATE, false, player.getName()); + } + + public void removeArmorStands(Player player) { + ArmorStand[] armorStands = this.armorStands.remove(player.getUniqueId()); + if (armorStands == null) { + return; + } + + for (ArmorStand armorStand : armorStands) { + armorStand.remove(); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/ArchivedWorldsInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/ArchivedWorldsInventory.java new file mode 100644 index 0000000..5a43ce0 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/ArchivedWorldsInventory.java @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.inventory; + +import static de.eintosti.buildsystem.world.navigator.inventory.CreatableWorldsInventory.CREATE_FOLDER_PROFILE; + +import com.cryptomorin.xseries.profiles.objects.Profileable; +import com.google.common.collect.Sets; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class ArchivedWorldsInventory extends DisplayablesInventory { + + public ArchivedWorldsInventory(BuildSystemPlugin plugin, Player player) { + super( + plugin, + player, + NavigatorCategory.ARCHIVE, + Messages.getString("archive_title", player), + Messages.getString("archive_no_worlds", player), + Visibility.IGNORE, + Sets.newHashSet(BuildWorldStatus.ARCHIVE) + ); + } + + /** + * Overrides the base method to include the "create folder" item in the inventory page layout. + * + * @return A newly created {@link Inventory} page with common base items and creation options + */ + @Override + protected Inventory createBaseInventoryPage(String inventoryTitle) { + Inventory inventory = super.createBaseInventoryPage(inventoryTitle); + addFolderCreateItem(inventory, player); + return inventory; + } + + private void addFolderCreateItem(Inventory inventory, Player player) { + if (player.hasPermission("buildsystem.create.folder")) { + inventory.setItem(49, InventoryUtils.createSkull(Messages.getString("world_navigator_create_folder", player), Profileable.detect(CREATE_FOLDER_PROFILE))); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/CreatableWorldsInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/CreatableWorldsInventory.java new file mode 100644 index 0000000..c97a5c0 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/CreatableWorldsInventory.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.inventory; + +import com.cryptomorin.xseries.profiles.objects.Profileable; +import com.google.common.collect.Sets; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import java.util.Set; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * An abstract inventory class extending {@link DisplayablesInventory} that provides common functionality for inventories which allow the creation of new {@link BuildWorld}s and + * {@link Folder}s. + */ +@NullMarked +public abstract class CreatableWorldsInventory extends DisplayablesInventory { + + private static final Set VALID_STATUSES = Sets.newHashSet( + BuildWorldStatus.NOT_STARTED, + BuildWorldStatus.IN_PROGRESS, + BuildWorldStatus.ALMOST_FINISHED, + BuildWorldStatus.FINISHED + ); + + static final String CREATE_WORLD_PROFILE = "3edd20be93520949e6ce789dc4f43efaeb28c717ee6bfcbbe02780142f716"; + static final String CREATE_FOLDER_PROFILE = "69b861aabb316c4ed73b4e5428305782e735565ba2a053912e1efd834fa5a6f"; + + private final PlayerServiceImpl playerService; + + /** + * Constructs a new {@link CreatableWorldsInventory}. + * + * @param plugin The plugin instance + * @param player The player for whom this inventory is created + * @param inventoryTitle The inventory's title + * @param noWorldsMessage The "no worlds" message + * @param requiredVisibility The required {@link Visibility} for worlds to be displayed in this inventory + */ + protected CreatableWorldsInventory( + BuildSystemPlugin plugin, + Player player, + NavigatorCategory category, + String inventoryTitle, + @Nullable String noWorldsMessage, + Visibility requiredVisibility + ) { + super(plugin, player, category, inventoryTitle, noWorldsMessage, requiredVisibility, VALID_STATUSES); + this.playerService = plugin.getPlayerService(); + } + + /** + * Overrides the base method to include the "create world" and "create folder" items in the inventory page layout. + * + * @return A newly created {@link Inventory} page with common base items and creation options + */ + @Override + protected Inventory createBaseInventoryPage(String inventoryTitle) { + Inventory inventory = super.createBaseInventoryPage(inventoryTitle); + addWorldCreateItem(inventory, player); + addFolderCreateItem(inventory, player); + return inventory; + } + + private void addWorldCreateItem(Inventory inventory, Player player) { + if (!playerService.canCreateWorld(player, requiredVisibility)) { + return; + } + + if (player.hasPermission(getWorldCreationPermission())) { + inventory.setItem(48, InventoryUtils.createSkull(Messages.getString(getWorldCreationItemTitleKey(), player), Profileable.detect(CREATE_WORLD_PROFILE))); + } + } + + private void addFolderCreateItem(Inventory inventory, Player player) { + if (player.hasPermission("buildsystem.create.folder")) { + inventory.setItem(50, InventoryUtils.createSkull(Messages.getString("world_navigator_create_folder", player), Profileable.detect(CREATE_FOLDER_PROFILE))); + } + } + + /** + * Gets the specific permission string required for a player to create a {@link BuildWorld} of the type managed by this inventory (e.g., {@code buildsystem.create.private}, + * {@code buildsystem.create.public}). + * + * @return The permission string for creating a world of this inventory's type + */ + protected abstract String getWorldCreationPermission(); + + /** + * Gets the message key the title of the "create world" item. + * + * @return The message key for the "create world" item's title + */ + protected abstract String getWorldCreationItemTitleKey(); +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/DisplayablesInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/DisplayablesInventory.java new file mode 100644 index 0000000..dc781b9 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/DisplayablesInventory.java @@ -0,0 +1,595 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.inventory; + +import com.cryptomorin.xseries.XMaterial; +import com.cryptomorin.xseries.XSound; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.player.settings.Settings; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.api.world.display.Displayable; +import de.eintosti.buildsystem.api.world.display.Displayable.DisplayableType; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldDisplay; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldFilter; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldFilter.Mode; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldSort; +import de.eintosti.buildsystem.command.tabcomplete.WorldsTabCompleter.WorldsArgument; +import de.eintosti.buildsystem.player.PlayerServiceImpl; +import de.eintosti.buildsystem.player.settings.SettingsManager; +import de.eintosti.buildsystem.storage.FolderStorageImpl; +import de.eintosti.buildsystem.storage.WorldStorageImpl; +import de.eintosti.buildsystem.util.PlayerChatInput; +import de.eintosti.buildsystem.util.StringCleaner; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import de.eintosti.buildsystem.util.inventory.PaginatedInventory; +import de.eintosti.buildsystem.world.WorldServiceImpl; +import de.eintosti.buildsystem.world.creation.CreateInventory; +import de.eintosti.buildsystem.world.creation.CreateInventory.Page; +import de.eintosti.buildsystem.world.modification.EditInventory; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import org.bukkit.Bukkit; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.persistence.PersistentDataContainer; +import org.bukkit.persistence.PersistentDataType; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * An abstract inventory class for displaying {@link Displayable} objects such as {@link Folder}s and {@link BuildWorld}s. This class is designed to be instantiated once per player + * to manage their specific inventory view. + */ +@NullMarked +public abstract class DisplayablesInventory extends PaginatedInventory { + + private static final int MAX_WORLDS_PER_PAGE = 36; + private static final int FIRST_WORD_SLOT = 9; + private static final int LAST_WORLD_SLOT = 44; + + private static final String PREVIOUS_PAGE_SKULL_PROFILE = "86971dd881dbaf4fd6bcaa93614493c612f869641ed59d1c9363a3666a5fa6"; + private static final String NEXT_PAGE_SKULL_PROFILE = "f32ca66056b72863e98f7f32bd7d94c7a0d796af691c9ac3a9136331352288f9"; + private static final String NO_WORLDS_SKULL_PROFILE = "2e3f50ba62cbda3ecf5479b62fedebd61d76589771cc19286bf2745cd71e47c6"; + + protected final BuildSystemPlugin plugin; + protected final InventoryManager inventoryManager; + protected final PlayerServiceImpl playerService; + protected final SettingsManager settingsManager; + protected final FolderStorageImpl folderStorage; + protected final WorldStorageImpl worldStorage; + + protected final Player player; + protected final NavigatorCategory category; + protected final Visibility requiredVisibility; + protected final Set validStatuses; + private final String inventoryTitle; + @Nullable + private final String noWorldsMessage; + + @Nullable + private List cachedDisplayables; + @Nullable + private Inventory[] generatedInventories; + + /** + * Constructs a new {@link DisplayablesInventory} for a specific player. + * + * @param plugin The plugin instance + * @param player The player for whom this inventory is created + * @param category The category of the inventory, used for organizing folders + * @param inventoryTitle The inventory's title + * @param noWorldsMessage The "no worlds" message + * @param requiredVisibility The required visibility for worlds to be displayed + * @param validStatuses The set of valid statuses for worlds to be displayed + */ + protected DisplayablesInventory( + BuildSystemPlugin plugin, + Player player, + NavigatorCategory category, + String inventoryTitle, + @Nullable String noWorldsMessage, + Visibility requiredVisibility, + Set validStatuses + ) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + this.playerService = plugin.getPlayerService(); + this.settingsManager = plugin.getSettingsManager(); + WorldServiceImpl worldService = plugin.getWorldService(); + this.folderStorage = worldService.getFolderStorage(); + this.worldStorage = worldService.getWorldStorage(); + + this.player = player; + this.category = category; + this.inventoryTitle = inventoryTitle; + this.noWorldsMessage = noWorldsMessage; + this.requiredVisibility = requiredVisibility; + this.validStatuses = validStatuses; + generatedInventories = new Inventory[0]; + } + + /** + * Opens the inventory for the associated player. + */ + public void openInventory() { + if (this.generatedInventories.length == 0) { + initializeInventories(); + } + + Inventory inventory = generatedInventories[getInvIndex(player)]; + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + /** + * Initializes all inventory pages based on the current player settings and world data. + */ + private void initializeInventories() { + this.cachedDisplayables = collectDisplayables(); + int numDisplayableObjects = this.cachedDisplayables.size(); + + int numPages = calculateNumPages(numDisplayableObjects, MAX_WORLDS_PER_PAGE); + this.generatedInventories = new Inventory[numPages]; + + for (int pageIndex = 0; pageIndex < numPages; pageIndex++) { + Inventory currentPage = createBaseInventoryPage(this.inventoryTitle); + this.generatedInventories[pageIndex] = currentPage; + + if (numDisplayableObjects == 0 && this.noWorldsMessage != null) { + currentPage.setItem(22, InventoryUtils.createSkull(this.noWorldsMessage, Profileable.detect(NO_WORLDS_SKULL_PROFILE))); + continue; + } + + int startItemIndex = pageIndex * MAX_WORLDS_PER_PAGE; + int endItemIndex = Math.min(startItemIndex + MAX_WORLDS_PER_PAGE, numDisplayableObjects); + + int currentSlot = FIRST_WORD_SLOT; + for (int i = startItemIndex; i < endItemIndex; i++) { + if (currentSlot > LAST_WORLD_SLOT) { + break; + } + this.cachedDisplayables.get(i).addToInventory(currentPage, currentSlot++, player); + } + } + } + + /** + * Creates a single inventory page with common navigation and setting items. + * + * @return A new inventory instance. + */ + protected Inventory createBaseInventoryPage(String inventoryTitle) { + Inventory inventory = new DisplayablesInventoryHolder(inventoryTitle).getInventory(); + InventoryUtils.fillWithGlass(inventory, player); + + addWorldSortItem(inventory); + addWorldFilterItem(inventory); + + inventory.setItem(52, InventoryUtils.createSkull(Messages.getString("gui_previous_page", player), Profileable.detect(PREVIOUS_PAGE_SKULL_PROFILE))); + inventory.setItem(53, InventoryUtils.createSkull(Messages.getString("gui_next_page", player), Profileable.detect(NEXT_PAGE_SKULL_PROFILE))); + + return inventory; + } + + /** + * Collects all {@link Displayable} items (i.e. {@link Folder}s and {@link BuildWorld}s) that should be shown to the player. + *

    + * The method applies the following logic: + *

      + *
    • Fetches all {@link BuildWorld}s visible to the player based on world access rights and filter settings.
    • + *
    • Sorts the filtered worlds according to the player's selected sort order.
    • + *
    • If a world is assigned to a {@link Folder}, it is excluded from display and the folder is shown instead.
    • + *
    • If a world is not assigned to any folder, it is displayed directly.
    • + *
    • All folders are listed first, followed by all standalone worlds.
    • + *
    + * This ensures that: + *
      + *
    • Worlds are never shown twice (both directly and via their folder).
    • + *
    • Folder grouping takes display priority over individual worlds.
    • + *
    + * + * @return A list of {@link Displayable} items to be presented in the UI, sorted with folders first. + */ + protected List collectDisplayables() { + WorldDisplay worldDisplay = settingsManager.getSettings(player).getWorldDisplay(); + + Collection folders = collectFolders(); + List standaloneWorlds = filterWorlds(collectWorlds(), worldDisplay).stream() + .filter(buildWorld -> !buildWorld.isAssignedToFolder()) + .toList(); + + List displayables = new ArrayList<>(); + displayables.addAll(folders); + displayables.addAll(standaloneWorlds); + displayables.sort(worldDisplay.getWorldSort().getComparator()); + return displayables; + } + + /** + * Collects all {@link Folder}s that belong to the specified category and do not have a parent folder. + * + * @return A collection of root folders in the specified category + */ + @Unmodifiable + protected Collection collectFolders() { + return folderStorage.getFolders().stream() + .filter(folder -> folder.getCategory() == this.category) + .filter(folder -> !folder.hasParent()) + .filter(folder -> folder.canView(this.player)) + .toList(); + } + + /** + * Collects all potential {@link BuildWorld}s that be displayed to the player before filtering them based on visibility and status. + */ + protected Collection collectWorlds() { + return worldStorage.getBuildWorlds(); + } + + /** + * Filters the given collection of {@link BuildWorld}s based on their visibility and status, as well as the player's world display settings. + * + * @param buildWorlds The collection of build worlds to filter + * @param worldDisplay The world display settings for the player + * @return A collection of filtered and sorted {@link BuildWorld}s that are valid for display to the player + */ + @Unmodifiable + protected Collection filterWorlds(Collection buildWorlds, WorldDisplay worldDisplay) { + return buildWorlds.stream() + .filter(this::isWorldValidForDisplay) + .filter(worldDisplay.getWorldFilter().apply()) + .toList(); + } + + /** + * Checks if a given {@link BuildWorld} is valid for display based on visibility, status, and player permissions. + * + * @param buildWorld The world to check. + * @return {@code true} if the world should be displayed, {@link false} otherwise + */ + private boolean isWorldValidForDisplay(BuildWorld buildWorld) { + WorldData worldData = buildWorld.getData(); + if (!this.worldStorage.isCorrectVisibility(worldData.privateWorld().get(), this.requiredVisibility)) { + return false; + } + + if (!this.validStatuses.contains(worldData.status().get())) { + return false; + } + + if (!buildWorld.getPermissions().canEnter(this.player)) { + return false; + } + + return Bukkit.getWorld(buildWorld.getName()) != null || !buildWorld.isLoaded(); + } + + /** + * Adds the world sorting item to the inventory. + * + * @param inventory The inventory to add the item to + */ + private void addWorldSortItem(Inventory inventory) { + Settings settings = settingsManager.getSettings(player); + WorldSort worldSort = settings.getWorldDisplay().getWorldSort(); + + String messageKey = switch (worldSort) { + case NAME_A_TO_Z -> "world_sort_name_az"; + case NAME_Z_TO_A -> "world_sort_name_za"; + case PROJECT_A_TO_Z -> "world_sort_project_az"; + case PROJECT_Z_TO_A -> "world_sort_project_za"; + case STATUS_NOT_STARTED -> "world_sort_status_not_started"; + case STATUS_FINISHED -> "world_sort_status_finished"; + case NEWEST_FIRST -> "world_sort_date_newest"; + case OLDEST_FIRST -> "world_sort_date_oldest"; + }; + + inventory.setItem(45, InventoryUtils.createItem(XMaterial.BOOK, Messages.getString("world_sort_title", player), Messages.getString(messageKey, player))); + } + + /** + * Adds the world filter item to the inventory. + * + * @param inventory The inventory to add the item to + */ + private void addWorldFilterItem(Inventory inventory) { + Settings settings = settingsManager.getSettings(player); + WorldFilter worldFilter = settings.getWorldDisplay().getWorldFilter(); + + String loreKey = switch (worldFilter.getMode()) { + case NONE -> "world_filter_mode_none"; + case STARTS_WITH -> "world_filter_mode_starts_with"; + case CONTAINS -> "world_filter_mode_contains"; + case MATCHES -> "world_filter_mode_matches"; + }; + + List lore = new ArrayList<>(); + lore.add(Messages.getString(loreKey, player, Map.entry("%text%", worldFilter.getText()))); + lore.addAll(Messages.getStringList("world_filter_lore", player)); + + inventory.setItem(46, InventoryUtils.createItem(XMaterial.HOPPER, Messages.getString("world_filter_title", player), lore)); + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof DisplayablesInventoryHolder)) { + return; + } + + ItemStack itemStack = event.getCurrentItem(); + if (itemStack == null) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + Settings settings = settingsManager.getSettings(player); + WorldDisplay worldDisplay = settings.getWorldDisplay(); + + switch (event.getSlot()) { + case 45: // Sort + Function newSortFunction = event.isLeftClick() ? this::getNextSort : this::getPreviousSort; + WorldSort newSort = newSortFunction.apply(worldDisplay.getWorldSort()); + worldDisplay.setWorldSort(newSort); + updateAndReopenInventory(); + return; + case 46: // Filter + handleFilterClick(event, worldDisplay); + return; + case 48: // Create world + if (itemStack.getType() == XMaterial.PLAYER_HEAD.get()) { + XSound.ENTITY_CHICKEN_EGG.play(player); + beginWorldCreation(); + return; + } + break; + case 49: // Create folder (archive) + case 50: // Create folder (not archive) + if (itemStack.getType() == XMaterial.PLAYER_HEAD.get()) { + XSound.ENTITY_CHICKEN_EGG.play(player); + player.closeInventory(); // Close to allow chat input + new PlayerChatInput(plugin, player, "enter_folder_name", input -> { + if (StringCleaner.hasInvalidNameCharacters(input)) { + Messages.sendMessage(player, "worlds_folder_creation_invalid_characters"); + } + + String sanitizedName = StringCleaner.sanitize(input); + if (sanitizedName.isEmpty()) { + Messages.sendMessage(player, "worlds_folder_creation_name_bank"); + return; + } + + if (folderStorage.folderExists(sanitizedName)) { + Messages.sendMessage(player, "worlds_folder_exists"); + return; + } + + Folder folder = createFolder(sanitizedName); + Messages.sendMessage(player, "worlds_folder_created", Map.entry("%folder%", folder.getName())); + + initializeInventories(); + openInventory(); + }); + return; + } + break; + case 52: // Previous page + if (decrementInv(player, cachedDisplayables.size(), MAX_WORLDS_PER_PAGE)) { + openInventory(); + } + return; + case 53: // Next page + if (incrementInv(player, cachedDisplayables.size(), MAX_WORLDS_PER_PAGE)) { + openInventory(); + } + return; + } + + if (event.getSlot() >= FIRST_WORD_SLOT && event.getSlot() <= LAST_WORLD_SLOT) { + handleDisplayableItemClick(event, itemStack); + } else if (event.getSlot() >= 45 && event.getSlot() <= 53 && itemStack.getType() != XMaterial.PLAYER_HEAD.get()) { + XSound.BLOCK_CHEST_OPEN.play(player); + returnToPreviousInventory(); + } + } + + protected void beginWorldCreation() { + new CreateInventory(plugin, this.requiredVisibility).openInventory(this.player, Page.PREDEFINED); + } + + protected Folder createFolder(String folderName) { + return this.folderStorage.createFolder(folderName, this.category, Builder.of(this.player)); + } + + /** + * Sends the player back to the previous inventory. + */ + protected void returnToPreviousInventory() { + new NavigatorInventory(plugin).openInventory(this.player); + } + + /** + * Handles clicks on the filter item, managing mode changes, and text input. + * + * @param event The InventoryClickEvent. + * @param worldDisplay The player's WorldDisplay settings. + */ + private void handleFilterClick(InventoryClickEvent event, WorldDisplay worldDisplay) { + WorldFilter worldFilter = worldDisplay.getWorldFilter(); + Mode currentMode = worldFilter.getMode(); + + if (event.isShiftClick()) { + worldFilter.setMode(Mode.NONE); + worldFilter.setText(""); + } else if (event.isLeftClick()) { + player.closeInventory(); + new PlayerChatInput(plugin, player, "world_filter_title", input -> { + worldFilter.setText(input.replace("\"", "")); + openInventory(); + }); + return; + } else if (event.isRightClick()) { + worldFilter.setMode(currentMode.getNext()); + } + + updateAndReopenInventory(); + } + + /** + * Handles clicks on {@link Displayable} items (worlds or folders) within the inventory. + * + * @param event The click event + * @param clickedItem The item that was clicked + */ + private void handleDisplayableItemClick(InventoryClickEvent event, ItemStack clickedItem) { + ItemMeta itemMeta = clickedItem.getItemMeta(); + if (itemMeta == null || !itemMeta.hasDisplayName()) { + return; + } + + PersistentDataContainer pdc = itemMeta.getPersistentDataContainer(); + String displayableType = pdc.get(InventoryUtils.DISPLAYABLE_TYPE_KEY, PersistentDataType.STRING); + String displayableName = pdc.get(InventoryUtils.DISPLAYABLE_NAME_KEY, PersistentDataType.STRING); + if (displayableType == null || displayableName == null) { + return; + } + + switch (DisplayableType.valueOf(displayableType)) { + case BUILD_WORLD -> { + BuildWorld buildWorld = worldStorage.getBuildWorld(displayableName); + if (buildWorld == null) { + plugin.getLogger().warning("Unable to find world with name: " + displayableName); + return; + } + manageWorldItemClick(event, buildWorld); + } + case FOLDER -> { + Folder folder = folderStorage.getFolder(displayableName); + if (folder == null) { + plugin.getLogger().warning("Unable to find folder with name: " + displayableName); + return; + } + new FolderContentInventory(plugin, player, category, folder, this, requiredVisibility, validStatuses).openInventory(); + } + } + } + + /** + * Updates the inventory content and reopens it for the player. This is typically called after a setting (sort, filter) has changed. + */ + private void updateAndReopenInventory() { + resetInvIndex(player); + initializeInventories(); + openInventory(); + } + + /** + * Gets the next {@link WorldSort} order in the cycle. + * + * @param currentSort The current sort + * @return The next sort in the sequence + */ + private WorldSort getNextSort(WorldSort currentSort) { + return switch (currentSort) { + case NEWEST_FIRST -> WorldSort.OLDEST_FIRST; + case OLDEST_FIRST -> WorldSort.NAME_A_TO_Z; + case PROJECT_A_TO_Z -> WorldSort.PROJECT_Z_TO_A; + case PROJECT_Z_TO_A -> WorldSort.STATUS_NOT_STARTED; + case STATUS_NOT_STARTED -> WorldSort.STATUS_FINISHED; + case STATUS_FINISHED -> WorldSort.NEWEST_FIRST; + case NAME_A_TO_Z -> WorldSort.NAME_Z_TO_A; + case NAME_Z_TO_A -> WorldSort.PROJECT_A_TO_Z; + }; + } + + /** + * Gets the previous {@link WorldSort} order in the cycle. + * + * @param currentSort The current sort + * @return The previous sort in the sequence. + */ + public WorldSort getPreviousSort(WorldSort currentSort) { + return switch (currentSort) { + case NEWEST_FIRST -> WorldSort.STATUS_FINISHED; + case OLDEST_FIRST -> WorldSort.NEWEST_FIRST; + case PROJECT_A_TO_Z -> WorldSort.NAME_Z_TO_A; + case PROJECT_Z_TO_A -> WorldSort.PROJECT_A_TO_Z; + case STATUS_NOT_STARTED -> WorldSort.PROJECT_Z_TO_A; + case STATUS_FINISHED -> WorldSort.STATUS_NOT_STARTED; + case NAME_A_TO_Z -> WorldSort.OLDEST_FIRST; + case NAME_Z_TO_A -> WorldSort.NAME_A_TO_Z; + }; + } + + /** + * Manages the click action for an item representing a {@link BuildWorld}. + * + * @param event The InventoryClickEvent. + * @param buildWorld The BuildWorld associated with the clicked item. + */ + private void manageWorldItemClick(InventoryClickEvent event, BuildWorld buildWorld) { + if (event.isLeftClick() || !buildWorld.getPermissions().canPerformCommand(player, WorldsArgument.EDIT.getPermission())) { + performNonEditClick(buildWorld); + return; + } + + if (buildWorld.isLoaded()) { + XSound.BLOCK_CHEST_OPEN.play(player); + new EditInventory(plugin).openInventory(player, buildWorld); + } else { + player.closeInventory(); + XSound.ENTITY_ZOMBIE_BREAK_WOODEN_DOOR.play(player); + player.sendTitle(" ", Messages.getString("world_not_loaded", player), 5, 70, 20); + } + } + + /** + * Performs a non-edit action for a {@link BuildWorld} item click (typically teleportation). + * + * @param buildWorld The BuildWorld to act upon. + */ + private void performNonEditClick(BuildWorld buildWorld) { + playerService.closeNewNavigator(player); + buildWorld.getTeleporter().teleport(player); + } + + private static class DisplayablesInventoryHolder extends BuildSystemHolder { + + public DisplayablesInventoryHolder(String inventoryTitle) { + super(54, inventoryTitle); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/FolderContentInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/FolderContentInventory.java new file mode 100644 index 0000000..65ff95c --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/FolderContentInventory.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.inventory; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builder; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.Displayable; +import de.eintosti.buildsystem.api.world.display.Folder; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldDisplay; +import de.eintosti.buildsystem.world.creation.CreateInventory; +import java.util.AbstractMap.SimpleEntry; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; +import org.jetbrains.annotations.Unmodifiable; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class FolderContentInventory extends DisplayablesInventory { + + private final Folder folder; + private final DisplayablesInventory parentInventory; + + public FolderContentInventory( + BuildSystemPlugin plugin, + Player player, + NavigatorCategory category, + Folder folder, + DisplayablesInventory parentInventory, + Visibility requiredVisibility, + Set validStatuses + ) { + super( + plugin, + player, + category, + Messages.getString("folder_title", player, new SimpleEntry<>("%folder%", folder.getName())), + null, + requiredVisibility, + validStatuses + ); + this.folder = folder; + this.parentInventory = parentInventory; + } + + @Override + protected Inventory createBaseInventoryPage(String inventoryTitle) { + return this.parentInventory.createBaseInventoryPage(inventoryTitle); + } + + @Override + protected List collectDisplayables() { + WorldDisplay worldDisplay = this.settingsManager.getSettings(this.player).getWorldDisplay(); + + Collection folders = collectFolders(); + Collection buildWorlds = filterWorlds(collectWorlds(), worldDisplay); + + List displayables = new ArrayList<>(); + displayables.addAll(folders); + displayables.addAll(buildWorlds); + displayables.sort(worldDisplay.getWorldSort().getComparator()); + return displayables; + } + + @Override + @Unmodifiable + protected Collection collectFolders() { + return this.folder.getSubFolders().stream() + .filter(folder -> folder.canView(this.player)) + .toList(); + } + + @Override + @Unmodifiable + protected Collection collectWorlds() { + return this.folder.getWorldUUIDs().stream() + .map(this.worldStorage::getBuildWorld) + .filter(Objects::nonNull) + .toList(); + } + + @Override + protected void beginWorldCreation() { + new CreateInventory(plugin, this.requiredVisibility, this.folder).openInventory(this.player, CreateInventory.Page.PREDEFINED); + } + + @Override + protected Folder createFolder(String folderName) { + return this.folderStorage.createFolder(folderName, this.category, this.folder, Builder.of(this.player)); + } + + @Override + protected void returnToPreviousInventory() { + this.parentInventory.openInventory(); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/NavigatorInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/NavigatorInventory.java new file mode 100644 index 0000000..b60090e --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/NavigatorInventory.java @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.inventory; + +import com.cryptomorin.xseries.XSound; +import com.cryptomorin.xseries.profiles.objects.Profileable; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.player.settings.SettingsInventory; +import de.eintosti.buildsystem.util.inventory.BuildSystemHolder; +import de.eintosti.buildsystem.util.inventory.InventoryHandler; +import de.eintosti.buildsystem.util.inventory.InventoryManager; +import de.eintosti.buildsystem.util.inventory.InventoryUtils; +import org.bukkit.entity.Player; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.inventory.Inventory; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class NavigatorInventory implements InventoryHandler { + + private final BuildSystemPlugin plugin; + private final InventoryManager inventoryManager; + + public NavigatorInventory(BuildSystemPlugin plugin) { + this.plugin = plugin; + this.inventoryManager = plugin.getInventoryManager(); + } + + public void openInventory(Player player) { + Inventory inventory = getInventory(player); + this.inventoryManager.registerInventoryHandler(inventory, this); + player.openInventory(inventory); + } + + private Inventory getInventory(Player player) { + Inventory inventory = new NavigatorInventoryHolder(player).getInventory(); + fillGuiWithGlass(player, inventory); + + inventory.setItem(11, InventoryUtils.createSkull(Messages.getString("old_navigator_world_navigator", player), Profileable.detect("d5c6dc2bbf51c36cfc7714585a6a5683ef2b14d47d8ff714654a893f5da622"))); + inventory.setItem(12, InventoryUtils.createSkull(Messages.getString("old_navigator_world_archive", player), Profileable.detect("7f6bf958abd78295eed6ffc293b1aa59526e80f54976829ea068337c2f5e8"))); + inventory.setItem(13, InventoryUtils.createSkull(Messages.getString("old_navigator_private_worlds", player), Profileable.of(player))); + + inventory.setItem(15, InventoryUtils.createSkull(Messages.getString("old_navigator_settings", player), Profileable.detect("1cba7277fc895bf3b673694159864b83351a4d14717e476ebda1c3bf38fcf37"))); + + return inventory; + } + + private void fillGuiWithGlass(Player player, Inventory inventory) { + for (int i = 0; i <= 26; i++) { + InventoryUtils.addGlassPane(player, inventory, i); + } + } + + @Override + public void onClick(InventoryClickEvent event) { + if (!(event.getInventory().getHolder() instanceof NavigatorInventoryHolder)) { + return; + } + + event.setCancelled(true); + Player player = (Player) event.getWhoClicked(); + + switch (event.getSlot()) { + case 11: + new PublicWorldsInventory(plugin, player).openInventory(); + break; + case 12: + new ArchivedWorldsInventory(plugin, player).openInventory(); + break; + case 13: + new PrivateWorldsInventory(plugin, player).openInventory(); + break; + case 15: + if (!player.hasPermission("buildsystem.settings")) { + XSound.ENTITY_ITEM_BREAK.play(player); + return; + } + new SettingsInventory(plugin).openInventory(player); + break; + default: + return; + } + + XSound.ENTITY_CHICKEN_EGG.play(player); + } + + private static class NavigatorInventoryHolder extends BuildSystemHolder { + + public NavigatorInventoryHolder(Player player) { + super(27, Messages.getString("old_navigator_title", player)); + } + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PrivateWorldsInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PrivateWorldsInventory.java new file mode 100644 index 0000000..5d751d8 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PrivateWorldsInventory.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.inventory; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +/** + * An inventory class specifically for displaying and managing private {@link BuildWorld}s. + */ +@NullMarked +public class PrivateWorldsInventory extends CreatableWorldsInventory { + + /** + * Constructs a new {@link PrivateWorldsInventory} instance. + * + * @param plugin The plugin instance + * @param player The player for whom this inventory is created + */ + public PrivateWorldsInventory(BuildSystemPlugin plugin, Player player) { + super( + plugin, + player, + NavigatorCategory.PRIVATE, + Messages.getString("private_title", player), + Messages.getString("private_no_worlds", player), + Visibility.PRIVATE + ); + } + + /** + * Returns the permission string required to create a private world. + * + * @return The permission string: "buildsystem.create.private". + */ + @Override + protected String getWorldCreationPermission() { + return "buildsystem.create.private"; + } + + /** + * Returns the message key for the title of the "create private world" item. + * + * @return The message key: "private_create_world". + */ + @Override + protected String getWorldCreationItemTitleKey() { + return "private_create_world"; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PublicWorldsInventory.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PublicWorldsInventory.java new file mode 100644 index 0000000..ad5515a --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/inventory/PublicWorldsInventory.java @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.inventory; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.data.Visibility; +import de.eintosti.buildsystem.api.world.display.NavigatorCategory; +import org.bukkit.entity.Player; +import org.jspecify.annotations.NullMarked; + +/** + * An inventory class specifically for displaying and managing public {@link BuildWorld}s. + */ +@NullMarked +public class PublicWorldsInventory extends CreatableWorldsInventory { + + /** + * Constructs a new {@link PublicWorldsInventory} instance. + * + * @param plugin The plugin instance + * @param player The player for whom this inventory is created + */ + public PublicWorldsInventory(BuildSystemPlugin plugin, Player player) { + super( + plugin, + player, + NavigatorCategory.PUBLIC, + Messages.getString("world_navigator_title", player), + Messages.getString("world_navigator_no_worlds", player), + Visibility.PUBLIC + ); + } + + /** + * Returns the permission string required to create a private world. + * + * @return The permission string: "buildsystem.create.private". + */ + @Override + protected String getWorldCreationPermission() { + return "buildsystem.create.public"; + } + + /** + * Returns the message key for the title of the "create public world" item. + * + * @return The message key: "world_navigator_create_world". + */ + @Override + protected String getWorldCreationItemTitleKey() { + return "world_navigator_create_world"; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldDisplayImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldDisplayImpl.java new file mode 100644 index 0000000..f2e1265 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldDisplayImpl.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.settings; + +import de.eintosti.buildsystem.api.world.navigator.settings.WorldDisplay; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldSort; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WorldDisplayImpl implements WorldDisplay { + + private final WorldFilterImpl worldFilter; + private WorldSort worldSort; + + public WorldDisplayImpl() { + this.worldSort = WorldSort.NAME_A_TO_Z; + this.worldFilter = new WorldFilterImpl(); + } + + public WorldDisplayImpl(WorldSort worldSort, WorldFilterImpl worldFilter) { + this.worldSort = worldSort; + this.worldFilter = worldFilter; + } + + @Override + public WorldSort getWorldSort() { + return worldSort; + } + + @Override + public void setWorldSort(WorldSort worldSort) { + this.worldSort = worldSort; + } + + @Override + public WorldFilterImpl getWorldFilter() { + return worldFilter; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldFilterImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldFilterImpl.java new file mode 100644 index 0000000..3820457 --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/navigator/settings/WorldFilterImpl.java @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.navigator.settings; + +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.navigator.settings.WorldFilter; +import java.util.function.Predicate; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WorldFilterImpl implements WorldFilter { + + private Mode mode; + private String text; + + public WorldFilterImpl() { + this(Mode.NONE, ""); + } + + public WorldFilterImpl(Mode mode, String text) { + this.mode = mode; + this.text = text; + } + + @Override + public Mode getMode() { + return mode; + } + + @Override + public void setMode(Mode mode) { + this.mode = mode; + } + + @Override + public String getText() { + return text; + } + + @Override + public void setText(String text) { + this.text = text; + } + + @Override + public Predicate apply() { + return switch (mode) { + case STARTS_WITH -> buildWorld -> buildWorld.getName().startsWith(text); + case CONTAINS -> buildWorld -> buildWorld.getName().contains(text); + case MATCHES -> buildWorld -> buildWorld.getName().matches(text); + default -> buildWorld -> true; + }; + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldLoaderImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldLoaderImpl.java new file mode 100644 index 0000000..15700cb --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldLoaderImpl.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.util; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.event.world.BuildWorldLoadEvent; +import de.eintosti.buildsystem.api.event.world.BuildWorldPostLoadEvent; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.util.WorldLoader; +import de.eintosti.buildsystem.world.creation.BuildWorldCreatorImpl; +import java.util.Map; +import org.bukkit.Bukkit; +import org.bukkit.World; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WorldLoaderImpl implements WorldLoader { + + private final BuildSystemPlugin plugin; + private final BuildWorld buildWorld; + + private WorldLoaderImpl(BuildWorld buildWorld) { + this.plugin = BuildSystemPlugin.get(); + this.buildWorld = buildWorld; + } + + @Contract("_ -> new") + public static WorldLoaderImpl of(BuildWorld buildWorld) { + return new WorldLoaderImpl(buildWorld); + } + + @Override + public void loadForPlayer(Player player) { + if (this.buildWorld.isLoaded()) { + return; + } + + player.closeInventory(); + player.sendTitle(" ", Messages.getString("loading_world", player, Map.entry("%world%", this.buildWorld.getName())), 5, 70, 20); + + load(); + } + + @Override + public void load() { + if (this.buildWorld.isLoaded()) { + return; + } + + BuildWorldLoadEvent loadEvent = new BuildWorldLoadEvent(this.buildWorld); + Bukkit.getServer().getPluginManager().callEvent(loadEvent); + if (loadEvent.isCancelled()) { + return; + } + + String worldName = this.buildWorld.getName(); + this.plugin.getLogger().info("*** Loading world \"" + worldName + "\" ***"); + World world = new BuildWorldCreatorImpl(this.plugin, this.buildWorld).generateBukkitWorld(); + if (world == null) { + return; + } + + this.buildWorld.getData().lastLoaded().set(System.currentTimeMillis()); + this.buildWorld.setLoaded(true); + + Bukkit.getServer().getPluginManager().callEvent(new BuildWorldPostLoadEvent(this.buildWorld)); + this.buildWorld.getUnloader().resetUnloadTask(); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldPermissionsImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldPermissionsImpl.java new file mode 100644 index 0000000..9b07d4f --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldPermissionsImpl.java @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.util; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.data.Bypassable; +import de.eintosti.buildsystem.api.data.Type; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.builder.Builders; +import de.eintosti.buildsystem.api.world.data.BuildWorldStatus; +import de.eintosti.buildsystem.api.world.data.WorldData; +import de.eintosti.buildsystem.api.world.util.WorldPermissions; +import de.eintosti.buildsystem.world.data.type.ConfigurableType; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class WorldPermissionsImpl implements WorldPermissions { + + @Nullable + private final BuildWorld buildWorld; + + private WorldPermissionsImpl(@Nullable BuildWorld buildWorld) { + this.buildWorld = buildWorld; + } + + @Contract("_ -> new") + public static WorldPermissionsImpl of(@Nullable BuildWorld buildWorld) { + return new WorldPermissionsImpl(buildWorld); + } + + @Override + public boolean canEnter(Player player) { + if (buildWorld == null) { + return false; + } + + if (hasAdminPermission(player) || canBypassViewPermission(player)) { + return true; + } + + Builders builders = buildWorld.getBuilders(); + if (builders.isCreator(player) || builders.isBuilder(player)) { + return true; + } + + String permission = buildWorld.getData().permission().get(); + if (permission.equals("-")) { + return true; + } + + return player.hasPermission(permission); + } + + @Override + public boolean canModify(Player player, Type check) { + if (this.buildWorld == null) { + return true; + } + + if (canBypassModification(player, check)) { + return true; + } + + if (!check.get()) { + return false; + } + + if (buildWorld.getData().status().get() == BuildWorldStatus.ARCHIVE && !player.hasPermission("buildsystem.bypass.archive")) { + return false; + } + + Builders builders = buildWorld.getBuilders(); + if (builders.isCreator(player)) { + return true; + } + + if (!buildWorld.getData().buildersEnabled().get()) { + return true; + } + + if (player.hasPermission("buildsystem.bypass.builders")) { + return true; + } + + return builders.isBuilder(player); + } + + private boolean canBypassModification(Player player, Type check) { + if (hasAdminPermission(player) || canBypassBuildRestriction(player)) { + return true; + } + + if (!(check instanceof ConfigurableType configurableType)) { + return false; + } + + return configurableType.getCapability(Bypassable.class) + .map(bypassable -> player.hasPermission(bypassable.permission())) + .orElse(false); + } + + @Override + public boolean canPerformCommand(Player player, @Nullable String permission) { + if (permission == null || permission.isEmpty()) { + // If no permission is specified, we assume the command can be executed by anyone. + return true; + } + + if (hasAdminPermission(player)) { + return true; + } + + if (buildWorld == null) { + // Most commands require the world to be non-null. + // Nevertheless, return true to allow a "world is null" message to be sent. + return true; + } + + if (buildWorld.getBuilders().isCreator(player)) { + return (player.hasPermission(permission + ".self") || player.hasPermission(permission)); + } + + return player.hasPermission(permission + ".other"); + } + + @Override + public boolean hasAdminPermission(Player player) { + return player.hasPermission(BuildSystemPlugin.ADMIN_PERMISSION); + } + + @Override + public boolean canBypassViewPermission(Player player) { + if (buildWorld == null) { + return false; + } + + WorldData worldData = buildWorld.getData(); + if (worldData.status().get() == BuildWorldStatus.ARCHIVE) { + return player.hasPermission("buildsystem.bypass.permission.archive"); + } + + return worldData.privateWorld().get() + ? player.hasPermission("buildsystem.bypass.permission.private") + : player.hasPermission("buildsystem.bypass.permission.public"); + } + + @Override + public boolean canBypassBuildRestriction(Player player) { + return BuildSystemPlugin.get().getPlayerService().isInBuildMode(player); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldTeleporterImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldTeleporterImpl.java new file mode 100644 index 0000000..1afa6fc --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldTeleporterImpl.java @@ -0,0 +1,129 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.util; + +import com.cryptomorin.xseries.XSound; +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.Messages; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.util.WorldTeleporter; +import de.eintosti.buildsystem.config.Config.World.Unload; +import io.papermc.lib.PaperLib; +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.block.BlockFace; +import org.bukkit.entity.Player; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public class WorldTeleporterImpl implements WorldTeleporter { + + private final BuildSystemPlugin plugin; + private final BuildWorld buildWorld; + + private WorldTeleporterImpl(BuildWorld buildWorld) { + this.plugin = BuildSystemPlugin.get(); + this.buildWorld = buildWorld; + } + + @Contract("_ -> new") + public static WorldTeleporterImpl of(BuildWorld buildWorld) { + return new WorldTeleporterImpl(buildWorld); + } + + @Override + public void teleport(Player player) { + boolean hadToLoad = false; + if (Unload.enabled && !buildWorld.isLoaded()) { + buildWorld.getLoader().loadForPlayer(player); + hadToLoad = true; + } + + World bukkitWorld = Bukkit.getServer().getWorld(buildWorld.getName()); + if (bukkitWorld == null) { + Messages.sendMessage(player, "worlds_tp_unknown_world"); + return; + } + + Location location = bukkitWorld.getSpawnLocation().add(0.5, 0, 0.5); + Location customSpawn = buildWorld.getData().getCustomSpawnLocation(); + if (customSpawn != null) { + location = customSpawn; + } else { + switch (buildWorld.getType()) { + case NETHER: + case END: + Location blockLocation = null; + for (int y = 0; y < bukkitWorld.getMaxHeight(); y++) { + Block block = bukkitWorld.getBlockAt(location.getBlockX(), y, location.getBlockZ()); + if (isSafeLocation(block.getLocation())) { + blockLocation = block.getLocation(); + break; + } + } + if (blockLocation != null) { + location = new Location( + bukkitWorld, + blockLocation.getBlockX() + 0.5, + blockLocation.getBlockY() + 1, + blockLocation.getBlockZ() + 0.5 + ); + } + break; + default: + break; + } + } + + Location finalLocation = location; + Bukkit.getScheduler().runTaskLater(plugin, () -> + PaperLib.teleportAsync(player, finalLocation) + .whenComplete((completed, throwable) -> { + if (!completed) { + return; + } + + player.resetTitle(); + XSound.ENTITY_ENDERMAN_TELEPORT.play(player); + + if (!finalLocation.clone().add(0, -1, 0).getBlock().getType().isSolid()) { + player.setFlying(player.getAllowFlight()); + } + } + ), hadToLoad ? 20L : 0L); + } + + public static boolean isSafeLocation(Location location) { + Block feet = location.getBlock(); + if (feet.getType() != Material.AIR && feet.getLocation().add(0, 1, 0).getBlock().getType() != Material.AIR) { + return false; + } + + Block head = feet.getRelative(BlockFace.UP); + if (head.getType() != Material.AIR) { + return false; + } + + Block ground = feet.getRelative(BlockFace.DOWN); + return ground.getType().isSolid(); + } +} diff --git a/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldUnloaderImpl.java b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldUnloaderImpl.java new file mode 100644 index 0000000..507e3dc --- /dev/null +++ b/buildsystem-core/src/main/java/de/eintosti/buildsystem/world/util/WorldUnloaderImpl.java @@ -0,0 +1,155 @@ +/* + * Copyright (c) 2018-2025, Thomas Meaney + * Copyright (c) contributors + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package de.eintosti.buildsystem.world.util; + +import de.eintosti.buildsystem.BuildSystemPlugin; +import de.eintosti.buildsystem.api.event.world.BuildWorldPostUnloadEvent; +import de.eintosti.buildsystem.api.event.world.BuildWorldUnloadEvent; +import de.eintosti.buildsystem.api.world.BuildWorld; +import de.eintosti.buildsystem.api.world.util.WorldUnloader; +import de.eintosti.buildsystem.config.Config.World.Unload; +import java.util.Arrays; +import java.util.Objects; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.Location; +import org.bukkit.World; +import org.bukkit.scheduler.BukkitTask; +import org.jetbrains.annotations.Contract; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public class WorldUnloaderImpl implements WorldUnloader { + + private final BuildSystemPlugin plugin; + private final BuildWorld buildWorld; + + private final long secondsUntilUnload; + @Nullable + private BukkitTask unloadTask; + + private WorldUnloaderImpl(BuildWorld buildWorld) { + this.plugin = BuildSystemPlugin.get(); + this.buildWorld = buildWorld; + + this.secondsUntilUnload = calculateSecondsUntilUnload(Unload.timeUntilUnload); + } + + private long calculateSecondsUntilUnload(String timeString) { + String[] timeArray = timeString.split(":"); + int hours = Integer.parseInt(timeArray[0]); + int minutes = Integer.parseInt(timeArray[1]); + int seconds = Integer.parseInt(timeArray[2]); + return hours * 3600L + minutes * 60L + seconds; + } + + @Contract("_ -> new") + public static WorldUnloaderImpl of(BuildWorld buildWorld) { + return new WorldUnloaderImpl(buildWorld); + } + + @Override + public void manageUnload() { + if (!Unload.enabled) { + buildWorld.setLoaded(true); + return; + } + + buildWorld.setLoaded(buildWorld.getWorld() != null); + startUnloadTask(); + } + + @Override + public void startUnloadTask() { + if (!Unload.enabled) { + return; + } + + this.unloadTask = Bukkit.getScheduler().runTaskLater(plugin, this::unload, 20L * secondsUntilUnload); + } + + @Override + public void resetUnloadTask() { + if (this.unloadTask != null) { + this.unloadTask.cancel(); + } + + startUnloadTask(); + } + + @Override + public void unload() { + World bukkitWorld = buildWorld.getWorld(); + if (bukkitWorld == null) { + return; + } + + if (!bukkitWorld.getPlayers().isEmpty()) { + resetUnloadTask(); + return; + } + + if (Unload.blacklistedWorlds.contains(buildWorld.getName()) || isSpawnWorld(bukkitWorld)) { + return; + } + + forceUnload(true); + } + + @Override + public void forceUnload(boolean save) { + BuildWorldUnloadEvent unloadEvent = new BuildWorldUnloadEvent(buildWorld); + Bukkit.getServer().getPluginManager().callEvent(unloadEvent); + if (unloadEvent.isCancelled()) { + return; + } + + this.buildWorld.getData().lastUnloaded().set(System.currentTimeMillis()); + this.buildWorld.setLoaded(false); + this.unloadTask = null; + + World bukkitWorld = this.buildWorld.getWorld(); + if (bukkitWorld == null) { + return; + } + + if (save) { + Arrays.stream(bukkitWorld.getLoadedChunks()).forEach(Chunk::unload); + bukkitWorld.save(); + } + + if (!Bukkit.unloadWorld(bukkitWorld, save)) { + plugin.getLogger().warning("Failed to unload world \"" + this.buildWorld.getName() + "\". It may still be loaded in memory."); + return; + } + + Bukkit.getWorlds().remove(bukkitWorld); + + Bukkit.getServer().getPluginManager().callEvent(new BuildWorldPostUnloadEvent(this.buildWorld)); + plugin.getLogger().info("*** Unloaded world \"" + this.buildWorld.getName() + "\" ***"); + } + + private boolean isSpawnWorld(World bukkitWorld) { + Location spawn = plugin.getSpawnManager().getSpawn(); + if (spawn == null) { + return false; + } + return Objects.equals(spawn.getWorld(), bukkitWorld); + } +} \ No newline at end of file diff --git a/buildsystem-core/src/main/resources/config.yml b/buildsystem-core/src/main/resources/config.yml new file mode 100644 index 0000000..4e1b59a --- /dev/null +++ b/buildsystem-core/src/main/resources/config.yml @@ -0,0 +1,100 @@ +messages: + spawn-teleport-message: false + join-quit-messages: true + date-format: "dd/MM/yyyy" +settings: + update-checker: true + scoreboard: true + archive: + vanish: true + change-gamemode: true + # Options: "SURVIVAL", "CREATIVE", "ADVENTURE", "SPECTATOR" + world-gamemode: "ADVENTURE" + disabled-physics: + prevent-connections: true + prevent-fluid-flow: true + prevent-falling-blocks: true + save-from-death: + enabled: true + teleport-to-map-spawn: true + build-mode: + drop-items: true + move-items: true + builder: + block-worldedit-non-builder: true + navigator: + item: "CLOCK" + give-item-on-join: true +world: + default: + permission: + public: "-" + private: "worlds.%world%" + time: + sunrise: 0 + noon: 6000 + night: 18000 + worldborder: + size: 6000000 + difficulty: "PEACEFUL" + gamerules: + doDaylightCycle: false + doMobSpawning: false + doFireTick: false + settings: + physics: true + explosions: true + mob-ai: true + block-breaking: true + block-placement: true + block-interactions: true + builders-enabled: + public: false + private: true + lock-weather: true + invalid-characters: ^\b$ + import-all: + delay: 30 # In seconds + max-amount: + public: -1 + private: -1 + unload: + enabled: true + time-until-unload: "01:00:00" + blacklisted-worlds: + - world + - world_nether + - worth_the_end + deletion-blacklist: + - world + - world_nether + - worth_the_end + disabled-physics: + prevent-connections: true + prevent-fluid-flow: true + prevent-falling-blocks: true + backup: + max-backups-per-world: 5 # Maximum: 18 + auto-backup: + enabled: true + interval: 900 # In seconds + only-active-worlds: true + storage: + # Options: local, s3, sftp + type: local + s3: + url: null # Only required when not using AWS S3 + access-key: YOUR_ACCESS_KEY + secret-key: YOUR_SECRET_KEY + region: eu-central-1 + bucket: buildsystem-backups + path: backups/worlds/ + sftp: + host: YOUR_SFTP_HOST + port: 22 + username: YOUR_SFTP_USERNAME + password: YOUR_SFTP_PASSWORD + path: backups/worlds/ +folder: + override-permissions: true + override-projects: false \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..0aecd43 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,7 @@ +group=de.eintosti +version=3.0.1 +org.gradle.daemon=true +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.vfs.watch=true +org.gradle.configuration-cache=true \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..4839d94 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,57 @@ +[versions] +# Platform expectations +spigot = "1.21.8-R0.1-SNAPSHOT" +paperlib = "1.0.8" + +# Plugins +luckperms = "5.5" +worldedit = "7.3.16" +placeholderapi = "2.11.6" +axiompaper = "4.0.1-1.21.1" +# Third Party +annotations = "26.0.2-1" +authlib = "3.13.56" +aws-sdk = "2.34.7" +bouncycastle = "1.82" +bstats = "3.1.0" +fastboard = "2.1.5" +jspecify = "1.0.0" +nbt = "1.5.1" +sshd-sftp = "2.16.0" +xseries = "13.5.1" +zip4j = "2.11.5" + +[libraries] +# Platform expectations +spigot = { group = "org.spigotmc", name = "spigot-api", version.ref = "spigot" } +paperlib = { group = "io.papermc", name = "paperlib", version.ref = "paperlib" } + +# Plugins +luckperms = { group = "net.luckperms", name = "api", version.ref = "luckperms" } +placeholderapi = { group = "me.clip", name = "placeholderapi", version.ref = "placeholderapi" } +worldedit = { group = "com.sk89q.worldedit", name = "worldedit-core", version.ref = "worldedit" } +axiompaper = { group = "maven.modrinth.workaround", name = "axiom-paper-plugin", version.ref = "axiompaper" } + +# Third Party +annotations = { group = "org.jetbrains", name = "annotations", version.ref = "annotations" } +authlib = { group = "com.mojang", name = "authlib", version.ref = "authlib" } +aws-auth = { module = "software.amazon.awssdk:auth", version.ref = "aws-sdk" } +aws-core = { module = "software.amazon.awssdk:core", version.ref = "aws-sdk" } +aws-regions = { module = "software.amazon.awssdk:regions", version.ref = "aws-sdk" } +aws-s3 = { module = "software.amazon.awssdk:s3", version.ref = "aws-sdk" } +bouncycastle = { group = "org.bouncycastle", name = "bcpkix-jdk18on", version.ref = "bouncycastle" } +bstats = { group = "org.bstats", name = "bstats-bukkit", version.ref = "bstats" } +fastboard = { group = "fr.mrmicky", name = "fastboard", version.ref = "fastboard" } +jspecify = { module = "org.jspecify:jspecify", version.ref = "jspecify" } +nbt = { group = "dev.dewy", name = "nbt", version.ref = "nbt" } +sftp = { group = "org.apache.sshd", name = "sshd-sftp", version.ref = "sshd-sftp" } +zip4j = { group = "net.lingala.zip4j", name = "zip4j", version.ref = "zip4j" } +xseries = { group = "com.github.cryptomorin", name = "XSeries", version.ref = "xseries" } + +[bundles] +aws = [ + "aws-s3", + "aws-auth", + "aws-regions", + # "aws-core", Unable to find the dependency at runtime +] \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..943f0cbfa754578e88a3dae77fce6e3dea56edbf GIT binary patch literal 61574 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 ScNgau(U?ep-K_E5zy1%ZQTdPn literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..bec664e --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,23 @@ +# +# Copyright (c) 2018-2025, Thomas Meaney +# Copyright (c) contributors +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..65dcd68 --- /dev/null +++ b/gradlew @@ -0,0 +1,244 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..51d7c62 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,16 @@ +rootProject.name = "BuildSystem" + +include("buildsystem-api") +include("buildsystem-core") + +pluginManagement { + repositories { + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositories { + mavenCentral() + } +} \ No newline at end of file

    KVsm2B>cbG_5T@NaDP3To7ul? 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 HN@xNA+2q9_ literal 0 HcmV?d00001 diff --git a/.github/images/setup.png b/.github/images/setup.png new file mode 100644 index 0000000000000000000000000000000000000000..c79c6a9f578760be514668bf014793ccfcd48d96 GIT binary patch literal 55132 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 literal 0 HcmV?d00001 diff --git a/.github/images/status.png b/.github/images/status.png new file mode 100644 index 0000000000000000000000000000000000000000..4017c6f54ef1f1d47d644c47662b3ccf71c99fda GIT binary patch literal 17445 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 Xv@t61i?EzQfB-TQ3gTs=27&(zk*Sqw literal 0 HcmV?d00001 diff --git a/.github/images/templates.png b/.github/images/templates.png new file mode 100644 index 0000000000000000000000000000000000000000..be9cecace0ae29bef3b03d7d9afbb7565e07d811 GIT binary patch literal 22052 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 JxSZY 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 KpUXO@geCwz literal 0 HcmV?d00001 diff --git a/.github/images/worlds.png b/.github/images/worlds.png new file mode 100644 index 0000000000000000000000000000000000000000..f6165f9f124ab648d6295e38bf014010e270e703 GIT binary patch literal 28813 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ⓈDdZQL_k%RYy=RZ;pI06F5hn{52`NeAC2ihg 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 Ns-&d|dG;#o{{Ru=$8-Py literal 0 HcmV?d00001 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 0000000..7ff37a7 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,21 @@ +{ + "extends": [ + "config:base", + ":disableDependencyDashboard" + ], + "automerge": true, + "ignoreDeps": [ + "org.spigotmc:spigot-api" + ], + "labels": [ + "renovate" + ], + "commitMessagePrefix": "chore: ", + "rebaseWhen": "conflicted", + "packageRules": [ + { + "matchPackagePrefixes": ["software.amazon.awssdk"], + "schedule": ["before 4am on the first day of the month"] + } + ] +} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..c1cb7c7 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,27 @@ +name: Build main + +on: [ push, pull_request ] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + java: [ 21 ] + fail-fast: true + + steps: + - name: Checkout Git Repository + uses: actions/checkout@v5 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Build with Gradle + run: ./gradlew clean build \ No newline at end of file diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7603d1c --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,51 @@ +name: Publish to Sonatype + +on: + push: + branches: [ master, dev/v3 ] + +jobs: + publish: + if: github.repository_owner_id == 29168243 + runs-on: ubuntu-latest + strategy: + matrix: + java: [ 21 ] + fail-fast: true + steps: + - name: Checkout Git Repository + uses: actions/checkout@v5 + + - name: Set up JDK ${{ matrix.java }} + uses: actions/setup-java@v5 + with: + java-version: ${{ matrix.java }} + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v5 + + - name: Get Project Version + id: version + run: | + VERSION=$(./gradlew -q properties --no-daemon | grep "^version:" | awk '{print $2}') + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + + - name: Publish to Sonatype + env: + ORG_GRADLE_PROJECT_sonatypeUsername: ${{ secrets.SONATYPE_USERNAME }} + ORG_GRADLE_PROJECT_sonatypePassword: ${{ secrets.SONATYPE_PASSWORD }} + ORG_GRADLE_PROJECT_signingKeyId: ${{ secrets.SIGNING_KEY_ID }} + ORG_GRADLE_PROJECT_signingKey: ${{ secrets.SIGNING_KEY }} + ORG_GRADLE_PROJECT_signingPassword: ${{ secrets.SIGNING_PASSWORD }} + run: | + PROJECT_VERSION="${{ steps.version.outputs.version }}" + echo "Detected project version: $PROJECT_VERSION" + + if [[ "$PROJECT_VERSION" == *-SNAPSHOT ]]; then + echo "Publishing SNAPSHOT version to Sonatype..." + ./gradlew publishAllPublicationsToSonatypeRepository --no-daemon --no-configuration-cache + else + echo "Publishing RELEASE version to Sonatype and closing staging repository..." + ./gradlew publishAllPublicationsToSonatypeRepository closeAndReleaseSonatypeStagingRepository --no-daemon --no-configuration-cache + fi \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..57bc4eb --- /dev/null +++ b/.gitignore @@ -0,0 +1,125 @@ +# User-specific stuff +.idea/ +.vscode/ + +*.iml +*.ipr +*.iws + +# IntelliJ +out/ + +# Eclipse +.classpath +.project +.settings/ +plugin/bin/ +api/bin/ + +# Compiled class file +*.class + +# Log file +*.log + +# BlueJ files +*.ctxt + +# Package Files # +*.war +*.nar +*.ear +*.zip +*.tar.gz +*.rar + +# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml +hs_err_pid* + +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# General +.DS_Store +.AppleDouble +.LSOverride + +# Icon must end with two \r +Icon + +# Thumbnails +._* + +# Files that might appear in the root of a volume +.DocumentRevisions-V100 +.fseventsd +.Spotlight-V100 +.TemporaryItems +.Trashes +.VolumeIcon.icns +.com.apple.timemachine.donotpresent + +# Directories potentially created on remote AFP share +.AppleDB +.AppleDesktop +Network Trash Folder +Temporary Items +.apdisk + +# Windows thumbnail cache files +Thumbs.db +Thumbs.db:encryptable +ehthumbs.db +ehthumbs_vista.db + +# Dump file +*.stackdump + +# Folder config file +[Dd]esktop.ini + +# Recycle Bin used on file shares +$RECYCLE.BIN/ + +# Windows Installer files +*.cab +*.msi +*.msix +*.msm +*.msp + +# Windows shortcuts +*.lnk + +target/ + +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next + +release.properties +dependency-reduced-pom.xml +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar +.flattened-pom.xml + +# Common working directory +bin/ +build/ +.gradle/ +buildSrc/build/ +buildSrc/.gradle/ +run/ +/*.jar diff --git a/HEADER.txt b/HEADER.txt new file mode 100644 index 0000000..81118da --- /dev/null +++ b/HEADER.txt @@ -0,0 +1,15 @@ +Copyright (c) 2018-2025, Thomas Meaney +Copyright (c) contributors + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program. If not, see . \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..2694e49 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,858 @@ +----------------- +BuildSystem License +----------------- + +BuildSystem as a whole is licensed under: + + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + +---------------- +commons-lang License +---------------- + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + +---------------- +LuckPerms License +---------------- + +MIT License + +Copyright (c) lucko (Luck) +Copyright (c) contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..a127cbc --- /dev/null +++ b/README.md @@ -0,0 +1,144 @@ +![Screenshot](.github/images/header.png) + +# BuildSystem ![GitHub Workflow Status (branch)](https://img.shields.io/github/actions/workflow/status/thomasmny/BuildSystem/build.yml?branch=master) ![version](https://img.shields.io/github/v/release/thomasmny/BuildSystem) [![Discord](https://img.shields.io/discord/419460301403193344.svg?label=&logo=discord&logoColor=ffffff&color=7389D8&labelColor=6A7EC2)](https://discord.com/rduPF3yk62) + +## Table of contents + +* [Introduction](#introduction) +* [Links and Contacts](#links-and-contacts) +* [Features](#features) +* [Statistics](#statistics) +* [Developer API](#developer-api) +* [Contributing](#contributing) + * [To compile...](#to-compile) + * [Then you will find...](#then-you-will-find) + * [Other commands](#other-commands) + * [PR Policy](#pr-policy) +* [License](#license) + +## Introduction + +**BuildSystem** is a simple but powerful - as the name already says - system for builders, with lots +of great features for everyday usage. Manage worlds in the worlds navigator, change their permission, projects and +status with ease. And not to forget: let each player decide which settings whey think are best for them and now the +building can start! + +## Links and Contacts + +* **Full guide:** + You can find a full guide with tutorials, commands and + permissions [here](https://eintosti.gitbook.io/buildsystem/). +* **Downloads:** + * [SpigotMC](https://spigotmc.org/resources/buildsystem-1-8-1-18.60441/) + * [Chunkfactory](https://chunkfactory.com/product/buildsystem-1-8-1-15.1049/) + * [MC-Market](https://www.mc-market.org/resources/12399/) + * Snapshots are available on [Jenkins](https://ci.eintosti.de/job/BuildSystem/). +* **Support:** + * [GitHub issue tracker](https://github.com/einTosti/BuildSystem/issues) + * [Discord](https://discord.gg/rduPF3yk62) +* **Donations:** + Donations are **100%** voluntary. However, I am truly grateful for every single one of you who + decides to do so as + each donation helps me to continue developing the project further. If you wish to donate anything, + you can do + so [here](https://einTosti.com/donate). + +## Features + +* **100% customisable messages** and scoreboard +* Powerful navigator which allows for an overview of all worlds. Extra GUIs for: + - **_Not Started_**, **_In Progress_**, **_Almost Finished_** and **_Finished_** maps + - Maps that have been put to the **_Archive_** + - **_Private_** player maps: Each player can create their own map, if a map with their name + doesn't exist +* **Create worlds with ease**: When creating a world, choose from: + - **_Predefined worlds_** _or_ + - **_Custom generators_** provided by 3rd party plugins _or_ + - **_Custom templates_** which you can add yourself +* Easily **manage your worlds**: Choose from over 6 different statuses for each world +* When in worlds that are set as finished, the player is invisible and can fly in adventure mode, so + they can only have + a look without breaking anything [bypass: `/build`] +* Set the **permission** you need to join a worlds +* Change what project each world is for (e.g. `"A small BedWars map"`) +* Set the **world item**, so you can spot them faster amongst other worlds + - You can choose from all items that are available in Minecraft +* **Import**, **delete** and **create worlds** with ease +* New and improved navigator! + - Not a GUI anymore, but furthermore an interactive navigator + - But if you rather have a "normal" GUI, the option is there for you to toggle between the +* **Per player settings** +* **Building tools**: + - Toggle block physics + - Custom speed + - Receive player skulls + - Change the time in a world with only a single command + - Toggle double slab breaking + - No-Clip + - Open iron doors and iron trapdoors + - Night vision + - Disable interactions with certain blocks + +## Statistics + +![Graph](https://bstats.org/signatures/bukkit/BuildSystem.svg) + +## Developer API + +**Maven:** + +```xml + + + de.eintosti + buildsystem-api + version + + ``` + +**Or alternatively, with Gradle:** + +```kotlin +repositories { + mavenCentral() +} + +dependencies { + compileOnly("de.eintosti:buildsystem-api:version") +} +``` + +## Contributing + +### To compile... + +#### On Windows + +1. Shift + right-click the folder with the directory’s files and click "Open command prompt". +2. `gradlew clean build` + +#### On Linux, BSD, or Mac OS X + +1. In your terminal, navigate to the folder with directory’s files (cd /folder/of/buildsystem/files) +2. `./gradlew clean build` + +### Then you will find... + +* the **BuildSystem** plugin jar `BuildSystem-` in **buildsystem-core/build/libs** + +### Other commands + +* `gradlew idea` will generate an [IntelliJ IDEA](https://www.jetbrains.com/idea/) module for each + folder. +* `gradlew eclipse` will generate an [Eclipse](https://www.eclipse.org/downloads/) project for each + folder. _(Possibly broken!)_ + +### PR Policy + +I'll accept changes that make sense. You should be able to justify their existence, along with any +maintenance costs +that come with them. + +## License + +This project is licensed under the [GNU General Public License v3.0](LICENSE.txt). diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..8909e98 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("io.github.gradle-nexus.publish-plugin") version "2.0.0" +} + +nexusPublishing { + repositories { + sonatype { + nexusUrl.set(uri("https://ossrh-staging-api.central.sonatype.com/service/local/")) + snapshotRepositoryUrl.set(uri("https://central.sonatype.com/repository/maven-snapshots/")) + } + } +} \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts new file mode 100644 index 0000000..14a61d5 --- /dev/null +++ b/buildSrc/build.gradle.kts @@ -0,0 +1,26 @@ +import java.util.* + +plugins { + `kotlin-dsl` +} + +repositories { + mavenCentral() + gradlePluginPortal() +} + +val properties = Properties().also { props -> + project.projectDir.resolveSibling("gradle.properties").bufferedReader().use { + props.load(it) + } +} + +dependencies { + implementation(gradleApi()) +} + +kotlin { + jvmToolchain { + this.languageVersion.set(JavaLanguageVersion.of(21)) + } +} diff --git a/buildSrc/src/main/kotlin/CommonConfig.kt b/buildSrc/src/main/kotlin/CommonConfig.kt new file mode 100644 index 0000000..53699f6 --- /dev/null +++ b/buildSrc/src/main/kotlin/CommonConfig.kt @@ -0,0 +1,41 @@ +import org.gradle.api.Project +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.api.tasks.compile.JavaCompile +import org.gradle.jvm.toolchain.JavaLanguageVersion +import org.gradle.kotlin.dsl.repositories +import org.gradle.kotlin.dsl.the +import org.gradle.kotlin.dsl.withType + +fun Project.applyCommonConfiguration() { + group = rootProject.group + version = rootProject.version + + repositories { + mavenCentral() + maven { + name = "Spigot" + url = uri("https://hub.spigotmc.org/nexus/content/repositories/snapshots/") + } + maven { + name = "PaperMC" + url = uri("https://repo.papermc.io/repository/maven-public/") + } + maven { + name = "OSS Sonatype Snapshots" + url = uri("https://oss.sonatype.org/content/repositories/snapshots/") + } + mavenLocal() + } + + plugins.withId("java") { + the().apply { + toolchain { + languageVersion.set(JavaLanguageVersion.of(21)) + } + } + } + + tasks.withType { + options.encoding = Charsets.UTF_8.name() + } +} \ No newline at end of file diff --git a/buildSrc/src/main/kotlin/CoreConfig.kt b/buildSrc/src/main/kotlin/CoreConfig.kt new file mode 100644 index 0000000..665c8bc --- /dev/null +++ b/buildSrc/src/main/kotlin/CoreConfig.kt @@ -0,0 +1,29 @@ +import org.gradle.api.Project +import org.gradle.api.artifacts.dsl.RepositoryHandler +import org.gradle.api.plugins.JavaPluginExtension +import org.gradle.kotlin.dsl.apply +import org.gradle.kotlin.dsl.ivy +import org.gradle.kotlin.dsl.the + +fun Project.applyCoreConfiguration() { + applyCommonConfiguration() + + apply(plugin = "java") + apply(plugin = "eclipse") + apply(plugin = "idea") + + if (name in setOf("buildsystem-core")) { + the().withSourcesJar() + } +} + +fun RepositoryHandler.modrinthMavenWorkaround(nameOrId: String, version: String, fileName: String) { + val url = "https://api.modrinth.com/maven/maven/modrinth/$nameOrId/$version/$fileName" + val group = "maven.modrinth.workaround" + ivy(url.substringBeforeLast('/')) { + name = "Modrinth Maven Workaround for $nameOrId" + patternLayout { artifact(url.substringAfterLast('/')) } + metadataSources { artifact() } + content { includeModule(group, nameOrId) } + } +} \ No newline at end of file diff --git a/buildsystem-api/build.gradle.kts b/buildsystem-api/build.gradle.kts new file mode 100644 index 0000000..a0b61bd --- /dev/null +++ b/buildsystem-api/build.gradle.kts @@ -0,0 +1,72 @@ +applyCommonConfiguration() + +plugins { + id("java") + id("maven-publish") + id("signing") +} + +project.description = "API" + +dependencies { + compileOnly(libs.spigot) + compileOnly(libs.xseries) + compileOnly(libs.annotations) + compileOnly(libs.jspecify) +} + +java { + withSourcesJar() + withJavadocJar() +} + + + +publishing { + publications { + create("mavenJava") { + from(components["java"]) + + pom { + name.set("BuildSystem API") + description.set("API for the BuildSystem Minecraft plugin.") + url.set("https://github.com/thomasmny/BuildSystem") + + licenses { + license { + name.set("GNU General Public License, Version 3") + url.set("https://www.gnu.org/licenses/gpl-3.0.txt") + distribution.set("repo") + } + } + developers { + developer { + id.set("thomasmny") + name.set("Thomas Meaney") + email.set("thomas.meaney@icloud.com") + } + } + scm { + connection.set("scm:git:git://github.com/thomasmny/BuildSystem.git") + developerConnection.set("scm:git:ssh://github.com:thomasmny/BuildSystem.git") + url.set("https://github.com/thomasmny/BuildSystem") + tag.set(project.version.toString()) + } + issueManagement { + system.set("GitHub") + url.set("https://github.com/thomasmny/BuildSystem/issues") + } + } + } + } +} + +signing { + val signingKeyId: String? by project + val signingKey: String? by project + val signingPassword: String? by project + + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + isRequired = true + sign(publishing.publications["mavenJava"]) +} \ No newline at end of file diff --git a/buildsystem-api/javadoc/overview.html b/buildsystem-api/javadoc/overview.html new file mode 100644 index 0000000..1b9d941 --- /dev/null +++ b/buildsystem-api/javadoc/overview.html @@ -0,0 +1,11 @@ + +