From 47eb38341fc7e667cdc6d6fbcdbb8f9b131a9117 Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Tue, 21 Apr 2026 18:05:29 +0200 Subject: [PATCH 01/10] - quick test --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 5efa817..8e5c5c0 100644 --- a/README.md +++ b/README.md @@ -13,4 +13,4 @@ With this plugin you can plant your Christmas tree and upgrade it. Look for some * **LoneDev6** - *Optimization patches* - [LoneDev6](https://github.com/LoneDev6) * **montlikadani** - *Translation (hu)* - [montlikadani](https://github.com/montlikadani) -See also the list of [contributors](https://github.com/MelonCode/X-Mas/graphs/contributors) who participated in this project. +See also the list of [contributors](https://github.com/MelonCode/X-Mas/graphs/contributors) who participated in this project. From 559cc2e442205add8d013b99653f057da82299c8 Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Tue, 21 Apr 2026 20:46:45 +0200 Subject: [PATCH 02/10] - Attempt to bump to PaperMC 26.1.2 with Java 25 - Fix potential dupe - Allows items to be returned at any stage of the tree - Gives only the items back that they invested - Move to MiniMessage - Move to 26.1.2 - Better use of materials names - Moved /xmas to legacy alias command - Default modern command: /xmastree - Changed prefix and a few other phrases - Bunch of other fixes --- .gitignore | 14 +- README.md | 258 +++++++++++- build.gradle | 69 ++++ pom.xml | 92 ----- settings.gradle | 15 + src/main/java/ru/meloncode/xmas/Effects.java | 12 +- src/main/java/ru/meloncode/xmas/Events.java | 66 +++- .../java/ru/meloncode/xmas/ItemMaker.java | 26 +- .../java/ru/meloncode/xmas/LocaleManager.java | 25 +- .../java/ru/meloncode/xmas/MagicTree.java | 221 ++++++++--- src/main/java/ru/meloncode/xmas/Main.java | 283 ++++++++++++-- .../ru/meloncode/xmas/ParticleContainer.java | 31 +- .../ru/meloncode/xmas/PlayParticlesTask.java | 2 +- .../ru/meloncode/xmas/TreeSerializer.java | 72 +++- src/main/java/ru/meloncode/xmas/XMas.java | 60 ++- .../java/ru/meloncode/xmas/XMasCommand.java | 366 ++++++++++++++++-- .../xmas/XMasPlaceholderExpansion.java | 44 +++ .../ru/meloncode/xmas/XMasPlaceholders.java | 127 ++++++ .../ru/meloncode/xmas/utils/TextUtils.java | 95 ++++- src/main/resources/config.yml | 172 ++++++-- src/main/resources/locales/default.yml | 37 +- src/main/resources/locales/en.yml | 45 ++- src/main/resources/locales/hu.yml | 21 +- src/main/resources/locales/ru.yml | 19 +- src/main/resources/locales/ru_santa.yml | 15 +- src/main/resources/plugin.yml | 17 +- 26 files changed, 1810 insertions(+), 394 deletions(-) create mode 100644 build.gradle delete mode 100644 pom.xml create mode 100644 settings.gradle create mode 100644 src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java create mode 100644 src/main/java/ru/meloncode/xmas/XMasPlaceholders.java diff --git a/.gitignore b/.gitignore index d1000bd..2b1070e 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ release.properties dependency-reduced-pom.xml buildNumber.properties .mvn/timing.properties +.gradle/ +build/ # IntelliJ project files .idea @@ -17,4 +19,14 @@ gen .project .classpath .settings -bin \ No newline at end of file +bin + +# Local editors and OS files +.DS_Store +Thumbs.db +.vscode/ +*.swp +*.swo + +# Local test servers and generated Paper data +servers/ diff --git a/README.md b/README.md index 8e5c5c0..f45e021 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,254 @@ -# X-Mas +# 1MB X-Mas Tree -![Image](http://puu.sh/dKlK1/85c3dad454.jpg) +Upgradeable Christmas trees for Paper servers. Players plant a magic spruce sapling, feed it resources, grow it through several tree levels, and collect presents that appear under the finished tree. -With this plugin you can plant your Christmas tree and upgrade it. Look for some gifts under it! +This fork keeps the old X-Mas event data usable for winter 2026 while moving the plugin forward for modern Paper and Java runtimes. -[SpigotMC plugin page](https://www.spigotmc.org/resources/x-mas-upgradeable-christmas-tree-event.2672/) +![X-Mas tree preview](http://puu.sh/dKlK1/85c3dad454.jpg) -## Authors +## Current targets -* **MelonCode** - *Original dev* - [MelonCode](https://github.com/MelonCode) -* **Ghost_chu** - *NMS fixes* - [Ghost_chu](https://github.com/Ghost-chu) -* **LoneDev6** - *Optimization patches* - [LoneDev6](https://github.com/LoneDev6) -* **montlikadani** - *Translation (hu)* - [montlikadani](https://github.com/montlikadani) +The Gradle build creates the legacy reference jar and the current Paper 26.1.2 target jar in `build/libs`: -See also the list of [contributors](https://github.com/MelonCode/X-Mas/graphs/contributors) who participated in this project. +| Jar | Purpose | +| --- | --- | +| `1MB-XMas-2026-v2.0.0-004-v21-1.21.8.jar` | Legacy reference jar copied from the deployed 2025 server jar. | +| `1MB-XMas-2026-v2.0.1-010-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | + +The checked-in source targets Paper 26.1.2. The legacy jar is preserved so the deployed working 2025 behavior can be compared or rolled back during testing. + +## Features + +- Magic Christmas Crystal item for planting event trees. +- Upgradeable spruce tree levels with configurable material requirements. +- Random present spawning under grown trees. +- Configurable present head skins using Mojang texture URLs. +- Configurable gift table using modern material names or saved exact items. +- MiniMessage support for locale strings, crystal display text, and plugin messages. +- Existing `plugins/X-Mas/trees.yml` data remains the event data source. +- Optional resource refunds when a tree is destroyed or cleaned up after the event. +- Configurable per-stage particles using Paper 26.1.2 particle names. +- `/xmastree debug` pages for status, commands, permissions, placeholders, and global boolean toggles. +- Primary `/xmastree` command with an optional legacy `/xmas` alias. +- Optional PlaceholderAPI placeholders for CMI holograms, ajLeaderboards, scoreboards, and menus. +- Legacy `trees.yml` world-name alias support for renamed destination worlds. + +## Installation + +1. Stop the Paper server. +2. Back up the existing `plugins/X-Mas` folder and the world folders. +3. Remove the old X-Mas jar from `plugins` so Paper does not load two copies of the same plugin. +4. Copy the jar for your server target into the server `plugins` folder. +5. Start the server with Java 25. +6. Check the console for XMas Tree startup messages, then run `/xmastree` in game or console. + +For the 2026 target, use the modern Paper 26.1.2 jar: + +- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-010-v25-26.1.2.jar` + +## Building + +Requirements: + +- JDK 25 +- Gradle +- The local Paper server folder in `servers/Server-Two-Paper-26.1.2` +- The local PlaceholderAPI jar in `servers/Server-Two-Paper-26.1.2/plugins` +- The deployed legacy jar in `servers/Server-One-Paper-1.21.11/plugins` if you want `legacyJar` + +Build the current Paper 26.1.2 jar and the legacy reference jar: + +```bash +gradle clean buildAllJars +``` + +Build only the Paper 26.1.2 jar: + +```bash +gradle jar +``` + +The `paper2612Jar` task is kept as an alias: + +```bash +gradle paper2612Jar +``` + +Copy the deployed legacy jar into the requested legacy filename: + +```bash +gradle legacyJar +``` + +The build compiles against the Paper 26.1.2 API jars found in `servers/Server-Two-Paper-26.1.2`. If that folder is missing or has not been started far enough for Paper to download its libraries, Gradle will not have the Paper API classpath it needs. + +## Commands + +The primary command is `/xmastree`. + +If `core.commands.legacy-command-enabled` is `true`, the legacy `/xmas` alias is also registered. + +| Command | Description | +| --- | --- | +| `/xmastree` | Shows plugin version, event status, auto-end status, resource-back status, tree count, and owner count. | +| `/xmastree help` | Shows the command list. | +| `/xmastree give ` | Gives an online player a Christmas Crystal. | +| `/xmastree gifts` | Spawns a small batch of presents under every loaded Christmas tree. | +| `/xmastree addhand` | Adds the item in your main hand to the gift list and saves it to `config.yml`. | +| `/xmastree reload` | Reloads config, locale, present heads, gifts, luck settings, command alias settings, and tree level requirements. | +| `/xmastree debug [page]` | Shows paginated status, commands, permissions, placeholders, and toggleable global config keys. | +| `/xmastree debug toggle true\|false` | Toggles supported global boolean config keys and reloads the plugin config. | +| `/xmastree end` | Ends the event and sets `core.plugin-enabled` to `false`. | + +## Permissions + +| Permission | Default | Description | +| --- | --- | --- | +| `xmas.admin` | `op` | Allows use of the `/xmastree` command and all XMas Tree admin subcommands. | + +## Player flow + +Players can receive a Christmas Crystal from an admin, or craft one with diamonds around an emerald in a cross shape. Right-click a spruce sapling with the crystal to create a magic tree. + +After planting, players right-click the tree with the configured level-up materials. The requirement header is short in chat and includes a hover hint that explains the ingredients must be fed into the tree by right-clicking while holding them. When all requirements for a level are complete, right-click the tree again to grow it. Presents spawn around grown trees while the event is enabled. + +If `core.holiday-ends.resource-back` is enabled, confirmed tree destruction returns the upgrade materials that were actually spent on the tree. The plugin tries to place a chest first, then a barrel, then the player's inventory, and finally drops any overflow at the tree location. + +Ingredient accept sounds can be tuned live in `config.yml` under `core.sounds.grow`. Use `0.0` for silent, `0.1` for quiet, `0.5` for half volume, and `1.0` for full volume. `/xmastree reload` applies the new values without a server restart. + +## Configuration + +The plugin writes its files to `plugins/X-Mas`: + +- `config.yml` controls event timing, locale, tree limits, gift cooldowns, present skins, gift items, and level-up requirements. +- `trees.yml` stores placed tree data and should be kept when upgrading an existing event. +- `locales/*.yml` controls player-facing messages and crystal display text. + +Use modern Paper/Bukkit material names such as `GOLD_INGOT`, `SPRUCE_LOG`, and `PLAYER_HEAD`. Legacy numeric IDs and old material names are skipped to avoid modern Paper exceptions. + +Gift entries in `xmas.gifts` can be simple material names: + +```yaml +- DIAMOND +- EMERALD:3 +``` + +Admins can also hold an item and run `/xmastree addhand`. This saves the exact item as Base64 so custom names, lore, enchantments, and metadata can be used as gifts. + +Legacy world-name remapping lives under `migration.world-aliases`. This is useful when an old `trees.yml` was saved in worlds like `general`, `wild`, or `santa`, but the new Paper 26.1.2 server uses different world names: + +```yaml +migration: + world-aliases: + general: world + wild: world + santa: santa_event +``` + +The saved coordinates are preserved. If the destination world terrain or world border changed, some legacy tree locations may still need manual cleanup. + +Present head entries in `xmas.presents` should use `textures.minecraft.net` URLs. Old player-name entries are still accepted for compatibility. + +Per-stage particles live under `xmas.tree-lvl..particles`. Particle names should come from the Paper 26.1.2 `Particle` enum: + +[jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html](https://jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html) + +The config currently supports simple particles and `DUST`. + +## MiniMessage + +Locale messages, crystal names, crystal lore, and command messages support MiniMessage: + +```yaml +crystal: + name: Christmas Crystal + lore: + - Concentrated Christmas Spirit + - Use it on a spruce sapling to fill it with magic! +``` + +Legacy `&` color codes are still parsed for compatibility when a message does not contain MiniMessage tags. + +## Placeholders + +PlaceholderAPI is optional. If PlaceholderAPI is installed, X-Mas registers the `onembxmastree` expansion. + +PlaceholderAPI requires an underscore after the expansion identifier, so use: + +```text +%onembxmastree_event.active% +``` + +The dotted key after `onembxmastree_` is supported to keep the placeholders readable and namespaced. Underscore variants also work, for example `%onembxmastree_event_active%`. + +| Placeholder | Example output | Description | +| --- | --- | --- | +| `%onembxmastree_event.active%` | `true` | Whether the event is currently active. | +| `%onembxmastree_event.active_text%` | `Active` | Human-readable active/inactive state. | +| `%onembxmastree_event.status%` | `In Progress` | Current event status text. | +| `%onembxmastree_event.starts_at%` | `manual` | Start mode. The plugin currently starts from `core.plugin-enabled`, not a scheduled start date. | +| `%onembxmastree_event.ends_at%` | `10-01-2027 03-33-33` | Configured event end date. | +| `%onembxmastree_event.ends_in%` | `263d 7h` | Approximate time until the configured end date, or `disabled` when auto-end is off. | +| `%onembxmastree_event.ends_timestamp%` | `1799552013000` | Event end timestamp in milliseconds. | +| `%onembxmastree_event.auto_end%` | `true` | Whether automatic event ending is enabled. | +| `%onembxmastree_resource.back%` | `true` | Whether resource refunds are enabled. | +| `%onembxmastree_resource.back_text%` | `Yes` | Human-readable refund state. | +| `%onembxmastree_particles.enabled%` | `true` | Whether X-Mas particles are globally enabled. | +| `%onembxmastree_luck.enabled%` | `false` | Whether gift luck chance is enabled. | +| `%onembxmastree_luck.chance%` | `75` | Gift luck chance as a percent. | +| `%onembxmastree_trees.total%` | `14` | Total loaded X-Mas trees. | +| `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | +| `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | +| `%onembxmastree_version%` | `2.0.1-010` | Loaded plugin version. | + +CMI hologram example: + +```text +&aX-Mas Event: &f%onembxmastree_event.active_text% +&aEnds in: &f%onembxmastree_event.ends_in% +&aResource back: &f%onembxmastree_resource.back_text% +&aTrees planted: &f%onembxmastree_trees.total% +``` + +ajLeaderboards placeholder examples: + +```text +%onembxmastree_trees.total% +%onembxmastree_trees.owners% +%onembxmastree_player.trees% +``` + +## Compatibility notes + +- Back up `plugins/X-Mas/trees.yml` before upgrading a live server. +- Existing tree records are loaded from the same `trees.yml` format. +- When saved world names no longer match the current server world names, `migration.world-aliases` can remap them without rewriting `trees.yml`. +- Existing present head player-name entries are still accepted, but new configs should prefer Mojang texture URLs. +- The modern jars are compiled with Java 25 bytecode and should be run on Java 25. +- The Paper 26.1.2 jar is the intended winter 2026 target. Paper 1.21.11 compatibility is no longer part of the active test path. + +## Security notes + +- Admin commands are gated by `xmas.admin`. +- Present texture URLs are restricted to `textures.minecraft.net`. +- Gift item Base64 entries are capped before deserialization. +- Config material names are resolved with modern `Material.matchMaterial` and invalid or legacy values are skipped. +- Treat `config.yml` and locale files as trusted admin-controlled files, especially when using MiniMessage click or hover tags. + +## Support + +Please report bugs, compatibility problems, and upgrade questions in the GitHub issues section: + +[github.com/mrfdev/XMasTree/issues](https://github.com/mrfdev/XMasTree/issues) + +## Credits + +- **MelonCode** - Original developer - [MelonCode](https://github.com/MelonCode) +- **Ghost_chu** - NMS fixes - [Ghost-chu](https://github.com/Ghost-chu) +- **LoneDev6** - Optimization patches - [LoneDev6](https://github.com/LoneDev6) +- **montlikadani** - Hungarian translation - [montlikadani](https://github.com/montlikadani) +- **1MB / mrfdev** - 2026 Paper modernization, Java 25 builds, and XMasTree maintenance + +Original SpigotMC listing: + +[spigotmc.org/resources/x-mas-upgradeable-christmas-tree-event.2672](https://www.spigotmc.org/resources/x-mas-upgradeable-christmas-tree-event.2672/) diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..70258e1 --- /dev/null +++ b/build.gradle @@ -0,0 +1,69 @@ +plugins { + id 'java' +} + +group = 'com.onemb.xmas' +version = '2.0.1-010' + +def legacyArchiveName = '1MB-XMas-2026-v2.0.0-004-v21-1.21.8.jar' +def paper2612ArchiveName = '1MB-XMas-2026-v2.0.1-010-v25-26.1.2.jar' + +def serverOne = layout.projectDirectory.dir('servers/Server-One-Paper-1.21.11') +def serverTwo = layout.projectDirectory.dir('servers/Server-Two-Paper-26.1.2') +def legacyServerJar = serverOne.file('plugins/1MB-X-Mas_2025-1.21.8.jar') +def placeholderApiJar = serverTwo.file('plugins/PlaceholderAPI-2.12.3-DEV-265.jar') + +def paper2612Api = serverTwo.file('libraries/io/papermc/paper/paper-api/26.1.2.build.18-alpha/paper-api-26.1.2.build.18-alpha.jar') + +def paper2612Classpath = files(paper2612Api) + fileTree(dir: serverTwo.dir('libraries').asFile, include: '**/*.jar') + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} + +dependencies { + compileOnly paper2612Classpath + compileOnly files(placeholderApiJar) +} + +tasks.withType(JavaCompile).configureEach { + options.encoding = 'UTF-8' + options.release = 25 +} + +tasks.named('processResources') { + filteringCharset = 'UTF-8' + filesMatching('plugin.yml') { + expand(version: project.version) + } +} + +tasks.named('jar') { + archiveFileName = paper2612ArchiveName +} + +tasks.register('paper2612Jar') { + description = 'Assembles the Paper 26.1.2 Java 25 plugin jar.' + group = 'build' + dependsOn tasks.named('jar') +} + +tasks.register('legacyJar', Copy) { + description = 'Copies the deployed legacy jar into the requested 2026 legacy filename.' + group = 'build' + from(legacyServerJar) + into(layout.buildDirectory.dir('libs')) + rename { legacyArchiveName } +} + +tasks.register('buildAllJars') { + description = 'Builds the legacy reference jar plus the modern Paper 26.1.2 target jar.' + group = 'build' + dependsOn tasks.named('legacyJar'), tasks.named('jar') +} + +tasks.named('assemble') { + dependsOn tasks.named('legacyJar'), tasks.named('paper2612Jar') +} diff --git a/pom.xml b/pom.xml deleted file mode 100644 index b894d55..0000000 --- a/pom.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - 4.0.0 - - me.meloncode - xmas - 2.5 - - - - spigot-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots/ - - - bungeecord-repo - https://oss.sonatype.org/content/repositories/snapshots - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.1 - - 1.8 - 1.8 - UTF-8 - - - - maven-shade-plugin - - false - - - - package - - shade - - - - - - - - true - src/main/resources - - - - - - - - org.spigotmc - spigot-api - 1.15-R0.1-SNAPSHOT - provided - - - - org.bukkit - bukkit - 1.15-R0.1-SNAPSHOT - provided - - - - net.md-5 - bungeecord-api - 1.15-SNAPSHOT - - - - org.apache.commons - commons-lang3 - 3.0 - - - org.jetbrains - annotations - 17.0.0 - compile - - - - \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..192ee77 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = '1MB-XMas' diff --git a/src/main/java/ru/meloncode/xmas/Effects.java b/src/main/java/ru/meloncode/xmas/Effects.java index 0270af3..b7a7b31 100644 --- a/src/main/java/ru/meloncode/xmas/Effects.java +++ b/src/main/java/ru/meloncode/xmas/Effects.java @@ -6,15 +6,15 @@ class Effects { public static final ParticleContainer AMBIENT_SAPLING = new ParticleContainer(Particle.PORTAL, 0.2f, 0.25f, 0.2f, 0.1f, 16); public static final ParticleContainer AMBIENT_PORTAL = new ParticleContainer(Particle.PORTAL, 2f, 2f, 2f, 0.1f, 16); - public static final ParticleContainer TREE_SWAG = new ParticleContainer(Particle.REDSTONE, 0.25f, 0.25f, 0.25f, 10f, 16); + public static final ParticleContainer TREE_SWAG = new ParticleContainer(Particle.DUST, 0.25f, 0.25f, 0.25f, 10f, 16); public static final ParticleContainer TREE_HEARTS_AMBIENT = new ParticleContainer(Particle.HEART, 1.25f, 1.25f, 1.25f, 10f, 1); - public static final ParticleContainer TREE_RED_SWAG = new ParticleContainer(Particle.REDSTONE, 0.25f, 0.25f, 0.25f, 0f, 16); - public static final ParticleContainer TREE_WHITE_AMBIENT = new ParticleContainer(Particle.FIREWORKS_SPARK, 2.25f, 2.25f, 2.25f, 0f, 4); + public static final ParticleContainer TREE_RED_SWAG = new ParticleContainer(Particle.DUST, 0.25f, 0.25f, 0.25f, 0f, 16); + public static final ParticleContainer TREE_WHITE_AMBIENT = new ParticleContainer(Particle.FIREWORK, 2.25f, 2.25f, 2.25f, 0f, 4); public static final ParticleContainer TREE_CRIT_SWAG = new ParticleContainer(Particle.CRIT, 0.25f, 0.25f, 0.25f, 0f, 16); public static final ParticleContainer TREE_GOLD_SWAG = new ParticleContainer(Particle.FLAME, 0.25f, 0.25f, 0.25f, 0f, 16); - public static final ParticleContainer SMOKE = new ParticleContainer(Particle.SMOKE_LARGE, 0f, 0f, 0f, 0f, 16); + public static final ParticleContainer SMOKE = new ParticleContainer(Particle.LARGE_SMOKE, 0f, 0f, 0f, 0f, 16); - public static final ParticleContainer GROW = new ParticleContainer(Particle.VILLAGER_HAPPY, 0.25f, 0.25f, 0.25f, 1f, 16); - public static final ParticleContainer AMBIENT_SNOW = new ParticleContainer(Particle.SNOW_SHOVEL, 1.5f, 3f, 1.5f, 0, 16); + public static final ParticleContainer GROW = new ParticleContainer(Particle.HAPPY_VILLAGER, 0.25f, 0.25f, 0.25f, 1f, 16); + public static final ParticleContainer AMBIENT_SNOW = new ParticleContainer(Particle.ITEM_SNOWBALL, 1.5f, 3f, 1.5f, 0, 16); } diff --git a/src/main/java/ru/meloncode/xmas/Events.java b/src/main/java/ru/meloncode/xmas/Events.java index 5ce427c..e0d3b60 100644 --- a/src/main/java/ru/meloncode/xmas/Events.java +++ b/src/main/java/ru/meloncode/xmas/Events.java @@ -19,7 +19,7 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.meta.SkullMeta; -import org.jetbrains.annotations.Nullable; +import org.bukkit.persistence.PersistentDataType; import ru.meloncode.xmas.utils.TextUtils; import java.util.Collection; @@ -70,9 +70,15 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { Player player = event.getPlayer(); if (event.getAction() == Action.RIGHT_CLICK_BLOCK) { Block block = event.getClickedBlock(); + if (block == null) { + return; + } if (MagicTree.isBlockBelongs(block)) { event.setCancelled(true); MagicTree tree = MagicTree.getTreeByBlock(block); + if (tree == null) { + return; + } if (Main.inProgress) { if (tree.getLevel().hasNext()) { if (tree.canLevelUp()) { @@ -95,7 +101,7 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { } if (tree.level.nextLevel != null) { TextUtils.sendMessage(player, LocaleManager.GROW_LVL_PROGRESS); - for (String line : TextUtils.generateChatReqList(tree)) { + for (var line : TextUtils.generateChatReqList(tree)) { TextUtils.sendMessage(player, line); } @@ -109,7 +115,7 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { } } else { if (player.getUniqueId().equals(tree.getOwner())) { - tree.end(); + tree.end(player); TextUtils.sendMessage(player, LocaleManager.TIMEOUT); } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); @@ -123,7 +129,7 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { if (XMas.getTreesPlayerOwn(player).size() < Main.MAX_TREE_COUNT) { if (is.getType() == XMas.XMAS_CRYSTAL.getType() && is.hasItemMeta() && is.getItemMeta().hasLore()) { ItemMeta im = is.getItemMeta(); - if (im.getLore().equals(XMas.XMAS_CRYSTAL.getItemMeta().getLore())) { + if (im != null && im.getPersistentDataContainer().has(Main.getCrystalKey(), PersistentDataType.BYTE)) { if (player.getGameMode() != GameMode.CREATIVE) { if (is.getAmount() > 1) { is.setAmount(is.getAmount() - 1); @@ -168,7 +174,8 @@ public void onItemSpawn(ItemSpawnEvent event) { ItemStack item = event.getEntity().getItemStack(); if (item.getType() == Material.PLAYER_HEAD) { SkullMeta meta = (SkullMeta) item.getItemMeta(); - if (meta.getOwner() != null && Main.getHeads().contains(meta.getOwner())) { + String headId = getHeadIdentifier(meta); + if (headId != null && Main.getHeads().contains(headId)) { event.setCancelled(true); } } @@ -213,17 +220,24 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { if (player.getUniqueId().equals(tree.getOwner()) || player.hasPermission("xmas.admin")) { if (Main.inProgress) if (destroyers.containsKey(player.getUniqueId()) && System.currentTimeMillis() - destroyers.get(player.getUniqueId()) <= 10000) { - XMas.removeTree(tree); + if (Main.resourceBack) { + tree.end(player); + } else { + XMas.removeTree(tree); + } if (Main.inProgress) - TextUtils.sendMessage(player, ChatColor.DARK_RED + LocaleManager.MONSTER); + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_COMPLETE); } else { destroyers.put(player.getUniqueId(), System.currentTimeMillis()); if (Main.inProgress) - TextUtils.sendMessage(player, ChatColor.RED + LocaleManager.DESTROY_WARNING); - TextUtils.sendMessage(player, ChatColor.DARK_RED + LocaleManager.DESTROY_TUT); + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_WARNING); + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_TUT); + if (Main.resourceBack) { + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_RESOURCE_BACK); + } } else { - tree.end(); + tree.end(player); } } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); @@ -232,9 +246,9 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { case SPRUCE_LEAVES: case GLOWSTONE: if (Main.inProgress) - TextUtils.sendMessage(player, ChatColor.DARK_GREEN + LocaleManager.DESTROY_LEAVES_SANTA); + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_LEAVES_SANTA); if (player.getUniqueId().equals(tree.getOwner()) || player.hasPermission("xmas.admin")) { - TextUtils.sendMessage(player, ChatColor.RED + LocaleManager.DESTROY_LEAVES_TUT); + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_LEAVES_TUT); } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); } @@ -243,16 +257,23 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { if (player.getUniqueId().equals(tree.getOwner()) || player.hasPermission("xmas.admin")) { if (Main.inProgress) { if (destroyers.containsKey(player.getUniqueId()) && System.currentTimeMillis() - destroyers.get(player.getUniqueId()) <= 10000) { - XMas.removeTree(tree); - TextUtils.sendMessage(player, ChatColor.RED + LocaleManager.MONSTER); + if (Main.resourceBack) { + tree.end(player); + } else { + XMas.removeTree(tree); + } + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_COMPLETE); } else { destroyers.put(player.getUniqueId(), System.currentTimeMillis()); if (Main.inProgress) - TextUtils.sendMessage(player, ChatColor.RED + LocaleManager.DESTROY_SAPLING); - TextUtils.sendMessage(player, ChatColor.DARK_RED + LocaleManager.DESTROY_TUT); + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_SAPLING); + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_TUT); + if (Main.resourceBack) { + TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_RESOURCE_BACK); + } } } else { - tree.end(); + tree.end(player); } } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); @@ -290,7 +311,7 @@ public void disableDecay(LeavesDecayEvent e) @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) private void disableFireworkDamage(EntityDamageByEntityEvent e) { - if (e.getDamager().getType() == EntityType.FIREWORK) { + if (e.getDamager().getType() == EntityType.FIREWORK_ROCKET) { if (e.getDamager().hasMetadata("nodamage")) { e.setCancelled(true); } @@ -309,4 +330,13 @@ private void chunkLoad(ChunkLoadEvent e) tree.spawnScheduledPresents(); } } + + private String getHeadIdentifier(SkullMeta meta) { + if (meta.getOwnerProfile() != null + && meta.getOwnerProfile().getTextures() != null + && meta.getOwnerProfile().getTextures().getSkin() != null) { + return meta.getOwnerProfile().getTextures().getSkin().toString(); + } + return meta.getOwner(); + } } diff --git a/src/main/java/ru/meloncode/xmas/ItemMaker.java b/src/main/java/ru/meloncode/xmas/ItemMaker.java index 444639f..eabe9c8 100644 --- a/src/main/java/ru/meloncode/xmas/ItemMaker.java +++ b/src/main/java/ru/meloncode/xmas/ItemMaker.java @@ -2,12 +2,13 @@ //I plan to make this plugin bigger. So... -import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.Damageable; import org.bukkit.inventory.meta.ItemMeta; +import net.kyori.adventure.text.format.TextDecoration; +import ru.meloncode.xmas.utils.TextUtils; import java.util.ArrayList; import java.util.List; @@ -25,14 +26,14 @@ public ItemMaker(Material material) { public ItemMaker(Material material, String name) { is = new ItemStack(material); im = is.getItemMeta(); - im.setDisplayName(name); + im.displayName(TextUtils.parse(name).decoration(TextDecoration.ITALIC, false)); } public ItemMaker(Material material, String name, List lore) { is = new ItemStack(material); im = is.getItemMeta(); - im.setDisplayName(name); - im.setLore(lore); + im.displayName(TextUtils.parse(name).decoration(TextDecoration.ITALIC, false)); + im.lore(TextUtils.parseList(lore)); } public ItemMaker(Material material, int amount, short durability) { @@ -43,14 +44,14 @@ public ItemMaker(Material material, int amount, short durability) { public ItemMaker(Material material, int amount, short durability, String name) { is = new ItemStack(material, amount); im = is.getItemMeta(); - im.setDisplayName(name); + im.displayName(TextUtils.parse(name).decoration(TextDecoration.ITALIC, false)); } public ItemMaker(Material material, int amount, short durability, String name, List lore) { is = new ItemStack(material, amount); im = is.getItemMeta(); - im.setDisplayName(name); - im.setLore(lore); + im.displayName(TextUtils.parse(name).decoration(TextDecoration.ITALIC, false)); + im.lore(TextUtils.parseList(lore)); } public ItemMaker setAmount(int amount) { @@ -59,18 +60,19 @@ public ItemMaker setAmount(int amount) { } public ItemMaker setDurability(short data) { - //is.setDurability(data); - ((Damageable) is).setDamage(data); + if (im instanceof Damageable damageable) { + damageable.setDamage(data); + } return this; } public ItemMaker setName(String name) { - im.setDisplayName(ChatColor.RESET + name); + im.displayName(TextUtils.parse("" + name).decoration(TextDecoration.ITALIC, false)); return this; } public ItemMaker setLore(List lore) { - im.setLore(lore); + im.lore(TextUtils.parseList(lore)); return this; } @@ -82,7 +84,7 @@ public ItemMaker addLoreLine(String line) { lore = new ArrayList<>(); } lore.add(line); - im.setLore(lore); + im.lore(TextUtils.parseList(lore)); return this; } diff --git a/src/main/java/ru/meloncode/xmas/LocaleManager.java b/src/main/java/ru/meloncode/xmas/LocaleManager.java index 889d840..91cd990 100644 --- a/src/main/java/ru/meloncode/xmas/LocaleManager.java +++ b/src/main/java/ru/meloncode/xmas/LocaleManager.java @@ -1,6 +1,5 @@ package ru.meloncode.xmas; -import org.bukkit.ChatColor; import org.bukkit.configuration.file.FileConfiguration; import ru.meloncode.xmas.utils.ConfigUtils; import ru.meloncode.xmas.utils.TextUtils; @@ -18,19 +17,21 @@ public class LocaleManager { public static String GROW_LVL_READY; public static String GROW_LEVEL_MAX; public static String GROW_REQ_LIST_TITLE; + public static String GROW_REQ_LIST_HINT; public static String GROW_NOT_ENOUGH_PLACE; public static String TREE_LIMIT; public static String DESTROY_SAPLING; public static String DESTROY_LEAVES_SANTA; public static String DESTROY_LEAVES_TUT; public static String DESTROY_WARNING; + public static String DESTROY_RESOURCE_BACK; public static String DESTROY_FAIL_OWNER; public static String DESTROY_TUT; + public static String DESTROY_COMPLETE; public static String CRYSTAL_NAME; public static List CRYSTAL_LORE; public static String GIFT_LUCK; public static String GIFT_FAIL; - public static String MONSTER; public static String TIMEOUT; public static String HAPPY_NEW_YEAR; @@ -48,7 +49,7 @@ public static void loadLocale(String lang) { locale = def_locale; } else { locale = ConfigUtils.loadConfig(file); - TextUtils.sendConsoleMessage("Locale '" + lang + "' successfuly loaded"); + TextUtils.sendConsoleMessage("Locale '" + lang + "' successfully loaded"); } loadStrings(); } @@ -60,16 +61,18 @@ private static void loadStrings() { GROW_LVL_READY = getString("messages.tree.grow-lvl-ready"); GROW_LEVEL_MAX = getString("messages.tree.grow-lvl-max"); GROW_REQ_LIST_TITLE = getString("messages.tree.grow-req-list-title"); + GROW_REQ_LIST_HINT = getString("messages.tree.grow-req-list-hint"); GROW_NOT_ENOUGH_PLACE = getString("messages.tree.grow-not-enough-place"); TREE_LIMIT = getString("messages.tree.tree-limit"); DESTROY_SAPLING = getString("messages.tree.destroy-sapling"); DESTROY_LEAVES_SANTA = getString("messages.tree.destroy-leaves-santa"); DESTROY_LEAVES_TUT = getString("messages.tree.destroy-leaves-tut"); - MONSTER = getString("messages.tree.destroy-complete"); DESTROY_WARNING = getString("messages.tree.destroy-warning"); + DESTROY_RESOURCE_BACK = getString("messages.tree.destroy-resource-back"); DESTROY_TUT = getString("messages.tree.destroy-tut"); + DESTROY_COMPLETE = getString("messages.tree.destroy-complete"); DESTROY_FAIL_OWNER = getString("messages.tree.destroy-fail-owner"); - CRYSTAL_NAME = ChatColor.GREEN + getString("crystal.name"); + CRYSTAL_NAME = getString("crystal.name"); CRYSTAL_LORE = getStringList("crystal.lore"); GIFT_LUCK = getString("messages.gift.luck-message"); GIFT_FAIL = getString("messages.gift.unluck-message"); @@ -88,11 +91,11 @@ private static String getString(String path) { throw new NullPointerException("Locale not loaded"); try { - String message = ChatColor.translateAlternateColorCodes('&', locale.getString(path)); + String message = locale.getString(path); return message.contains("_UNUSED") ? null : message; } catch (NullPointerException e) { - TextUtils.sendConsoleMessage(ChatColor.DARK_RED + "Unable to find '" + path + "' in locale " + Main.getInstance().getConfig().getString("core.locale") + ". Bad File?"); - TextUtils.sendConsoleMessage(ChatColor.DARK_RED + "Using default locale to get value"); + TextUtils.sendConsoleMessage("Unable to find '" + path + "' in locale " + Main.getInstance().getConfig().getString("core.locale") + ". Bad File?"); + TextUtils.sendConsoleMessage("Using default locale to get value"); return def_locale.getString(path); } } @@ -105,13 +108,13 @@ private static List getStringList(String path) { List raw = locale.getStringList(path); List list = new ArrayList<>(); for (String s : raw) { - list.add(ChatColor.translateAlternateColorCodes('&', s)); + list.add(s); } return list; } catch (IllegalArgumentException e) { - TextUtils.sendConsoleMessage(ChatColor.DARK_RED + "Unable to find '" + path + "' in locale " + Main.getInstance().getConfig().getString("core.locale") + ". Bad File?"); - TextUtils.sendConsoleMessage(ChatColor.DARK_RED + "Using default locale to get value"); + TextUtils.sendConsoleMessage("Unable to find '" + path + "' in locale " + Main.getInstance().getConfig().getString("core.locale") + ". Bad File?"); + TextUtils.sendConsoleMessage("Using default locale to get value"); return def_locale.getStringList(path); } diff --git a/src/main/java/ru/meloncode/xmas/MagicTree.java b/src/main/java/ru/meloncode/xmas/MagicTree.java index da86ce7..4dc8f10 100644 --- a/src/main/java/ru/meloncode/xmas/MagicTree.java +++ b/src/main/java/ru/meloncode/xmas/MagicTree.java @@ -10,8 +10,13 @@ import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.FireworkMeta; import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.profile.PlayerProfile; +import org.bukkit.profile.PlayerTextures; +import ru.meloncode.xmas.utils.TextUtils; import org.bukkit.util.Vector; +import java.net.MalformedURLException; +import java.net.URL; import java.util.*; import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; @@ -54,10 +59,16 @@ public MagicTree(UUID owner, UUID uid, TreeLevel level, Location location, Map getLevelupRequirements() { public boolean grow(Material material) { if (levelupRequirements.containsKey(material)) { + int levelRequirement = level.getLevelupRequirements().getOrDefault(material, levelupRequirements.get(material)); + int remainingBefore = levelupRequirements.get(material); + float volume = levelRequirement == remainingBefore ? Main.growFirstSoundVolume : Main.growRepeatSoundVolume; if (levelupRequirements.get(material) <= 1) { levelupRequirements.remove(material); } else { @@ -94,10 +108,11 @@ public boolean grow(Material material) { for (Block block : blocks) { if (block.getType() == Material.SPRUCE_LEAVES || block.getType() == Material.SPRUCE_SAPLING) { Effects.GROW.playEffect(block.getLocation()); - for (int i = 0; i <= 3; i++) - location.getWorld().playSound(location, Sound.ENTITY_PLAYER_LEVELUP, 1, Main.RANDOM.nextFloat() + 0.2f); } } + if (volume > 0) { + location.getWorld().playSound(location, Sound.ENTITY_PLAYER_LEVELUP, volume, Main.RANDOM.nextFloat() + 0.2f); + } save(); return true; } @@ -120,7 +135,7 @@ public void playParticles() { if (blocks != null && blocks.size() > 0) { for (Block block : blocks) { - if(!block.getWorld().isChunkLoaded(block.getX() / 16, block.getZ() / 16)) + if (!block.getChunk().isLoaded()) continue; if (block.getType() == Material.SPRUCE_LEAVES) { if (level.getSwagEffect() != null) { @@ -179,9 +194,13 @@ public void unbuild() { } } location.clone().add(0, -1, 0).getBlock().setType(Material.GRASS_BLOCK); + blocks = null; } public void build() { + if (blocks != null && !blocks.isEmpty()) { + return; + } if (level.getStructureTemplate().canGrow(location)) { blocks = level.getStructureTemplate().build(location); for (Block block : blocks) { @@ -192,7 +211,7 @@ public void build() { @SuppressWarnings("deprecation") public void spawnPresent() { - if(!location.getWorld().isChunkLoaded((int)location.getX() / 16, (int)location.getZ() / 16)) + if (!location.getChunk().isLoaded()) { if(scheduledPresents + 1 <= 8) scheduledPresents++; @@ -216,15 +235,41 @@ public void spawnPresent() { //skull.setRotation(face); Rotatable skullRotatable = (Rotatable) skull.getBlockData(); skullRotatable.setRotation(face); + skull.setRotation(face); //skull.setSkullType(SkullType.PLAYER); skull.setType(Material.PLAYER_HEAD); //skull.setOwner(); - skull.setOwningPlayer(Bukkit.getOfflinePlayer(Main.getHeads().get(Main.RANDOM.nextInt(Main.getHeads().size())))); + applyConfiguredHead(skull, Main.getHeads().get(Main.RANDOM.nextInt(Main.getHeads().size()))); skull.update(true); } } } + private void applyConfiguredHead(Skull skull, String configuredHead) { + if (configuredHead == null || configuredHead.trim().isEmpty()) { + return; + } + String trimmedHead = configuredHead.trim(); + if (!trimmedHead.contains("://")) { + skull.setOwningPlayer(Bukkit.getOfflinePlayer(trimmedHead)); + return; + } + try { + URL skinUrl = new URL(trimmedHead); + if (!"textures.minecraft.net".equalsIgnoreCase(skinUrl.getHost())) { + Bukkit.getLogger().warning("[X-Mas] Ignoring non-Mojang present skin URL: " + trimmedHead); + return; + } + PlayerProfile profile = Bukkit.createPlayerProfile(UUID.randomUUID()); + PlayerTextures textures = profile.getTextures(); + textures.setSkin(skinUrl); + profile.setTextures(textures); + skull.setOwnerProfile(profile); + } catch (MalformedURLException e) { + Bukkit.getLogger().warning("[X-Mas] Invalid present skin URL: " + trimmedHead); + } + } + public boolean canLevelUp() { return getLevelupRequirements().size() == 0; } @@ -238,58 +283,138 @@ public void save() { } public void end() { + end(getPlayerOwner()); + } + + public void end(Player refundTarget) { unbuild(); - // Bad code. Need it fast. - Block bl; - if ((bl = location.clone().add(1, 0, 0).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); - if ((bl = location.clone().add(-1, 0, 0).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); - if ((bl = location.clone().add(0, 0, 1).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); - if ((bl = location.clone().add(0, 0, -1).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); - - if ((bl = location.clone().add(1, 0, 1).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); - if ((bl = location.clone().add(-1, 0, -1).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); - - if ((bl = location.clone().add(-1, 0, 1).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); - if ((bl = location.clone().add(1, 0, -1).getBlock()).getType() == Material.PLAYER_HEAD) - bl.setType(Material.AIR); + clearNearbyPresents(); if (Main.resourceBack) { - bl = location.getBlock(); - bl.setType(Material.CHEST); - Chest chest = (Chest) bl.getState(); - Inventory inv = chest.getInventory(); - - inv.addItem(new ItemStack(Material.DIAMOND, 4)); - inv.addItem(new ItemStack(Material.EMERALD, 1)); - TreeLevel cLevel = TreeLevel.SAPLING; - while (cLevel != level) { - if (cLevel.getLevelupRequirements() != null && cLevel.getLevelupRequirements().size() > 0) { - for (Entry currItem : cLevel.getLevelupRequirements().entrySet()) { - inv.addItem(new ItemStack(currItem.getKey(), currItem.getValue())); - } + refundResources(refundTarget); + } + XMas.removeTree(this, false); + } + + private void clearNearbyPresents() { + Block bl; + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + if (x == 0 && z == 0) { + continue; + } + bl = location.clone().add(x, 0, z).getBlock(); + if (bl.getType() == Material.PLAYER_HEAD) { + bl.setType(Material.AIR); } + } + } + } + + private void refundResources(Player refundTarget) { + List refundItems = collectRefundItems(); + if (refundItems.isEmpty()) { + return; + } + + List leftovers = putRefundsInContainer(Material.CHEST, refundItems); + if (leftovers != null) { + dropRefunds(leftovers); + notifyRefund(refundTarget, "Your tree resources were returned in a chest."); + return; + } + + leftovers = putRefundsInContainer(Material.BARREL, refundItems); + if (leftovers != null) { + dropRefunds(leftovers); + notifyRefund(refundTarget, "Your tree resources were returned in a barrel."); + return; + } - if (cLevel.nextLevel == null) - break; - cLevel = cLevel.nextLevel; + leftovers = refundTarget != null ? addItems(refundTarget.getInventory(), refundItems) : refundItems; + dropRefunds(leftovers); + if (refundTarget != null) { + if (leftovers.isEmpty()) { + notifyRefund(refundTarget, "Your tree resources were returned to your inventory."); + } else { + notifyRefund(refundTarget, "Your tree resources were returned. Inventory overflow dropped at the tree."); } + } + } - int count = 0; + private List collectRefundItems() { + List refundItems = new ArrayList<>(); + + TreeLevel cLevel = TreeLevel.SAPLING; + while (cLevel != null && cLevel != level) { + addRequirements(refundItems, cLevel.getLevelupRequirements()); + cLevel = cLevel.nextLevel; + } + + if (level.getLevelupRequirements() != null) { for (Entry currItem : level.getLevelupRequirements().entrySet()) { - if (getLevelupRequirements().containsKey(currItem.getKey())) - count = getLevelupRequirements().get(currItem.getKey()); - if (currItem.getValue() - count > 0) - inv.addItem(new ItemStack(currItem.getKey(), currItem.getValue() - count)); - count = 0; + int remaining = getLevelupRequirements().getOrDefault(currItem.getKey(), 0); + int spent = Math.max(0, currItem.getValue() - remaining); + if (spent > 0) { + refundItems.add(new ItemStack(currItem.getKey(), spent)); + } } } - XMas.removeTree(this); + return refundItems; + } + + private void addRequirements(List refundItems, Map requirements) { + if (requirements == null || requirements.isEmpty()) { + return; + } + for (Entry currItem : requirements.entrySet()) { + if (currItem.getValue() > 0) { + refundItems.add(new ItemStack(currItem.getKey(), currItem.getValue())); + } + } + } + + private List putRefundsInContainer(Material containerMaterial, List refundItems) { + Block refundBlock = location.getBlock(); + try { + refundBlock.setType(containerMaterial, false); + BlockState state = refundBlock.getState(); + if (state instanceof Container container) { + return addItems(container.getInventory(), refundItems); + } + } catch (Exception e) { + Bukkit.getLogger().warning("[X-Mas] Failed to place refund " + containerMaterial + ": " + e.getMessage()); + } + refundBlock.setType(Material.AIR, false); + return null; + } + + private List addItems(Inventory inventory, List items) { + List clones = new ArrayList<>(); + for (ItemStack item : items) { + if (item != null && !item.getType().isAir()) { + clones.add(item.clone()); + } + } + Map leftovers = inventory.addItem(clones.toArray(new ItemStack[0])); + return new ArrayList<>(leftovers.values()); + } + + private void dropRefunds(List items) { + if (items == null || items.isEmpty() || location.getWorld() == null) { + return; + } + Location dropLocation = location.clone().add(0.5, 0.5, 0.5); + for (ItemStack item : items) { + if (item != null && !item.getType().isAir()) { + location.getWorld().dropItemNaturally(dropLocation, item.clone()); + } + } + } + + private void notifyRefund(Player refundTarget, String message) { + if (refundTarget != null && refundTarget.isOnline()) { + TextUtils.sendRawMessage(refundTarget, message); + } } public long getPresentCounter() { diff --git a/src/main/java/ru/meloncode/xmas/Main.java b/src/main/java/ru/meloncode/xmas/Main.java index bf3dc8f..ca3dab7 100644 --- a/src/main/java/ru/meloncode/xmas/Main.java +++ b/src/main/java/ru/meloncode/xmas/Main.java @@ -1,7 +1,7 @@ package ru.meloncode.xmas; -import com.google.common.collect.Lists; import org.bukkit.*; +import org.bukkit.command.PluginCommand; import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.event.EventHandler; @@ -9,8 +9,10 @@ import org.bukkit.event.world.WorldLoadEvent; import org.bukkit.event.world.WorldUnloadEvent; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; import org.bukkit.inventory.Recipe; import org.bukkit.inventory.ShapedRecipe; +import org.bukkit.persistence.PersistentDataType; import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.util.Vector; @@ -19,6 +21,7 @@ import java.io.File; import java.text.ParseException; import java.text.SimpleDateFormat; +import java.util.Base64; import java.util.*; public class Main extends JavaPlugin implements Listener { @@ -31,14 +34,20 @@ public class Main extends JavaPlugin implements Listener { static boolean resourceBack; static int MAX_TREE_COUNT; static boolean autoEnd; + static boolean particlesEnabled; + static float growFirstSoundVolume; + static float growRepeatSoundVolume; static long endTime; static boolean inProgress; private static int UPDATE_SPEED; private static int PARTICLES_DELAY; + private static NamespacedKey crystalKey; private static List heads; private static Plugin plugin; + private static final int MAX_SERIALIZED_GIFT_LENGTH = 65536; private FileConfiguration config; private String locale; + private XMasPlaceholderExpansion placeholderExpansion; public static Plugin getInstance() { return plugin; @@ -48,6 +57,10 @@ public static List getHeads() { return heads; } + public static NamespacedKey getCrystalKey() { + return crystalKey; + } + @Override public void onLoad() { plugin = this; @@ -56,6 +69,7 @@ public void onLoad() { @Override public void onEnable() { this.saveDefaults(); + crystalKey = new NamespacedKey(this, "xmas_crystal"); config = getConfig(); locale = config.getString("core.locale"); @@ -69,8 +83,12 @@ public void onEnable() { UPDATE_SPEED = 7; } PARTICLES_DELAY = config.getInt("core.particles-delay"); - if (PARTICLES_DELAY <= 0) - config.set("particles-delay", 35); + if (PARTICLES_DELAY <= 0) { + config.set("core.particles-delay", 35); + PARTICLES_DELAY = 35; + } + particlesEnabled = config.getBoolean("core.particles-enabled", true); + loadSoundConfig(); autoEnd = config.getBoolean("core.holiday-ends.enabled"); resourceBack = config.getBoolean("core.holiday-ends.resource-back"); @@ -80,7 +98,7 @@ public void onEnable() { date = sdf.parse(config.getString("core.holiday-ends.date")); endTime = date.getTime(); } catch (ParseException e1) { - TextUtils.sendConsoleMessage("Unable to load date"); + TextUtils.sendConsoleMessage("Unable to load date"); } defineTreeLevels(); for (World world : getServer().getWorlds()) { @@ -90,33 +108,20 @@ public void onEnable() { LocaleManager.loadLocale(locale); heads = config.getStringList("xmas.presents"); if (heads.size() == 0) { - getLogger().warning(ChatColor.RED + "Warning! No heads loaded! Presents can't spawn without box!"); + getLogger().warning("[X-Mas] Warning! No heads loaded! Presents can't spawn without box!"); return; } gifts = new ArrayList<>(); - for (String cItem : config.getStringList("xmas.gifts")) { - try { - - if (cItem.contains(":")) { - String[] split = cItem.split(":"); - if (split.length == 0) throw new IllegalArgumentException(); - - Material material; - int amount = 1; - material = Material.valueOf(split[0]); - if (split.length > 1) amount = Integer.parseInt(split[1]); - gifts.add(new ItemStack(material, amount)); - } else { - gifts.add(new ItemStack(Material.valueOf(cItem))); - } - - } catch (IllegalArgumentException e) { - getLogger().severe(ChatColor.RED + "[X-Mas] Unable to get load gift from '" + cItem + "'"); - getLogger().warning(ChatColor.RED + "[X-Mas] For gifts - use format MATERIAL:AMOUNT:DATA. Amount and data are optional"); + for (String serializedItem : config.getStringList("xmas.gifts")) { + ItemStack item = deserializeItem(serializedItem); + if (item != null) { + gifts.add(item); + } else { + getLogger().warning("[X-Mas] Failed to load gift item: " + serializedItem); } } if (gifts.size() == 0) { - getLogger().warning(ChatColor.RED + "[X-Mas] Warning! No gifts loaded! No X-Mas without gifts!"); + getLogger().warning("[X-Mas] Warning! No gifts loaded! No X-Mas without gifts!"); return; } @@ -124,11 +129,16 @@ public void onEnable() { LUCK_CHANCE = (float) config.getInt("xmas.luck.chance") / 100; new Events().registerListener(); new MagicTask(this).runTaskTimer(this, 5, UPDATE_SPEED); - new PlayParticlesTask(this).runTaskTimerAsynchronously(this, 5, PARTICLES_DELAY); + new PlayParticlesTask(this).runTaskTimer(this, 5, PARTICLES_DELAY); XMas.XMAS_CRYSTAL = new ItemMaker(Material.EMERALD, LocaleManager.CRYSTAL_NAME, LocaleManager.CRYSTAL_LORE).make(); + ItemMeta crystalMeta = XMas.XMAS_CRYSTAL.getItemMeta(); + if (crystalMeta != null) { + crystalMeta.getPersistentDataContainer().set(crystalKey, PersistentDataType.BYTE, (byte) 1); + XMas.XMAS_CRYSTAL.setItemMeta(crystalMeta); + } ShapedRecipe grinderRecipe; - grinderRecipe = new ShapedRecipe(new NamespacedKey(this, "xmas"), XMas.XMAS_CRYSTAL).shape("#d#", "ded", "#d#").setIngredient('d', Material.DIAMOND).setIngredient('e', Material.EMERALD); + grinderRecipe = new ShapedRecipe(new NamespacedKey(this, "xmas"), XMas.XMAS_CRYSTAL).shape(" d ", "ded", " d ").setIngredient('d', Material.DIAMOND).setIngredient('e', Material.EMERALD); Iterator recipes = getServer().recipeIterator(); boolean registered = false; while (recipes.hasNext()) { @@ -145,6 +155,7 @@ public void onEnable() { } catch (Exception ignored) { } XMasCommand.register(this); + registerPlaceholderApi(); TextUtils.sendConsoleMessage(LocaleManager.PLUGIN_ENABLED); } @@ -160,17 +171,169 @@ public void onWorldUnload(WorldUnloadEvent event) { } } + public void reloadPluginConfig() { + reloadConfig(); + config = getConfig(); + locale = config.getString("core.locale"); + + inProgress = config.getBoolean("core.plugin-enabled", true); + UPDATE_SPEED = config.getInt("core.update-speed"); + if (UPDATE_SPEED <= 0) { + UPDATE_SPEED = 7; + } + PARTICLES_DELAY = config.getInt("core.particles-delay"); + if (PARTICLES_DELAY <= 0) { + PARTICLES_DELAY = 35; + } + particlesEnabled = config.getBoolean("core.particles-enabled", true); + loadSoundConfig(); + + autoEnd = config.getBoolean("core.holiday-ends.enabled"); + resourceBack = config.getBoolean("core.holiday-ends.resource-back"); + MAX_TREE_COUNT = config.getInt("core.tree-limit"); + try { + SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy kk-mm-ss"); + endTime = sdf.parse(config.getString("core.holiday-ends.date")).getTime(); + } catch (ParseException e) { + TextUtils.sendConsoleMessage("Invalid holiday end date in config.yml"); + } + + defineTreeLevels(); + LocaleManager.loadLocale(locale); + heads = config.getStringList("xmas.presents"); + + gifts = new ArrayList<>(); + for (String serializedItem : config.getStringList("xmas.gifts")) { + ItemStack item = deserializeItem(serializedItem); + if (item != null) { + gifts.add(item); + } else { + getLogger().warning("[X-Mas] Failed to deserialize gift item: " + serializedItem); + } + } + + LUCK_CHANCE_ENABLED = config.getBoolean("xmas.luck.enabled"); + LUCK_CHANCE = (float) config.getInt("xmas.luck.chance") / 100; + XMasCommand.refreshCommandConfiguration(this); + TextUtils.sendConsoleMessage("Configuration reloaded!"); + } + + private void loadSoundConfig() { + growFirstSoundVolume = clampVolume(config.getDouble("core.sounds.grow.first-volume", 0.5)); + growRepeatSoundVolume = clampVolume(config.getDouble("core.sounds.grow.repeat-volume", 0.2)); + } + + private float clampVolume(double volume) { + if (Double.isNaN(volume)) { + return 0; + } + return (float) Math.max(0, Math.min(1, volume)); + } + + public void addGiftItem(ItemStack item) { + ItemStack gift = item.clone(); + String serializedItem = serializeItem(gift); + if (serializedItem == null) { + getLogger().warning("Failed to serialize item for saving to the gift list. Item: " + gift); + return; + } + + List giftList = config.getStringList("xmas.gifts"); + giftList.add(serializedItem); + config.set("xmas.gifts", giftList); + saveConfig(); + gifts.add(gift); + } + + private String serializeItem(ItemStack item) { + try { + byte[] serializedBytes = item.serializeAsBytes(); + return Base64.getEncoder().encodeToString(serializedBytes); + } catch (Exception e) { + getLogger().severe("Failed to serialize item: " + e.getMessage()); + return null; + } + } + + public static ItemStack deserializeItem(String serializedItem) { + if (serializedItem == null) { + return null; + } + String trimmed = serializedItem.trim(); + if (trimmed.isEmpty()) { + return null; + } + + ItemStack materialItem = deserializeMaterialItem(trimmed); + if (materialItem != null) { + return materialItem; + } + + if (trimmed.length() > MAX_SERIALIZED_GIFT_LENGTH) { + Bukkit.getLogger().severe("[X-Mas] Gift item is too large to deserialize safely: " + trimmed.length() + " characters"); + return null; + } + + try { + byte[] serializedBytes = Base64.getDecoder().decode(trimmed); + return ItemStack.deserializeBytes(serializedBytes); + } catch (IllegalArgumentException e) { + Bukkit.getLogger().severe("[X-Mas] Invalid material name or Base64 gift item: " + trimmed); + return null; + } catch (Exception e) { + Bukkit.getLogger().severe("[X-Mas] Failed to deserialize gift item: " + e.getMessage()); + return null; + } + } + + private static ItemStack deserializeMaterialItem(String serializedItem) { + String materialName = serializedItem; + int amount = 1; + if (serializedItem.contains(":")) { + String[] split = serializedItem.split(":", 2); + materialName = split[0].trim(); + try { + amount = Integer.parseInt(split[1].trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + Material material = Material.matchMaterial(materialName); + if (material == null || material.isLegacy() || !material.isItem()) { + return null; + } + int maxStackSize = Math.max(1, material.getMaxStackSize()); + amount = Math.max(1, Math.min(amount, maxStackSize)); + return new ItemStack(material, amount); + } + @Override public void onDisable() { + if (placeholderExpansion != null && placeholderExpansion.isRegistered()) { + placeholderExpansion.unregister(); + } if (XMas.getAllTrees().size() > 0) for (MagicTree tree : XMas.getAllTrees()) { tree.unbuild(); } } + private void registerPlaceholderApi() { + if (!getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) { + return; + } + placeholderExpansion = new XMasPlaceholderExpansion(this); + if (placeholderExpansion.register()) { + getLogger().info("Registered PlaceholderAPI expansion: " + XMasPlaceholders.IDENTIFIER); + } else { + getLogger().warning("PlaceholderAPI is present, but placeholder registration failed."); + } + } + public void end() { - Bukkit.broadcastMessage(ChatColor.GREEN + LocaleManager.HAPPY_NEW_YEAR); + Bukkit.broadcast(TextUtils.parse("" + LocaleManager.HAPPY_NEW_YEAR)); inProgress = false; config.set("core.plugin-enabled", false); saveConfig(); @@ -179,7 +342,7 @@ public void end() { private void saveDefaults() { this.saveDefaultConfig(); plugin.saveResource("locales/default.yml", true); - ArrayList defaults = Lists.newArrayList("locales/en.yml", "locales/ru.yml", "locales/ru_santa.yml", "trees.yml"); + List defaults = Arrays.asList("locales/en.yml", "locales/ru.yml", "locales/ru_santa.yml", "trees.yml"); for (String path : defaults) if (!new File(getDataFolder(), '/' + path).exists()) plugin.saveResource(path, false); } @@ -196,7 +359,11 @@ private void defineTreeLevels() { Map smallLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("small_tree.lvlup").getValues(false)); Map treeLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("tree.lvlup").getValues(false)); - TreeLevel.MAGIC_TREE = new TreeLevel("magic_tree", Effects.TREE_WHITE_AMBIENT, Effects.TREE_SWAG, null, null, magic_delay, Collections.emptyMap(), new StructureTemplate(new HashMap() { + TreeLevel.MAGIC_TREE = new TreeLevel("magic_tree", + getParticleEffect("magic_tree", "ambient", Effects.TREE_WHITE_AMBIENT), + getParticleEffect("magic_tree", "swag", Effects.TREE_SWAG), + getParticleEffect("magic_tree", "body", null), + null, magic_delay, Collections.emptyMap(), new StructureTemplate(new HashMap() { private static final long serialVersionUID = 1L; { @@ -232,7 +399,11 @@ private void defineTreeLevels() { } })); - TreeLevel.TREE = new TreeLevel("tree", Effects.AMBIENT_SNOW, Effects.TREE_GOLD_SWAG, null, TreeLevel.MAGIC_TREE, tree_delay, treeLevelUp, new StructureTemplate(new HashMap() { + TreeLevel.TREE = new TreeLevel("tree", + getParticleEffect("tree", "ambient", Effects.AMBIENT_SNOW), + getParticleEffect("tree", "swag", Effects.TREE_GOLD_SWAG), + getParticleEffect("tree", "body", null), + TreeLevel.MAGIC_TREE, tree_delay, treeLevelUp, new StructureTemplate(new HashMap() { private static final long serialVersionUID = 1L; { @@ -265,7 +436,11 @@ private void defineTreeLevels() { } })); - TreeLevel.SMALL_TREE = new TreeLevel("small_tree", Effects.AMBIENT_PORTAL, Effects.TREE_RED_SWAG, null, TreeLevel.TREE, small_delay, smallLevelUp, new StructureTemplate(new HashMap() { + TreeLevel.SMALL_TREE = new TreeLevel("small_tree", + getParticleEffect("small_tree", "ambient", Effects.AMBIENT_PORTAL), + getParticleEffect("small_tree", "swag", Effects.TREE_RED_SWAG), + getParticleEffect("small_tree", "body", null), + TreeLevel.TREE, small_delay, smallLevelUp, new StructureTemplate(new HashMap() { private static final long serialVersionUID = 1L; { @@ -292,7 +467,11 @@ private void defineTreeLevels() { } })); - TreeLevel.SAPLING = new TreeLevel("sapling", Effects.AMBIENT_SAPLING, null, null, TreeLevel.SMALL_TREE, sapling_delay, saplingLevelUp, new StructureTemplate(new HashMap() { + TreeLevel.SAPLING = new TreeLevel("sapling", + getParticleEffect("sapling", "ambient", Effects.AMBIENT_SAPLING), + getParticleEffect("sapling", "swag", null), + getParticleEffect("sapling", "body", null), + TreeLevel.SMALL_TREE, sapling_delay, saplingLevelUp, new StructureTemplate(new HashMap() { private static final long serialVersionUID = 1L; { @@ -301,4 +480,42 @@ private void defineTreeLevels() { } })); } + + private ParticleContainer getParticleEffect(String level, String effect, ParticleContainer fallback) { + String path = "xmas.tree-lvl." + level + ".particles." + effect; + ConfigurationSection section = config.getConfigurationSection(path); + if (section == null) { + return fallback; + } + if (!section.getBoolean("enabled", fallback != null)) { + return null; + } + + String configuredParticle = section.getString("particle", fallback != null ? fallback.getType().name() : null); + if (configuredParticle == null || configuredParticle.trim().isEmpty()) { + return fallback; + } + + Particle particle; + try { + particle = Particle.valueOf(configuredParticle.trim().toUpperCase(Locale.ENGLISH)); + } catch (IllegalArgumentException e) { + getLogger().warning("[X-Mas] Unknown particle '" + configuredParticle + "' at " + path + ". Using fallback."); + return fallback; + } + + if (particle.getDataType() != Void.class && particle != Particle.DUST) { + getLogger().warning("[X-Mas] Particle '" + particle.name() + "' needs extra data and is not supported in config yet. Using fallback."); + return fallback; + } + + return new ParticleContainer( + particle, + (float) section.getDouble("offset-x", fallback != null ? fallback.getOffsetX() : 0), + (float) section.getDouble("offset-y", fallback != null ? fallback.getOffsetY() : 0), + (float) section.getDouble("offset-z", fallback != null ? fallback.getOffsetZ() : 0), + (float) section.getDouble("speed", fallback != null ? fallback.getSpeed() : 0), + Math.max(0, section.getInt("count", fallback != null ? fallback.getCount() : 0)) + ); + } } diff --git a/src/main/java/ru/meloncode/xmas/ParticleContainer.java b/src/main/java/ru/meloncode/xmas/ParticleContainer.java index fee0acb..6208ddf 100644 --- a/src/main/java/ru/meloncode/xmas/ParticleContainer.java +++ b/src/main/java/ru/meloncode/xmas/ParticleContainer.java @@ -1,6 +1,7 @@ package ru.meloncode.xmas; import org.bukkit.Color; +import org.bukkit.Bukkit; import org.bukkit.Location; import org.bukkit.Particle; import org.bukkit.Particle.DustOptions; @@ -36,19 +37,43 @@ public ParticleContainer(Particle type, float offsetX, float offsetY, float offs this.count = count; } + public Particle getType() { + return type; + } + + public float getOffsetX() { + return offsetX; + } + + public float getOffsetY() { + return offsetY; + } + + public float getOffsetZ() { + return offsetZ; + } + + public float getSpeed() { + return speed; + } + + public int getCount() { + return count; + } + public void playEffect(Location location) { - location = location.clone(); // prevent chaning pos of object + location = location.clone(); // prevent changing pos of object location.add(0.5, 0.5, 0.5); // A small fix for (Player player : location.getWorld().getPlayers()) if (player.getLocation().distance(location) < 16) { try { - if (type == Particle.REDSTONE) { + if (type == Particle.DUST) { player.spawnParticle(type, location, count, offsetX, offsetY, offsetZ, speed, COLORS[random.nextInt(6)]); } else { player.spawnParticle(type, location, count, offsetX, offsetY, offsetZ, speed); } } catch (Exception e) { - e.printStackTrace(); + Bukkit.getLogger().warning("[X-Mas] Failed to spawn particle " + type + ": " + e.getMessage()); } } } diff --git a/src/main/java/ru/meloncode/xmas/PlayParticlesTask.java b/src/main/java/ru/meloncode/xmas/PlayParticlesTask.java index 6950525..8092aea 100644 --- a/src/main/java/ru/meloncode/xmas/PlayParticlesTask.java +++ b/src/main/java/ru/meloncode/xmas/PlayParticlesTask.java @@ -12,7 +12,7 @@ class PlayParticlesTask extends BukkitRunnable { @Override public void run() { - if (Main.inProgress) + if (Main.inProgress && Main.particlesEnabled) for (MagicTree tree : XMas.getAllTrees()) { tree.playParticles(); } diff --git a/src/main/java/ru/meloncode/xmas/TreeSerializer.java b/src/main/java/ru/meloncode/xmas/TreeSerializer.java index b4108d9..32904e6 100644 --- a/src/main/java/ru/meloncode/xmas/TreeSerializer.java +++ b/src/main/java/ru/meloncode/xmas/TreeSerializer.java @@ -1,6 +1,5 @@ package ru.meloncode.xmas; -import org.bukkit.ChatColor; import org.bukkit.Location; import org.bukkit.Material; import org.bukkit.World; @@ -13,11 +12,15 @@ import java.io.IOException; import java.util.HashMap; import java.util.Map; +import java.util.Set; import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; class TreeSerializer { private static final File treesFile = new File(Main.getInstance().getDataFolder() + "/trees.yml"); private static final FileConfiguration trees = ConfigUtils.loadConfig(treesFile); + private static final Set loggedWorldAliasMappings = ConcurrentHashMap.newKeySet(); public static void loadTrees(JavaPlugin plugin, World world) { try { @@ -31,7 +34,8 @@ public static void loadTrees(JavaPlugin plugin, World world) { if (trees.getConfigurationSection("trees") != null && trees.getConfigurationSection("trees").getKeys(false).size() > 0) { for (String cKey : trees.getConfigurationSection("trees").getKeys(false)) { - if (world.getName().equals(trees.getString("trees." + cKey + ".loc.world"))) { + String savedWorldName = trees.getString("trees." + cKey + ".loc.world"); + if (matchesSavedWorld(world, savedWorldName)) { try { treeUID = UUID.fromString(cKey); owner = UUID.fromString(trees.getString("trees." + cKey + ".owner")); @@ -51,16 +55,14 @@ public static void loadTrees(JavaPlugin plugin, World world) { XMas.addMagicTree(new MagicTree(owner, treeUID, level, loc, requirements, presentCounter, scheduledPresents)); } catch (Exception e) { - plugin.getLogger().severe(String.format("Error while loading tree `%s`", cKey)); - e.printStackTrace(); - System.out.println("================================================"); + plugin.getLogger().log(Level.SEVERE, String.format("Error while loading tree `%s`", cKey), e); } } } } } catch (Exception e) { - TextUtils.sendConsoleMessage(ChatColor.DARK_RED + "ERROR WHILE LOADING TREES"); - e.printStackTrace(); + TextUtils.sendConsoleMessage("ERROR WHILE LOADING TREES"); + plugin.getLogger().log(Level.SEVERE, "Unable to load X-Mas trees", e); } } @@ -76,13 +78,13 @@ public static void saveTree(MagicTree tree) { trees.set("trees." + cKey + ".loc.z", tree.getLocation().getZ()); if (tree.getLevelupRequirements() != null && tree.getLevelupRequirements().size() > 0) trees.createSection("trees." + cKey + ".levelup", tree.getLevelupRequirements()); + trees.set("trees." + cKey + ".present_counter", tree.getPresentCounter()); + trees.set("trees." + cKey + ".scheduled_presents", tree.getScheduledPresents()); try { trees.save(treesFile); } catch (IOException e) { - e.printStackTrace(); + Main.getInstance().getLogger().log(Level.SEVERE, "Unable to save X-Mas tree data", e); } - trees.set("trees." + cKey + ".present_counter", tree.getPresentCounter()); - trees.set("trees." + cKey + ".scheduled_presents", tree.getScheduledPresents()); } public static void removeTree(MagicTree tree) { @@ -90,7 +92,7 @@ public static void removeTree(MagicTree tree) { try { trees.save(treesFile); } catch (IOException e) { - e.printStackTrace(); + Main.getInstance().getLogger().log(Level.SEVERE, "Unable to remove X-Mas tree data", e); } } @@ -101,14 +103,52 @@ public static Map convertRequirementsMap(Map if (map != null) for (String sMaterial : map.keySet()) { try { - cMaterial = Material.valueOf(sMaterial); - value = (int) map.get(sMaterial); + cMaterial = Material.matchMaterial(sMaterial); + if (cMaterial == null || cMaterial.isLegacy()) { + TextUtils.sendConsoleMessage("Can't find modern material '" + sMaterial + "' for tree level."); + continue; + } + Object rawValue = map.get(sMaterial); + if (!(rawValue instanceof Number)) { + TextUtils.sendConsoleMessage("Tree level material '" + sMaterial + "' must use a numeric amount."); + continue; + } + value = ((Number) rawValue).intValue(); levelupRequirements.put(cMaterial, value); } catch (IllegalArgumentException e) { - TextUtils.sendConsoleMessage("Can't find material '" + sMaterial + "' for tree level."); - return null; + TextUtils.sendConsoleMessage("Can't load material '" + sMaterial + "' for tree level."); } } return levelupRequirements; } -} \ No newline at end of file + + private static boolean matchesSavedWorld(World world, String savedWorldName) { + if (world == null || savedWorldName == null || savedWorldName.isBlank()) { + return false; + } + if (world.getName().equalsIgnoreCase(savedWorldName)) { + return true; + } + + String configuredWorldName = getWorldAlias(savedWorldName); + if (configuredWorldName != null && world.getName().equalsIgnoreCase(configuredWorldName)) { + logWorldAliasMapping(savedWorldName, world.getName()); + return true; + } + return false; + } + + private static String getWorldAlias(String savedWorldName) { + if (!(Main.getInstance() instanceof Main plugin)) { + return null; + } + return plugin.getConfig().getString("migration.world-aliases." + savedWorldName); + } + + private static void logWorldAliasMapping(String savedWorldName, String worldName) { + String mapping = savedWorldName + "->" + worldName; + if (loggedWorldAliasMappings.add(mapping)) { + Main.getInstance().getLogger().info("Loading legacy X-Mas trees from saved world '" + savedWorldName + "' into '" + worldName + "' via migration.world-aliases."); + } + } +} diff --git a/src/main/java/ru/meloncode/xmas/XMas.java b/src/main/java/ru/meloncode/xmas/XMas.java index 7335851..507d79c 100644 --- a/src/main/java/ru/meloncode/xmas/XMas.java +++ b/src/main/java/ru/meloncode/xmas/XMas.java @@ -8,7 +8,6 @@ import org.bukkit.block.Skull; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -import org.jetbrains.annotations.Nullable; import ru.meloncode.xmas.utils.LocationUtils; import ru.meloncode.xmas.utils.TextUtils; @@ -35,6 +34,10 @@ public static void createMagicTree(Player player, Location loc) { public static void addMagicTree(MagicTree tree) { trees.put(tree.getTreeUID(), tree); + trees_byChunk.computeIfAbsent(LocationUtils.getChunkKey(tree.getLocation()), aLong -> new ArrayList<>()); + List chunkTrees = trees_byChunk.get(LocationUtils.getChunkKey(tree.getLocation())); + chunkTrees.removeIf(existingTree -> existingTree.getTreeUID().equals(tree.getTreeUID())); + chunkTrees.add(tree); tree.build(); } @@ -42,39 +45,60 @@ public static Collection getAllTrees() { return trees.values(); } - @Nullable public static Collection getAllTreesInChunk(Chunk chunk) { return trees_byChunk.get(LocationUtils.getChunkKey(chunk)); } public static void removeTree(MagicTree tree) { - tree.unbuild(); + removeTree(tree, true); + } + + public static void removeTree(MagicTree tree, boolean unbuild) { + if (unbuild) { + tree.unbuild(); + } TreeSerializer.removeTree(tree); trees.remove(tree.getTreeUID()); - trees_byChunk.remove(LocationUtils.getChunkKey(tree.getLocation())); + List chunkTrees = trees_byChunk.get(LocationUtils.getChunkKey(tree.getLocation())); + if (chunkTrees != null) { + chunkTrees.remove(tree); + if (chunkTrees.isEmpty()) { + trees_byChunk.remove(LocationUtils.getChunkKey(tree.getLocation())); + } + } } public static void processPresent(Block block, Player player) { if (block.getType() == Material.PLAYER_HEAD) { Skull skull = (Skull) block.getState(); + String headId = getHeadIdentifier(skull); - if (Main.getHeads().contains(skull.getOwner())) { - Location loc = block.getLocation(); - World world = loc.getWorld(); - if (world != null) { - if (RANDOM.nextFloat() < Main.LUCK_CHANCE || !Main.LUCK_CHANCE_ENABLED) { - world.dropItemNaturally(loc, new ItemStack(Main.gifts.get(RANDOM.nextInt(Main.gifts.size())))); - Effects.TREE_SWAG.playEffect(loc); - TextUtils.sendMessage(player, LocaleManager.GIFT_LUCK); - } else { - Effects.SMOKE.playEffect(loc); - world.dropItemNaturally(loc, new ItemStack(Material.COAL)); - TextUtils.sendMessage(player, LocaleManager.GIFT_FAIL); - } + if (headId != null && Main.getHeads().contains(headId)) { + Location loc = block.getLocation(); + World world = loc.getWorld(); + if (world != null) { + if (RANDOM.nextFloat() < Main.LUCK_CHANCE || !Main.LUCK_CHANCE_ENABLED) { + world.dropItemNaturally(loc, new ItemStack(Main.gifts.get(RANDOM.nextInt(Main.gifts.size())))); + Effects.TREE_SWAG.playEffect(loc); + TextUtils.sendMessage(player, LocaleManager.GIFT_LUCK); + } else { + Effects.SMOKE.playEffect(loc); + world.dropItemNaturally(loc, new ItemStack(Material.COAL)); + TextUtils.sendMessage(player, LocaleManager.GIFT_FAIL); } - block.setType(Material.AIR); } + block.setType(Material.AIR); + } + } + } + + static String getHeadIdentifier(Skull skull) { + if (skull.getOwnerProfile() != null + && skull.getOwnerProfile().getTextures() != null + && skull.getOwnerProfile().getTextures().getSkin() != null) { + return skull.getOwnerProfile().getTextures().getSkin().toString(); } + return skull.getOwner(); } public static List getTreesPlayerOwn(Player player) { diff --git a/src/main/java/ru/meloncode/xmas/XMasCommand.java b/src/main/java/ru/meloncode/xmas/XMasCommand.java index 943ee1b..fd41d14 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -1,20 +1,37 @@ package ru.meloncode.xmas; import org.bukkit.Bukkit; +import org.bukkit.command.CommandMap; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.PluginCommand; +import org.bukkit.command.SimpleCommandMap; +import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import ru.meloncode.xmas.utils.TextUtils; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; import java.text.SimpleDateFormat; -import java.util.HashSet; -import java.util.Random; -import java.util.Set; -import java.util.UUID; +import java.util.*; -import static org.bukkit.ChatColor.*; - -public class XMasCommand implements CommandExecutor { +public class XMasCommand implements CommandExecutor, TabCompleter { + public static final String PRIMARY_COMMAND = "xmastree"; + public static final String LEGACY_COMMAND = "xmas"; + private static final List COMMANDS = Arrays.asList("help", "give", "end", "gifts", "reload", "addhand", "debug"); + private static final Set DEBUG_TOGGLE_KEYS = new LinkedHashSet<>(Arrays.asList( + "core.commands.legacy-command-enabled", + "core.plugin-enabled", + "core.holiday-ends.enabled", + "core.holiday-ends.resource-back", + "core.particles-enabled", + "xmas.luck.enabled" + )); + private static final int DEBUG_PAGE_SIZE = 8; + private static XMasCommand registeredExecutor; + private static PluginCommand legacyAliasCommand; private final Main plugin; @@ -23,18 +40,33 @@ private XMasCommand(Main plugin) { } public static void register(Main plugin) { - plugin.getCommand("xmas").setExecutor(new XMasCommand(plugin)); + registeredExecutor = new XMasCommand(plugin); + PluginCommand primaryCommand = plugin.getCommand(PRIMARY_COMMAND); + if (primaryCommand == null) { + throw new IllegalStateException("Primary command '/" + PRIMARY_COMMAND + "' is not defined in plugin.yml"); + } + primaryCommand.setExecutor(registeredExecutor); + primaryCommand.setTabCompleter(registeredExecutor); + refreshCommandConfiguration(plugin); + } + + public static void refreshCommandConfiguration(Main plugin) { + if (registeredExecutor == null) { + register(plugin); + return; + } + syncLegacyAlias(plugin, registeredExecutor); } @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { if (args.length > 0) { - String action = args[0].toLowerCase(); + String action = args[0].toLowerCase(Locale.ENGLISH); switch (action) { case "help": { - for (String line : LocaleManager.COMMAND_HELP) { - sender.sendMessage(GREEN + line); + for (String line : getHelpLines()) { + TextUtils.sendRawMessage(sender, line); } break; } @@ -45,10 +77,10 @@ public boolean onCommand(CommandSender sender, Command command, String label, St if (player != null) { player.getInventory().addItem(XMas.XMAS_CRYSTAL); } else { - sender.sendMessage(LocaleManager.COMMAND_PLAYER_OFFLINE); + TextUtils.sendMessage(sender, LocaleManager.COMMAND_PLAYER_OFFLINE); } } else { - sender.sendMessage(LocaleManager.COMMAND_NO_PLAYER_NAME); + TextUtils.sendMessage(sender, LocaleManager.COMMAND_NO_PLAYER_NAME); } break; } @@ -63,7 +95,38 @@ public boolean onCommand(CommandSender sender, Command command, String label, St magicTree.spawnPresent(); } } - Bukkit.broadcastMessage(LocaleManager.COMMAND_GIVEAWAY); + Bukkit.broadcast(TextUtils.parse(LocaleManager.COMMAND_GIVEAWAY)); + break; + } + case "reload": { + if (!sender.hasPermission("xmas.admin")) { + TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + break; + } + plugin.reloadPluginConfig(); + TextUtils.sendRawMessage(sender, "" + TextUtils.DISPLAY_NAME + " configuration reloaded."); + break; + } + case "addhand": { + if (!(sender instanceof Player player)) { + TextUtils.sendRawMessage(sender, "Only players can use this command."); + break; + } + if (!sender.hasPermission("xmas.admin")) { + TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + break; + } + ItemStack item = player.getInventory().getItemInMainHand(); + if (item.getType().isAir()) { + TextUtils.sendRawMessage(player, "Hold an item before running " + commandPath("addhand") + "."); + break; + } + plugin.addGiftItem(item.clone()); + TextUtils.sendRawMessage(player, "Added the held item to the gift list."); + break; + } + case "debug": { + handleDebug(sender, args); break; } @@ -76,8 +139,42 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return true; } + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + List suggestions = new ArrayList<>(); + if (args.length == 1) { + String typed = args[0].toLowerCase(Locale.ENGLISH); + for (String subCommand : COMMANDS) { + if (subCommand.startsWith(typed)) { + suggestions.add(subCommand); + } + } + } else if (args.length == 2 && args[0].equalsIgnoreCase("give")) { + String typed = args[1].toLowerCase(Locale.ENGLISH); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase(Locale.ENGLISH).startsWith(typed)) { + suggestions.add(player.getName()); + } + } + } else if (args[0].equalsIgnoreCase("debug")) { + if (args.length == 2) { + suggestions.addAll(filterStartingWith(Arrays.asList("1", "2", "3", "toggle"), args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("toggle")) { + suggestions.addAll(filterStartingWith(new ArrayList<>(DEBUG_TOGGLE_KEYS), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("toggle")) { + suggestions.addAll(filterStartingWith(Arrays.asList("true", "false"), args[3])); + } + } + return suggestions; + } + private void sendStatus(CommandSender sender) { + for (String line : getStatusLines()) { + TextUtils.sendRawMessage(sender, line); + } + } + private List getStatusLines() { int treeCount = XMas.getAllTrees().size(); Set owners = new HashSet<>(); for (MagicTree magicTree : XMas.getAllTrees()) { @@ -85,19 +182,242 @@ private void sendStatus(CommandSender sender) { } SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy kk-mm-ss"); + List lines = new ArrayList<>(); - sender.sendMessage(DARK_GREEN + LocaleManager.PLUGIN_NAME + " " + plugin.getDescription().getVersion() + " Plugin Status"); - sender.sendMessage(""); - sender.sendMessage(GRAY + "Event Status: " + (Main.inProgress ? DARK_GREEN + "In Progress" : RED + "Holidays End")); + lines.add("" + TextUtils.DISPLAY_NAME + " " + plugin.getDescription().getVersion() + " Plugin Status"); + lines.add(""); + lines.add("Event Status: " + (Main.inProgress ? "In Progress" : "Holidays End")); if (Main.inProgress) { - sender.sendMessage(DARK_GREEN + "Current Time: " + GREEN + sdf.format(System.currentTimeMillis())); - sender.sendMessage(DARK_GREEN + "Holidays end: " + RED + sdf.format(Main.endTime)); + lines.add("Current Time: " + sdf.format(System.currentTimeMillis())); + lines.add("Holidays end: " + sdf.format(Main.endTime)); + } + lines.add("Auto-End: " + (Main.autoEnd ? "Yes" : "No") + " | Resource Back: " + (Main.resourceBack ? "Yes" : "No") + " | Particles: " + (Main.particlesEnabled ? "Yes" : "No")); + lines.add(""); + lines.add("There are " + treeCount + " magic trees owned by " + owners.size() + " players"); + lines.add("Use " + commandPath("help") + " for command list"); + return lines; + } + + private void handleDebug(CommandSender sender, String[] args) { + if (!sender.hasPermission("xmas.admin")) { + TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + return; + } + + if (args.length >= 2 && args[1].equalsIgnoreCase("toggle")) { + handleDebugToggle(sender, args); + return; + } + + int page = 1; + if (args.length >= 2) { + try { + page = Integer.parseInt(args[1]); + } catch (NumberFormatException ignored) { + page = 1; + } + } + sendDebugPage(sender, page); + } + + private void handleDebugToggle(CommandSender sender, String[] args) { + if (args.length < 4) { + TextUtils.sendRawMessage(sender, "Usage: " + commandPath("debug toggle true|false")); + TextUtils.sendRawMessage(sender, "Keys: " + String.join(", ", DEBUG_TOGGLE_KEYS)); + return; + } + + String key = args[2].toLowerCase(Locale.ENGLISH); + if (!DEBUG_TOGGLE_KEYS.contains(key)) { + TextUtils.sendRawMessage(sender, "Unknown toggle key: " + args[2]); + TextUtils.sendRawMessage(sender, "Keys: " + String.join(", ", DEBUG_TOGGLE_KEYS)); + return; + } + if (!args[3].equalsIgnoreCase("true") && !args[3].equalsIgnoreCase("false")) { + TextUtils.sendRawMessage(sender, "Value must be true or false."); + return; } - sender.sendMessage(GREEN + "Auto-End: " + (Main.autoEnd ? DARK_GREEN + "Yes" : RED + "No") + GREEN + " | " + "Resource Back: " + (Main.resourceBack ? DARK_GREEN + "Yes" : "No")); - sender.sendMessage(""); - sender.sendMessage(DARK_GREEN + "There are " + GREEN + treeCount + DARK_GREEN + " magic trees owned by " + RED + owners.size() + DARK_GREEN + " players"); - sender.sendMessage(DARK_GREEN + "Use " + RED + "/xmas help" + DARK_GREEN + " for command list"); + boolean value = Boolean.parseBoolean(args[3]); + plugin.getConfig().set(key, value); + plugin.saveConfig(); + plugin.reloadPluginConfig(); + TextUtils.sendRawMessage(sender, "Set " + key + " to " + value + " and reloaded " + TextUtils.DISPLAY_NAME + "."); + } + + private void sendDebugPage(CommandSender sender, int requestedPage) { + List lines = new ArrayList<>(getStatusLines()); + lines.add(""); + lines.add("Commands"); + lines.add("" + commandPath("") + " - status"); + lines.add("" + commandPath("help") + " - command list"); + lines.add("" + commandPath("give ") + " - give a Christmas Crystal"); + lines.add("" + commandPath("gifts") + " - spawn presents under all trees"); + lines.add("" + commandPath("addhand") + " - add held item to gifts"); + lines.add("" + commandPath("reload") + " - reload config and locale"); + lines.add("" + commandPath("end") + " - end the event"); + lines.add("" + commandPath("debug [page]") + " - extended debug output"); + lines.add("" + commandPath("debug toggle true|false") + " - toggle global booleans"); + if (isLegacyAliasEnabled()) { + lines.add("Legacy alias enabled: /" + LEGACY_COMMAND); + } + lines.add(""); + lines.add("Permissions"); + lines.add("xmas.admin - allows all " + TextUtils.DISPLAY_NAME + " admin commands"); + lines.add(""); + lines.add("Placeholders"); + lines.add("Requires PlaceholderAPI. Use '_' after prefix, then dotted keys."); + for (String placeholder : XMasPlaceholders.EXAMPLES) { + lines.add("" + placeholder); + } + lines.add(""); + lines.add("Toggleable Config Keys"); + for (String key : DEBUG_TOGGLE_KEYS) { + lines.add("" + key + " = " + plugin.getConfig().getBoolean(key)); + } + + int pages = Math.max(1, (int) Math.ceil((double) lines.size() / DEBUG_PAGE_SIZE)); + int page = Math.max(1, Math.min(requestedPage, pages)); + int start = (page - 1) * DEBUG_PAGE_SIZE; + int end = Math.min(start + DEBUG_PAGE_SIZE, lines.size()); + + TextUtils.sendRawMessage(sender, "" + TextUtils.DISPLAY_NAME + " Debug page " + page + "/" + pages); + for (int i = start; i < end; i++) { + TextUtils.sendRawMessage(sender, lines.get(i)); + } + if (page < pages) { + TextUtils.sendRawMessage(sender, "Next: " + commandPath("debug " + (page + 1))); + } + } + + private List getHelpLines() { + List lines = new ArrayList<>(); + for (String line : LocaleManager.COMMAND_HELP) { + lines.add(line.replace("/xmas", "/" + PRIMARY_COMMAND)); + } + if (isLegacyAliasEnabled()) { + lines.add("Legacy alias: /" + LEGACY_COMMAND + " still works."); + } + return lines; + } + + private String commandPath(String suffix) { + if (suffix == null || suffix.isBlank()) { + return "/" + PRIMARY_COMMAND; + } + return "/" + PRIMARY_COMMAND + " " + suffix; + } + + private boolean isLegacyAliasEnabled() { + return plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true); + } + + private static void syncLegacyAlias(Main plugin, XMasCommand executor) { + CommandMap commandMap = getCommandMap(); + if (commandMap == null) { + plugin.getLogger().warning("Unable to access the Bukkit command map. Skipping legacy /xmas alias registration."); + return; + } + if (!plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true)) { + unregisterLegacyAlias(commandMap); + return; + } + + Command existing = commandMap.getCommand(LEGACY_COMMAND); + if (existing instanceof PluginCommand existingPluginCommand) { + if (existingPluginCommand.getPlugin() == plugin) { + existingPluginCommand.setExecutor(executor); + existingPluginCommand.setTabCompleter(executor); + legacyAliasCommand = existingPluginCommand; + return; + } + plugin.getLogger().warning("Legacy alias '/" + LEGACY_COMMAND + "' is already owned by plugin '" + existingPluginCommand.getPlugin().getName() + "'. Skipping alias registration."); + return; + } + if (existing != null) { + plugin.getLogger().warning("Legacy alias '/" + LEGACY_COMMAND + "' is already registered by another command source. Skipping alias registration."); + return; + } + + PluginCommand aliasCommand = createPluginCommand(plugin, LEGACY_COMMAND); + if (aliasCommand == null) { + plugin.getLogger().warning("Unable to create the legacy /xmas alias command."); + return; + } + aliasCommand.setDescription("Legacy alias for /" + PRIMARY_COMMAND); + aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|debug|end]"); + aliasCommand.setPermission("xmas.admin"); + aliasCommand.setExecutor(executor); + aliasCommand.setTabCompleter(executor); + commandMap.register(plugin.getDescription().getName().toLowerCase(Locale.ENGLISH), aliasCommand); + legacyAliasCommand = aliasCommand; + plugin.getLogger().info("Registered legacy alias '/" + LEGACY_COMMAND + "' for '/" + PRIMARY_COMMAND + "'."); + } + + private static void unregisterLegacyAlias(CommandMap commandMap) { + if (legacyAliasCommand == null) { + Command existing = commandMap.getCommand(LEGACY_COMMAND); + if (existing instanceof PluginCommand existingPluginCommand && existingPluginCommand.getPlugin() == Main.getInstance()) { + legacyAliasCommand = existingPluginCommand; + } + } + if (legacyAliasCommand == null) { + return; + } + legacyAliasCommand.unregister(commandMap); + if (commandMap instanceof SimpleCommandMap simpleCommandMap) { + try { + Field knownCommandsField = SimpleCommandMap.class.getDeclaredField("knownCommands"); + knownCommandsField.setAccessible(true); + Object rawKnownCommands = knownCommandsField.get(simpleCommandMap); + if (rawKnownCommands instanceof Map rawMap) { + Iterator> iterator = rawMap.entrySet().iterator(); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (entry.getValue() == legacyAliasCommand) { + iterator.remove(); + } + } + } + } catch (ReflectiveOperationException ignored) { + } + } + legacyAliasCommand = null; + } + + private static CommandMap getCommandMap() { + try { + Field commandMapField = Bukkit.getServer().getClass().getDeclaredField("commandMap"); + commandMapField.setAccessible(true); + Object rawCommandMap = commandMapField.get(Bukkit.getServer()); + if (rawCommandMap instanceof CommandMap commandMap) { + return commandMap; + } + } catch (ReflectiveOperationException ignored) { + } + return null; + } + + private static PluginCommand createPluginCommand(Main plugin, String name) { + try { + Constructor constructor = PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class); + constructor.setAccessible(true); + return constructor.newInstance(name, plugin); + } catch (ReflectiveOperationException e) { + plugin.getLogger().warning("Unable to construct dynamic command '/" + name + "': " + e.getMessage()); + return null; + } + } + + private List filterStartingWith(List values, String typed) { + String lower = typed.toLowerCase(Locale.ENGLISH); + List matches = new ArrayList<>(); + for (String value : values) { + if (value.toLowerCase(Locale.ENGLISH).startsWith(lower)) { + matches.add(value); + } + } + return matches; } } diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java new file mode 100644 index 0000000..2567550 --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java @@ -0,0 +1,44 @@ +package ru.meloncode.xmas; + +import me.clip.placeholderapi.expansion.PlaceholderExpansion; +import org.bukkit.OfflinePlayer; + +import java.util.List; + +public class XMasPlaceholderExpansion extends PlaceholderExpansion { + private final Main plugin; + + public XMasPlaceholderExpansion(Main plugin) { + this.plugin = plugin; + } + + @Override + public String getIdentifier() { + return XMasPlaceholders.IDENTIFIER; + } + + @Override + public String getAuthor() { + return "1MB / mrfdev"; + } + + @Override + public String getVersion() { + return plugin.getDescription().getVersion(); + } + + @Override + public boolean persist() { + return true; + } + + @Override + public List getPlaceholders() { + return XMasPlaceholders.EXAMPLES; + } + + @Override + public String onRequest(OfflinePlayer player, String params) { + return XMasPlaceholders.resolve(plugin, player, params); + } +} diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java new file mode 100644 index 0000000..e2cafbc --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java @@ -0,0 +1,127 @@ +package ru.meloncode.xmas; + +import org.bukkit.OfflinePlayer; + +import java.text.SimpleDateFormat; +import java.util.Arrays; +import java.util.Collection; +import java.util.Date; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Set; +import java.util.UUID; + +final class XMasPlaceholders { + public static final String IDENTIFIER = "onembxmastree"; + public static final List EXAMPLES = Arrays.asList( + "%onembxmastree_event.active%", + "%onembxmastree_event.active_text%", + "%onembxmastree_event.status%", + "%onembxmastree_event.starts_at%", + "%onembxmastree_event.ends_at%", + "%onembxmastree_event.ends_in%", + "%onembxmastree_event.ends_timestamp%", + "%onembxmastree_event.auto_end%", + "%onembxmastree_resource.back%", + "%onembxmastree_resource.back_text%", + "%onembxmastree_particles.enabled%", + "%onembxmastree_luck.enabled%", + "%onembxmastree_luck.chance%", + "%onembxmastree_trees.total%", + "%onembxmastree_trees.owners%", + "%onembxmastree_player.trees%", + "%onembxmastree_version%" + ); + private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd-MM-yyyy HH-mm-ss"); + + private XMasPlaceholders() { + } + + public static String resolve(Main plugin, OfflinePlayer player, String params) { + if (params == null || params.trim().isEmpty()) { + return null; + } + String key = normalize(params); + return switch (key) { + case "event_active" -> Boolean.toString(Main.inProgress); + case "event_active_text" -> Main.inProgress ? "Active" : "Inactive"; + case "event_status" -> Main.inProgress ? "In Progress" : "Holidays End"; + case "event_starts_at" -> "manual"; + case "event_ends_at" -> formatEndDate(); + case "event_ends_in" -> formatDurationUntilEnd(); + case "event_ends_timestamp" -> Long.toString(Main.endTime); + case "event_auto_end" -> Boolean.toString(Main.autoEnd); + case "resource_back" -> Boolean.toString(Main.resourceBack); + case "resource_back_text" -> Main.resourceBack ? "Yes" : "No"; + case "particles_enabled" -> Boolean.toString(Main.particlesEnabled); + case "luck_enabled" -> Boolean.toString(Main.LUCK_CHANCE_ENABLED); + case "luck_chance" -> Integer.toString(Math.round(Main.LUCK_CHANCE * 100)); + case "trees_total" -> Integer.toString(XMas.getAllTrees().size()); + case "trees_owners" -> Integer.toString(countOwners(XMas.getAllTrees())); + case "player_trees" -> Integer.toString(countPlayerTrees(player)); + case "version" -> plugin.getDescription().getVersion(); + default -> null; + }; + } + + private static String normalize(String params) { + return params.trim() + .toLowerCase(Locale.ENGLISH) + .replace('.', '_') + .replace('-', '_'); + } + + private static String formatEndDate() { + if (Main.endTime <= 0) { + return "unknown"; + } + return DATE_FORMAT.format(new Date(Main.endTime)); + } + + private static String formatDurationUntilEnd() { + if (!Main.autoEnd) { + return "disabled"; + } + if (Main.endTime <= 0) { + return "unknown"; + } + long remainingMillis = Main.endTime - System.currentTimeMillis(); + if (remainingMillis <= 0) { + return "ended"; + } + + long totalSeconds = remainingMillis / 1000; + long days = totalSeconds / 86400; + long hours = (totalSeconds % 86400) / 3600; + long minutes = (totalSeconds % 3600) / 60; + if (days > 0) { + return days + "d " + hours + "h"; + } + if (hours > 0) { + return hours + "h " + minutes + "m"; + } + return minutes + "m"; + } + + private static int countOwners(Collection trees) { + Set owners = new LinkedHashSet<>(); + for (MagicTree tree : trees) { + owners.add(tree.getOwner()); + } + return owners.size(); + } + + private static int countPlayerTrees(OfflinePlayer player) { + if (player == null || player.getUniqueId() == null) { + return 0; + } + int count = 0; + for (MagicTree tree : XMas.getAllTrees()) { + if (player.getUniqueId().equals(tree.getOwner())) { + count++; + } + } + return count; + } +} diff --git a/src/main/java/ru/meloncode/xmas/utils/TextUtils.java b/src/main/java/ru/meloncode/xmas/utils/TextUtils.java index 94eeddf..28925af 100644 --- a/src/main/java/ru/meloncode/xmas/utils/TextUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/TextUtils.java @@ -1,24 +1,40 @@ package ru.meloncode.xmas.utils; -import org.apache.commons.lang.NullArgumentException; -import org.apache.commons.lang.WordUtils; import org.bukkit.Bukkit; -import org.bukkit.ChatColor; import org.bukkit.Material; +import org.bukkit.command.CommandSender; import org.bukkit.entity.Player; +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.event.HoverEvent; +import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextDecoration; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; import ru.meloncode.xmas.LocaleManager; import ru.meloncode.xmas.MagicTree; import java.util.ArrayList; import java.util.List; +import java.util.Objects; public class TextUtils { + public static final String DISPLAY_NAME = "XMas Tree"; + private static final String PREFIX = "[" + DISPLAY_NAME + "] "; + private static final String CONSOLE_PREFIX = "[" + DISPLAY_NAME + "] "; - public static List generateChatReqList(MagicTree tree) { - if (tree == null) - throw new NullArgumentException("tree"); - List list = new ArrayList<>(); - list.add(ChatColor.GOLD + LocaleManager.GROW_REQ_LIST_TITLE + ":"); + private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); + private static final LegacyComponentSerializer LEGACY_AMPERSAND = LegacyComponentSerializer.legacyAmpersand(); + private static final LegacyComponentSerializer LEGACY_SECTION = LegacyComponentSerializer.legacySection(); + + public static List generateChatReqList(MagicTree tree) { + Objects.requireNonNull(tree, "tree"); + List list = new ArrayList<>(); + Component title = Component.text(LocaleManager.GROW_REQ_LIST_TITLE, NamedTextColor.GOLD) + .decorate(TextDecoration.BOLD); + if (LocaleManager.GROW_REQ_LIST_HINT != null && !LocaleManager.GROW_REQ_LIST_HINT.isBlank()) { + title = title.hoverEvent(HoverEvent.showText(parse(LocaleManager.GROW_REQ_LIST_HINT))); + } + list.add(title); if (tree.getLevel().getLevelupRequirements() != null && tree.getLevel().getLevelupRequirements().size() > 0) for (Material cMaterial : tree.getLevel().getLevelupRequirements().keySet()) { int levelReq = tree.getLevel().getLevelupRequirements().get(cMaterial); @@ -26,18 +42,71 @@ public static List generateChatReqList(MagicTree tree) { if (tree.getLevelupRequirements().containsKey(cMaterial)) treeReq = tree.getLevelupRequirements().get(cMaterial); - list.add(ChatColor.BOLD + "" + (treeReq == 0 ? ChatColor.GREEN + "" + ChatColor.STRIKETHROUGH : ChatColor.RED) + WordUtils.capitalizeFully(String.valueOf(cMaterial).replace('_', ' ') + " : " + (levelReq - treeReq + " / " + levelReq))); + NamedTextColor color = treeReq == 0 ? NamedTextColor.GREEN : NamedTextColor.RED; + Component line = Component.translatable(cMaterial.getItemTranslationKey()) + .color(color) + .decorate(TextDecoration.BOLD) + .append(Component.text(" : " + (levelReq - treeReq) + " / " + levelReq, color).decorate(TextDecoration.BOLD)); + if (treeReq == 0) { + line = line.decorate(TextDecoration.STRIKETHROUGH); + } + list.add(line); } return list; } public static void sendMessage(Player player, String message) { - if (player != null && message != null) - player.sendMessage(ChatColor.DARK_RED + "[" + ChatColor.DARK_GREEN + LocaleManager.PLUGIN_NAME + ChatColor.DARK_RED + "] " + ChatColor.RESET + message); + sendMessage((CommandSender) player, message); + } + + public static void sendMessage(Player player, Component message) { + sendMessage((CommandSender) player, message); + } + + public static void sendMessage(CommandSender sender, String message) { + if (sender != null && message != null) { + sender.sendMessage(parse(PREFIX + message)); + } + } + + public static void sendMessage(CommandSender sender, Component message) { + if (sender != null && message != null) { + sender.sendMessage(parse(PREFIX).append(message)); + } + } + + public static void sendRawMessage(CommandSender sender, String message) { + if (sender != null && message != null) { + sender.sendMessage(parse(message)); + } } public static void sendConsoleMessage(String message) { - if (message != null) - Bukkit.getConsoleSender().sendMessage(ChatColor.DARK_RED + "[" + ChatColor.DARK_GREEN + "X" + ChatColor.DARK_RED + "-" + ChatColor.DARK_GREEN + "MAS" + ChatColor.DARK_RED + "] " + ChatColor.DARK_GREEN + message); + if (message != null) { + Bukkit.getConsoleSender().sendMessage(parse(CONSOLE_PREFIX + message)); + } + } + + public static Component parse(String message) { + if (message == null) { + return Component.empty(); + } + if (message.indexOf('ยง') >= 0) { + return LEGACY_SECTION.deserialize(message); + } + if (message.indexOf('&') >= 0 && message.indexOf('<') < 0) { + return LEGACY_AMPERSAND.deserialize(message); + } + return MINI_MESSAGE.deserialize(message); + } + + public static List parseList(List messages) { + List components = new ArrayList<>(); + if (messages != null) { + for (String message : messages) { + components.add(parse(message)); + } + } + return components; } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index a7a74ac..31f61bb 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,38 +1,67 @@ core: -#Plugin will automatically disabled after date in holiday-ends if it's enabled -#You can also disable plugin by using /xmas end -#In disabled mode players cannot create new trees, but can collect their trees and get resources back (configurable) + # Master switch for the event. When false, players can no longer create or grow trees. plugin-enabled: true + + # Locale file to load from plugins/X-Mas/locales/.yml. locale: en -#Max tree count per player + + # Maximum number of magic Christmas trees a single player can own. tree-limit: 3 -#On date all trees stop to spawn presents and particles. -#Players can unbuild it and get resources back (if enabled) + + commands: + # Primary admin command is /xmastree. + # When true, the legacy /xmas alias is also registered. + # /xmastree reload applies changes without a server restart. + legacy-command-enabled: true + + # Automatic end-of-event behavior. holiday-ends: + # When true, the plugin will switch plugin-enabled to false after the date below. enabled: true -#DD.MM.YYYY HH-MM-SS - date: 10-01-2021 03-33-33 -# if true - plugin will summon chest with spent resources under tree + + # Date/time when the event ends. Format: dd-MM-yyyy HH-mm-ss. + date: 10-01-2027 03-33-33 + + # When true, destroyed or ended trees return spent upgrade resources. + # The plugin tries a chest first, then a barrel, then the player's inventory, then floor drops. resource-back: true - - #Task timer speed. All time values are scaled, so it will affect only particle-spawning. - #I don't Recommend to touch it. - #20 = 1 update / sec - #Can't be 0 + + # Main tree update interval in ticks. 20 ticks is roughly 1 second. + # Gift cooldown values are scaled by this value. Must be greater than 0. update-speed: 7 + + # Particle update interval in ticks. Keep this higher if particles become noisy. particles-delay: 35 + + # Global particle switch. Can be toggled with /xmastree debug toggle core.particles-enabled true|false. + particles-enabled: true + + sounds: + grow: + # Volume for the first accepted item of each material requirement. + # Use 0.0 for silent, 0.1 for quiet, 0.5 for half volume, and 1.0 for full volume. + first-volume: 0.5 + + # Volume for repeated accepted items of the same material requirement. + # Example: first redstone dust uses first-volume, later redstone dust uses repeat-volume. + repeat-volume: 0.2 + xmas: luck: + # When true, gift openings can fail and drop the fallback item instead. enabled: false -# Value 1-100 + + # Success chance from 1-100 when luck is enabled. chance: 75 -#Add here all nicknames of players which head you can use as one of the gift skin + + # Present head skins. Prefer official MineSkin/Mojang texture URLs. + # For safety, texture URLs must use textures.minecraft.net. Old player-name entries still work. presents: - - CruXXx - - SeerPotion -# Alternative looking -# - MHF_Present1 -# - MHF_Present2 + - http://textures.minecraft.net/texture/21bc9d42b0041e8f95cb9b26628fdaf50cd0e36f7bb9d6b3a4d2af3949da97d6 + - http://textures.minecraft.net/texture/2b1ec7dc753061ca174424ea45cf9490b39cd5dcca477d138a603e6be755ec72 + + # Gift list for presents. Use modern Paper/Bukkit material names, optionally MATERIAL:AMOUNT. + # Admins can also run /xmastree addhand to save the exact held item as a Base64 entry. gifts: - DIAMOND - EMERALD @@ -50,29 +79,124 @@ xmas: - DIAMOND_HOE - NAME_TAG + # Tree levels. gift-cooldown is in seconds before scaling by core.update-speed. + # lvlup sections define the modern material names and counts needed for the next level. + # Particle enum reference for Paper 26.1.2: + # https://jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html + # Configured particles currently support simple particles and DUST. tree-lvl: sapling: - #-1 to disable present spawning + # -1 disables present spawning for this level. gift-cooldown: -1 - #Resources to up level tree to next. + # Visual effects for this stage. Set enabled: false to disable an effect slot. + particles: + ambient: + enabled: true + particle: SPORE_BLOSSOM_AIR + offset-x: 0.35 + offset-y: 0.45 + offset-z: 0.35 + speed: 0.01 + count: 2 + swag: + enabled: false + body: + enabled: false + # Materials required to grow from sapling to small_tree. lvlup: DIAMOND: 1 REDSTONE: 10 ENDER_PEARL: 1 small_tree: + # Seconds between present spawn attempts for small trees. gift-cooldown: 300 + # Small trees get a little more ambient motion and light ornament sparkle. + particles: + ambient: + enabled: true + particle: CHERRY_LEAVES + offset-x: 1.0 + offset-y: 1.5 + offset-z: 1.0 + speed: 0.01 + count: 3 + swag: + enabled: true + particle: DUST + offset-x: 0.25 + offset-y: 0.25 + offset-z: 0.25 + speed: 0.0 + count: 8 + body: + enabled: false + # Materials required to grow from small_tree to tree. lvlup: DIAMOND: 3 GOLD_INGOT: 5 BLAZE_POWDER: 10 SNOWBALL: 30 tree: + # Seconds between present spawn attempts for full trees. gift-cooldown: 180 + # Full trees are more visible, with snow and firefly sparkle. + particles: + ambient: + enabled: true + particle: SNOWFLAKE + offset-x: 1.5 + offset-y: 3.0 + offset-z: 1.5 + speed: 0.0 + count: 8 + swag: + enabled: true + particle: FIREFLY + offset-x: 0.35 + offset-y: 0.35 + offset-z: 0.35 + speed: 0.0 + count: 4 + body: + enabled: false + # Materials required to grow from tree to magic_tree. lvlup: DIAMOND: 5 EMERALD: 3 GOLD_NUGGET: 8 GLOWSTONE_DUST: 16 magic_tree: + # Seconds between present spawn attempts for max-level magic trees. gift-cooldown: 120 - lvlup: + # Magic trees use the strongest ambient set. + particles: + ambient: + enabled: true + particle: FIREFLY + offset-x: 2.25 + offset-y: 2.25 + offset-z: 2.25 + speed: 0.0 + count: 8 + swag: + enabled: true + particle: DUST + offset-x: 0.3 + offset-y: 0.3 + offset-z: 0.3 + speed: 10.0 + count: 16 + body: + enabled: false + # Max-level trees have no next-level requirements. + lvlup: + +migration: + # Map saved world names from legacy trees.yml data to the current server world names. + # This keeps old player trees loadable even when the destination server uses different world names. + # Example: + # world-aliases: + # general: world + # wild: world + # santa: santa_event + world-aliases: {} diff --git a/src/main/resources/locales/default.yml b/src/main/resources/locales/default.yml index 0e793bf..8112fbb 100644 --- a/src/main/resources/locales/default.yml +++ b/src/main/resources/locales/default.yml @@ -17,7 +17,7 @@ #_UNUSED is a keyword to disable message. #For chat -plugin-name: X-Mas +plugin-name: XMas Tree messages: plugin-enabled: Merry Christmas And Happy New Year! @@ -26,31 +26,36 @@ messages: tree: grow-lvl-ready: Woosh! Press Right Mouse Button to let tree grow! grow-lvl-progress: _UNUSED - grow-req-list-title: Required + grow-req-list-title: Still needed + grow-req-list-hint: Right-click the tree while holding the listed ingredient items to feed them into the tree. grow-not-enough-place: Not enough place to make it grow grow-lvl-max: This tree has reached it's maximum level! tree-limit: Hey, do not be greedy! - destroy-sapling: This miracle won't drop. Are you sure? + destroy-sapling: You can cut down this tree. destroy-leaves-santa: Santa Claus sees everything. destroy-leaves-tut: To destroy a tree - cut the log. - destroy-warning: Are you sure? Your progress will be lost and no resources back. - destroy-tut: To destroy your tree cut it again. + destroy-warning: You can cut down this tree. + destroy-resource-back: Your used upgrade items will be returned for collection. + destroy-tut: Hit it again to confirm. destroy-fail-owner: It's not your tree! - destroy-complete: You're MONSTER! + destroy-complete: Your tree has been packed away for the season. gift: luck-message: _UNUSED unluck-message: Probably Santa has put you in his blacklist. I hope you're lucky next time! crystal: - name: Christmas Crystal + name: Christmas Crystal lore: - - Concentrated Christmas Spirit - - Use it on Sapling to fill it with magic! + - Concentrated Christmas Spirit + - Use it on a spruce sapling to fill it with magic! command: help: - - '&2Use &c/xmas &2to show plugin version and status' - - '&2Use &c/xmas give &2 to give crystal somebody' - - '&2Use &c/xmas gifts &2to spawn some presents under trees!' - - '&2Use &c/xmas end &2to set plugin into ending mode' - player-offline: '&cPlayer not found' - no-player-name: '&6Missing player name' - giveaway: '&cHo! &2Ho! &cHo! &3Looks like it\s time to check presents!' \ No newline at end of file + - 'Use /xmastree to show plugin version and status' + - 'Use /xmastree give player to give a player a Christmas Crystal' + - 'Use /xmastree gifts to spawn presents under all trees' + - 'Use /xmastree addhand to add your held item as a gift' + - 'Use /xmastree reload to reload the plugin config' + - 'Use /xmastree debug for paginated status, commands, permissions, placeholders, and toggles' + - 'Use /xmastree end to end the event' + player-offline: 'Player not found' + no-player-name: 'Missing player name' + giveaway: 'Ho! Ho! Ho! Looks like its time to check presents!' diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml index 9abaef3..17b9664 100644 --- a/src/main/resources/locales/en.yml +++ b/src/main/resources/locales/en.yml @@ -1,7 +1,7 @@ #_UNUSED is a keyword to disable message. #For chat -plugin-name: X-Mas +plugin-name: XMas Tree messages: plugin-enabled: Merry Christmas And Happy New Year! @@ -11,37 +11,42 @@ messages: tree: grow-lvl-ready: Woosh! Press Right Mouse Button to let tree grow! grow-lvl-progress: _UNUSED - grow-req-list-title: Required + grow-req-list-title: Still needed + grow-req-list-hint: Right-click the tree while holding the listed ingredient items to feed them into the tree. grow-not-enough-place: Not enough place to make it grow grow-lvl-max: This tree has reached it's maximum level! tree-limit: Hey, do not be greedy! (Tree limit) - destroy-sapling: This miracle won't drop. Are you sure? + destroy-sapling: You can cut down this tree. destroy-leaves-santa: Santa Claus sees everything. destroy-leaves-tut: To destroy a tree - cut the log. - destroy-warning: Are you sure? Your progress will be lost and no resources back. - destroy-tut: To destroy your tree cut it again. + destroy-warning: You can cut down this tree. + destroy-resource-back: Your used upgrade items will be returned for collection. + destroy-tut: Hit it again to confirm. destroy-fail-owner: It's not your tree! - destroy-complete: You're MONSTER! + destroy-complete: Your tree has been packed away for the season. gift: luck-message: _UNUSED unluck-message: Probably Santa has put you in blacklist. I hope you're lucky next time! crystal: - name: Christmas Crystal + name: Christmas Crystal lore: - - Concentrated Christmas Spirit - - Use it on Sapling to fill it with magic! + - Concentrated Christmas Spirit + - Use it on a spruce sapling to fill it with magic! help: - - 'Use /xmas to show plugin version and status' - - 'Use /xmas give to give crystal somebody' - - 'Use /xmas gifts to spawn some presents under every Christmas Tree!' - - 'Use /xmas end to set plugin into ending mode' + - 'Use /xmastree to show plugin version and status' + - 'Use /xmastree give player to give crystal somebody' + - 'Use /xmastree gifts to spawn some presents under every Christmas Tree!' + - 'Use /xmastree end to set plugin into ending mode' command: help: - - '&2Use &c/xmas &2to show plugin version and status' - - '&2Use &c/xmas give &2 to give crystal somebody' - - '&2Use &c/xmas gifts &2to spawn some presents under trees!' - - '&2Use &c/xmas end &2to set plugin into ending mode' - player-offline: '&cPlayer not found' - no-player-name: '&6Missing player name' - giveaway: '&cHo! &2Ho! &cHo! &3Looks like its time to check presents!' \ No newline at end of file + - 'Use /xmastree to show plugin version and status' + - 'Use /xmastree give player to give a player a Christmas Crystal' + - 'Use /xmastree gifts to spawn presents under all trees' + - 'Use /xmastree addhand to add your held item as a gift' + - 'Use /xmastree reload to reload the plugin config' + - 'Use /xmastree debug for paginated status, commands, permissions, placeholders, and toggles' + - 'Use /xmastree end to end the event' + player-offline: 'Player not found' + no-player-name: 'Missing player name' + giveaway: 'Ho! Ho! Ho! Looks like its time to check presents!' diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml index aefd1c2..876eec6 100644 --- a/src/main/resources/locales/hu.yml +++ b/src/main/resources/locales/hu.yml @@ -18,7 +18,7 @@ messages: destroy-sapling: Ez a csoda nem csรถkken. Biztos vagy ebben? destroy-leaves-santa: Mikulรกs lรกt mindent. destroy-leaves-tut: Egy fa megsemmisรญtรฉsรฉhez - vรกgd ki a fรกt. - destroy-warning: Biztos vagy ebben? A fejlล‘dรฉsed elvรฉsz, รฉs nincs erล‘forrรกs. + destroy-warning: Biztos vagy ebben? A fejlล‘dรฉsed elvรฉsz. destroy-tut: A fรกt megsemmisรญteni, vรกgd ki รบjra. destroy-fail-owner: Ez nem a te fรกd! destroy-complete: SZร–RNY vagy! @@ -32,16 +32,19 @@ crystal: - Hasznรกld a fa fa-csemetรฉkhez, hogy kitรถltse mรกgiรกval! help: - 'Hasznรกld /xmas a plugin verziรณjรกnak รฉs รกllapotรกnak megjelenรญtรฉse' - - 'Hasznรกld /xmas give , hogy valakinek kristรกlyt adj' + - 'Hasznรกld /xmas give nรฉv, hogy valakinek kristรกlyt adj' - 'Hasznรกld /xmas ajรกndรฉkok, hogy minden karรกcsonyi fa alรก ajรกndรฉkozzon nรฉhรกny ajรกndรฉkot!' - 'Hasznรกld /xmas vรฉget a bล‘vรญtmรฉny beillesztรฉsรฉnek mรณdjรกba' command: help: - - '&2Hasznรกld &c/xmas &2a plugin verziรณjรกnak รฉs รกllapotรกnak megjelenรญtรฉse' - - '&2Hasznรกld &c/xmas give &2, hogy valakinek kristรกlyt adj' - - '&2Hasznรกld &c/xmas gifts &2hogy fรกk alatt ajรกndรฉkozzon nรฉhรกny ajรกndรฉkot!' - - '&2Hasznรกld &c/xmas end &2a bล‘vรญtmรฉny beillesztรฉse vรฉges mรณdba' - player-offline: '&cA jรกtรฉkos nem talรกlhatรณ' - no-player-name: '&6Hiรกnyzรณ jรกtรฉkos nรฉv' -giveaway: '&cHo! &2Ho! &cHo! &3รšgy nรฉz ki, itt az ideje, hogy ellenล‘rizze ajรกndรฉkokat!' + - 'Hasznรกld /xmas a plugin verziรณjรกnak รฉs รกllapotรกnak megjelenรญtรฉsรฉhez' + - 'Hasznรกld /xmas give nรฉv, hogy kristรกlyt adj egy jรกtรฉkosnak' + - 'Hasznรกld /xmas gifts, hogy ajรกndรฉkok jelenjenek meg a fรกk alatt' + - 'Hasznรกld /xmas addhand, hogy a kรฉzben tartott tรกrgy ajรกndรฉk legyen' + - 'Hasznรกld /xmas reload a konfigurรกciรณ รบjratรถltรฉsรฉhez' + - 'Hasznรกld /xmas debug a lapozhatรณ stรกtusz รฉs kapcsolรณk megjelenรญtรฉsรฉhez' + - 'Hasznรกld /xmas end az esemรฉny lezรกrรกsรกhoz' + player-offline: 'A jรกtรฉkos nem talรกlhatรณ' + no-player-name: 'Hiรกnyzรณ jรกtรฉkos nรฉv' + giveaway: 'Ho! Ho! Ho! Ideje ellenล‘rizni az ajรกndรฉkokat!' diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml index 46baeab..5b744f6 100644 --- a/src/main/resources/locales/ru.yml +++ b/src/main/resources/locales/ru.yml @@ -17,7 +17,7 @@ messages: destroy-sapling: ะญั‚ะพ ั‡ัƒะดะพ ะฝะต ะฒั‹ะฟะฐะดะตั‚. ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? :< destroy-leaves-santa: ะ”ะตะด ะœะพั€ะพะท ะฒัะต ะฒะธะดะธั‚! destroy-leaves-tut: ะ”ะปั ั‚ะพะณะพ ั‡ั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะะปะบัƒ - ั€ัƒะฑะธ ัั‚ะฒะพะป! - destroy-warning: ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? ะ’ะตััŒ ะฟั€ะพะณั€ะตัั ะฑัƒะดะตั‚ ะฟะพั‚ะตั€ัะฝ. ะ ะตััƒั€ัั‹ ะฒะพะทะฒั€ะฐั‰ะตะฝั‹ ะฝะต ะฑัƒะดัƒั‚. + destroy-warning: ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? ะ’ะตััŒ ะฟั€ะพะณั€ะตัั ะฑัƒะดะตั‚ ะฟะพั‚ะตั€ัะฝ. destroy-tut: ะงั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะะปะบัƒ ั€ะฐะทั€ัƒัˆัŒั‚ะต ะฑะปะพะบ ะตั‰ะต ั€ะฐะท. destroy-fail-owner: ะญั‚ะพ ะฝะต ะฒะฐัˆะต ะดะตั€ะตะฒะพ. destroy-complete: ะ˜ ะฝะต ะถะฐะปะบะพ?! @@ -31,10 +31,13 @@ crystal: - ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต ะตะณะพ ะฝะฐ ัะฐะถะตะฝั†ะต ั‡ั‚ะพะฑั‹ ัะดะตะปะฐั‚ัŒ ะตะณะพ ะฒะพะปัˆะตะฑะฝั‹ะผ. command: help: - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas &2- ะพั‚ะพะฑั€ะฐะทะธั‚ัŒ ะฒะตั€ัะธัŽ ะฟะปะฐะณะธะฝะฐ ะธ ัั‚ะฐั‚ัƒั' - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas give &2<ะะธะบ> - ะฒั‹ะดะฐั‚ัŒ ะธะณั€ะพะบัƒ ะบั€ะธัั‚ะฐะปะป' - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas gifts &2- ะฟะพะด ะบะฐะถะดะพะน ะตะปะบะพะน ะฟะพัะฒะธั‚ัั ะฝะตัะบะพะปัŒะบะพ ะฟะพะดะฐั€ะบะพะฒ!' - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas end &2ะŸะตั€ะตะฒะพะดะธั‚ ะฟะปะฐะณะธะฝ ะฒ ั€ะตะถะธะผ ะทะฐะฒะตั€ัˆะตะฝะธั ะฟั€ะฐะทะดะฝะธะบะพะฒ' - player-offline: '&6ะ˜ะณั€ะพะบ ะฝะต ะฝะฐะนะดะตะฝ' - no-player-name: '&cะ’ั‹ ะฝะต ะฒะฒะตะปะธ ะธะผั ะธะณั€ะพะบะฐ!' - giveaway: '&aะšะฐะถะตั‚ัั ะฒั€ะตะผั ะฟั€ะพะฒะตั€ัั‚ัŒ ะฟะพะดะฐั€ะบะธ!' \ No newline at end of file + - 'ะšะพะผะฐะฝะดะฐ /xmas - ะพั‚ะพะฑั€ะฐะทะธั‚ัŒ ะฒะตั€ัะธัŽ ะฟะปะฐะณะธะฝะฐ ะธ ัั‚ะฐั‚ัƒั' + - 'ะšะพะผะฐะฝะดะฐ /xmas give ะะธะบ - ะฒั‹ะดะฐั‚ัŒ ะธะณั€ะพะบัƒ ะบั€ะธัั‚ะฐะปะป' + - 'ะšะพะผะฐะฝะดะฐ /xmas gifts - ัะพะทะดะฐั‚ัŒ ะฟะพะดะฐั€ะบะธ ะฟะพะด ะตะปะบะฐะผะธ' + - 'ะšะพะผะฐะฝะดะฐ /xmas addhand - ะดะพะฑะฐะฒะธั‚ัŒ ะฟั€ะตะดะผะตั‚ ะฒ ั€ัƒะบะต ะฒ ะฟะพะดะฐั€ะบะธ' + - 'ะšะพะผะฐะฝะดะฐ /xmas reload - ะฟะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ ะบะพะฝั„ะธะณ' + - 'ะšะพะผะฐะฝะดะฐ /xmas debug - ะพั‚ะปะฐะดะพั‡ะฝั‹ะน ัั‚ะฐั‚ัƒั, ะบะพะผะฐะฝะดั‹ ะธ ะฟะตั€ะตะบะปัŽั‡ะฐั‚ะตะปะธ' + - 'ะšะพะผะฐะฝะดะฐ /xmas end - ะทะฐะฒะตั€ัˆะธั‚ัŒ ะฟั€ะฐะทะดะฝะธะบ' + player-offline: 'ะ˜ะณั€ะพะบ ะฝะต ะฝะฐะนะดะตะฝ' + no-player-name: 'ะ’ั‹ ะฝะต ะฒะฒะตะปะธ ะธะผั ะธะณั€ะพะบะฐ!' + giveaway: 'ะšะฐะถะตั‚ัั ะฒั€ะตะผั ะฟั€ะพะฒะตั€ัั‚ัŒ ะฟะพะดะฐั€ะบะธ!' diff --git a/src/main/resources/locales/ru_santa.yml b/src/main/resources/locales/ru_santa.yml index 239cd4c..732d369 100644 --- a/src/main/resources/locales/ru_santa.yml +++ b/src/main/resources/locales/ru_santa.yml @@ -17,7 +17,7 @@ messages: destroy-sapling: ะญั‚ะพ ั‡ัƒะดะพ ะฝะต ะฒั‹ะฟะฐะดะตั‚. ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? :< destroy-leaves-santa: ะกะฐะฝั‚ะฐ ะšะปะฐัƒั ะฒัะต ะฒะธะดะธั‚! destroy-leaves-tut: ะ”ะปั ั‚ะพะณะพ ั‡ั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะ ะพะถะดะตัั‚ะฒะตะฝัะบะพะต ะดะตั€ะตะฒะพ - ั€ัƒะฑะธ ัั‚ะฒะพะป! - destroy-warning: ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? ะ’ะตััŒ ะฟั€ะพะณั€ะตัั ะฑัƒะดะตั‚ ะฟะพั‚ะตั€ัะฝ. ะ ะตััƒั€ัั‹ ะฒะพะทะฒั€ะฐั‰ะตะฝั‹ ะฝะต ะฑัƒะดัƒั‚. + destroy-warning: ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? ะ’ะตััŒ ะฟั€ะพะณั€ะตัั ะฑัƒะดะตั‚ ะฟะพั‚ะตั€ัะฝ. destroy-tut: ะงั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะ ะพะถะดะตัั‚ะฒะตะฝัะบะพะต ะดะตั€ะตะฒะพ ั€ะฐะทั€ัƒัˆัŒั‚ะต ะฑะปะพะบ ะตั‰ะต ั€ะฐะท. destroy-fail-owner: ะญั‚ะพ ะฝะต ะฒะฐัˆะต ะดะตั€ะตะฒะพ! destroy-complete: ะ˜ ะฝะต ะถะฐะปะบะพ?! @@ -31,10 +31,13 @@ crystal: - ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต ะตะณะพ ะฝะฐ ัะฐะถะตะฝั†ะต ั‡ั‚ะพะฑั‹ ัะดะตะปะฐั‚ัŒ ะตะณะพ ะฒะพะปัˆะตะฑะฝั‹ะผ. command: help: - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas &2- ะพั‚ะพะฑั€ะฐะทะธั‚ัŒ ะฒะตั€ัะธัŽ ะฟะปะฐะณะธะฝะฐ ะธ ัั‚ะฐั‚ัƒั' - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas give &2<ะะธะบ> - ะฒั‹ะดะฐั‚ัŒ ะธะณั€ะพะบัƒ ะบั€ะธัั‚ะฐะปะป' - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas gifts &2- ะฟะพะด ะบะฐะถะดะพะน ะตะปะบะพะน ะฟะพัะฒะธั‚ัั ะฝะตัะบะพะปัŒะบะพ ะฟะพะดะฐั€ะบะพะฒ!' - - '&2ะšะพะผะฐะฝะดะฐ &c/xmas end &2ะŸะตั€ะตะฒะพะดะธั‚ ะฟะปะฐะณะธะฝ ะฒ ั€ะตะถะธะผ ะทะฐะฒะตั€ัˆะตะฝะธั ะฟั€ะฐะทะดะฝะธะบะพะฒ' + - 'ะšะพะผะฐะฝะดะฐ /xmas - ะพั‚ะพะฑั€ะฐะทะธั‚ัŒ ะฒะตั€ัะธัŽ ะฟะปะฐะณะธะฝะฐ ะธ ัั‚ะฐั‚ัƒั' + - 'ะšะพะผะฐะฝะดะฐ /xmas give ะะธะบ - ะฒั‹ะดะฐั‚ัŒ ะธะณั€ะพะบัƒ ะบั€ะธัั‚ะฐะปะป' + - 'ะšะพะผะฐะฝะดะฐ /xmas gifts - ัะพะทะดะฐั‚ัŒ ะฟะพะดะฐั€ะบะธ ะฟะพะด ะตะปะบะฐะผะธ' + - 'ะšะพะผะฐะฝะดะฐ /xmas addhand - ะดะพะฑะฐะฒะธั‚ัŒ ะฟั€ะตะดะผะตั‚ ะฒ ั€ัƒะบะต ะฒ ะฟะพะดะฐั€ะบะธ' + - 'ะšะพะผะฐะฝะดะฐ /xmas reload - ะฟะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ ะบะพะฝั„ะธะณ' + - 'ะšะพะผะฐะฝะดะฐ /xmas debug - ะพั‚ะปะฐะดะพั‡ะฝั‹ะน ัั‚ะฐั‚ัƒั, ะบะพะผะฐะฝะดั‹ ะธ ะฟะตั€ะตะบะปัŽั‡ะฐั‚ะตะปะธ' + - 'ะšะพะผะฐะฝะดะฐ /xmas end - ะทะฐะฒะตั€ัˆะธั‚ัŒ ะฟั€ะฐะทะดะฝะธะบ' player-offline: 'ะ˜ะณั€ะพะบ ะฝะต ะฝะฐะนะดะตะฝ' no-player-name: 'ะ’ั‹ ะฝะต ะฒะฒะตะปะธ ะธะผั ะธะณั€ะพะบะฐ!' - giveaway: '&cะฅะพัƒ! &2ะฅะพัƒ! &cะฅะพัƒ! &2ะšะฐะถะตั‚ัั ะฒั€ะตะผั ะฟั€ะพะฒะตั€ัั‚ัŒ ะฟะพะดะฐั€ะบะธ!' \ No newline at end of file + giveaway: 'ะฅะพัƒ! ะฅะพัƒ! ะฅะพัƒ! ะšะฐะถะตั‚ัั ะฒั€ะตะผั ะฟั€ะพะฒะตั€ัั‚ัŒ ะฟะพะดะฐั€ะบะธ!' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 8bcb742..92ca44d 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,10 +1,15 @@ name: X-Mas -version: ${project.version} +version: ${version} main: ru.meloncode.xmas.Main load: POSTWORLD -api-version: '1.14' -softdepend: [Multiverse-Core, PlotMe, PlotSquared, Plotz] +api-version: '1.21' +softdepend: [Multiverse-Core, PlotMe, PlotSquared, Plotz, PlaceholderAPI] commands: - xmas: - usage: Use / help to see help - permission: xmas.admin \ No newline at end of file + xmastree: + description: Manage the 1MB XMas Tree event + usage: / [help|give|gifts|addhand|reload|debug|end] + permission: xmas.admin +permissions: + xmas.admin: + description: Allows all XMas Tree admin commands + default: op From 221e404be9f26f38a26f385015eff3c62c56ee97 Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Tue, 21 Apr 2026 20:58:59 +0200 Subject: [PATCH 03/10] - more commands, permissions, - placeholders, updated readme, - further testing done. - pre-release beta. Dont use in live yet --- README.md | 32 ++++-- build.gradle | 4 +- src/main/java/ru/meloncode/xmas/Events.java | 6 +- .../java/ru/meloncode/xmas/XMasCommand.java | 103 ++++++++++++++++-- .../xmas/XMasPlaceholderExpansion.java | 2 +- src/main/resources/plugin.yml | 46 +++++++- 6 files changed, 162 insertions(+), 31 deletions(-) diff --git a/README.md b/README.md index f45e021..c79ac56 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ The Gradle build creates the legacy reference jar and the current Paper 26.1.2 t | Jar | Purpose | | --- | --- | | `1MB-XMas-2026-v2.0.0-004-v21-1.21.8.jar` | Legacy reference jar copied from the deployed 2025 server jar. | -| `1MB-XMas-2026-v2.0.1-010-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | +| `1MB-XMas-2026-v2.0.1-011-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | The checked-in source targets Paper 26.1.2. The legacy jar is preserved so the deployed working 2025 behavior can be compared or rolled back during testing. @@ -44,7 +44,7 @@ The checked-in source targets Paper 26.1.2. The legacy jar is preserved so the d For the 2026 target, use the modern Paper 26.1.2 jar: -- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-010-v25-26.1.2.jar` +- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-011-v25-26.1.2.jar` ## Building @@ -52,9 +52,9 @@ Requirements: - JDK 25 - Gradle -- The local Paper server folder in `servers/Server-Two-Paper-26.1.2` -- The local PlaceholderAPI jar in `servers/Server-Two-Paper-26.1.2/plugins` -- The deployed legacy jar in `servers/Server-One-Paper-1.21.11/plugins` if you want `legacyJar` +- The current local dev/test setup in this repo uses `servers/Server-Two-Paper-26.1.2` for Paper API jars and local smoke testing +- The current local dev/test setup in this repo uses `servers/Server-Two-Paper-26.1.2/plugins/PlaceholderAPI-2.12.3-DEV-265.jar` for the optional PlaceholderAPI compile-time classpath +- The deployed legacy jar in `servers/Server-One-Paper-1.21.11/plugins` is only needed if you want the `legacyJar` copy task Build the current Paper 26.1.2 jar and the legacy reference jar: @@ -80,7 +80,9 @@ Copy the deployed legacy jar into the requested legacy filename: gradle legacyJar ``` -The build compiles against the Paper 26.1.2 API jars found in `servers/Server-Two-Paper-26.1.2`. If that folder is missing or has not been started far enough for Paper to download its libraries, Gradle will not have the Paper API classpath it needs. +End users do not need the `servers/` folder. The build output jars are written to `build/libs/`, and those are the files you install on a Paper server. + +In this workspace, the current Gradle setup compiles against the Paper 26.1.2 API jars found in `servers/Server-Two-Paper-26.1.2`. If that folder is missing or has not been started far enough for Paper to download its libraries, Gradle will not have the local Paper API classpath it currently expects. ## Commands @@ -104,7 +106,17 @@ If `core.commands.legacy-command-enabled` is `true`, the legacy `/xmas` alias is | Permission | Default | Description | | --- | --- | --- | -| `xmas.admin` | `op` | Allows use of the `/xmastree` command and all XMas Tree admin subcommands. | +| `onembxmastree.admin` | `op` | Umbrella permission for all XMas Tree commands and override actions. | +| `onembxmastree.command.status` | `true` | Allows viewing `/xmastree` status output. | +| `onembxmastree.command.help` | `true` | Allows viewing `/xmastree help`. | +| `onembxmastree.command.give` | `op` | Allows `/xmastree give `. | +| `onembxmastree.command.gifts` | `op` | Allows `/xmastree gifts`. | +| `onembxmastree.command.addhand` | `op` | Allows `/xmastree addhand`. | +| `onembxmastree.command.reload` | `op` | Allows `/xmastree reload`. | +| `onembxmastree.command.debug` | `op` | Allows `/xmastree debug [page]`. | +| `onembxmastree.command.debug.toggle` | `op` | Allows `/xmastree debug toggle true\|false`. | +| `onembxmastree.command.end` | `op` | Allows `/xmastree end`. | +| `onembxmastree.tree.override` | `op` | Allows managing other players' trees. | ## Player flow @@ -199,7 +211,7 @@ The dotted key after `onembxmastree_` is supported to keep the placeholders read | `%onembxmastree_trees.total%` | `14` | Total loaded X-Mas trees. | | `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | | `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | -| `%onembxmastree_version%` | `2.0.1-010` | Loaded plugin version. | +| `%onembxmastree_version%` | `2.0.1-011` | Loaded plugin version. | CMI hologram example: @@ -229,7 +241,7 @@ ajLeaderboards placeholder examples: ## Security notes -- Admin commands are gated by `xmas.admin`. +- Admin and staff access is gated by `onembxmastree.*` permissions. - Present texture URLs are restricted to `textures.minecraft.net`. - Gift item Base64 entries are capped before deserialization. - Config material names are resolved with modern `Material.matchMaterial` and invalid or legacy values are skipped. @@ -247,7 +259,7 @@ Please report bugs, compatibility problems, and upgrade questions in the GitHub - **Ghost_chu** - NMS fixes - [Ghost-chu](https://github.com/Ghost-chu) - **LoneDev6** - Optimization patches - [LoneDev6](https://github.com/LoneDev6) - **montlikadani** - Hungarian translation - [montlikadani](https://github.com/montlikadani) -- **1MB / mrfdev** - 2026 Paper modernization, Java 25 builds, and XMasTree maintenance +- **mrfloris** - 2026 Paper modernization, Java 25 builds, and XMasTree maintenance - [mrfloris](https://github.com/mrfloris) Original SpigotMC listing: diff --git a/build.gradle b/build.gradle index 70258e1..cc34b26 100644 --- a/build.gradle +++ b/build.gradle @@ -3,10 +3,10 @@ plugins { } group = 'com.onemb.xmas' -version = '2.0.1-010' +version = '2.0.1-011' def legacyArchiveName = '1MB-XMas-2026-v2.0.0-004-v21-1.21.8.jar' -def paper2612ArchiveName = '1MB-XMas-2026-v2.0.1-010-v25-26.1.2.jar' +def paper2612ArchiveName = '1MB-XMas-2026-v2.0.1-011-v25-26.1.2.jar' def serverOne = layout.projectDirectory.dir('servers/Server-One-Paper-1.21.11') def serverTwo = layout.projectDirectory.dir('servers/Server-Two-Paper-26.1.2') diff --git a/src/main/java/ru/meloncode/xmas/Events.java b/src/main/java/ru/meloncode/xmas/Events.java index e0d3b60..604ca7d 100644 --- a/src/main/java/ru/meloncode/xmas/Events.java +++ b/src/main/java/ru/meloncode/xmas/Events.java @@ -217,7 +217,7 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { MagicTree tree = MagicTree.getTreeByBlock(block); switch (block.getType()) { case SPRUCE_LOG: - if (player.getUniqueId().equals(tree.getOwner()) || player.hasPermission("xmas.admin")) { + if (player.getUniqueId().equals(tree.getOwner()) || XMasCommand.canOverrideTree(player)) { if (Main.inProgress) if (destroyers.containsKey(player.getUniqueId()) && System.currentTimeMillis() - destroyers.get(player.getUniqueId()) <= 10000) { if (Main.resourceBack) { @@ -247,14 +247,14 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { case GLOWSTONE: if (Main.inProgress) TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_LEAVES_SANTA); - if (player.getUniqueId().equals(tree.getOwner()) || player.hasPermission("xmas.admin")) { + if (player.getUniqueId().equals(tree.getOwner()) || XMasCommand.canOverrideTree(player)) { TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_LEAVES_TUT); } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); } break; case SPRUCE_SAPLING: - if (player.getUniqueId().equals(tree.getOwner()) || player.hasPermission("xmas.admin")) { + if (player.getUniqueId().equals(tree.getOwner()) || XMasCommand.canOverrideTree(player)) { if (Main.inProgress) { if (destroyers.containsKey(player.getUniqueId()) && System.currentTimeMillis() - destroyers.get(player.getUniqueId()) <= 10000) { if (Main.resourceBack) { diff --git a/src/main/java/ru/meloncode/xmas/XMasCommand.java b/src/main/java/ru/meloncode/xmas/XMasCommand.java index fd41d14..05dd0cd 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -20,6 +20,17 @@ public class XMasCommand implements CommandExecutor, TabCompleter { public static final String PRIMARY_COMMAND = "xmastree"; public static final String LEGACY_COMMAND = "xmas"; + public static final String PERMISSION_ADMIN = "onembxmastree.admin"; + public static final String PERMISSION_STATUS = "onembxmastree.command.status"; + public static final String PERMISSION_HELP = "onembxmastree.command.help"; + public static final String PERMISSION_GIVE = "onembxmastree.command.give"; + public static final String PERMISSION_GIFTS = "onembxmastree.command.gifts"; + public static final String PERMISSION_ADDHAND = "onembxmastree.command.addhand"; + public static final String PERMISSION_RELOAD = "onembxmastree.command.reload"; + public static final String PERMISSION_DEBUG = "onembxmastree.command.debug"; + public static final String PERMISSION_DEBUG_TOGGLE = "onembxmastree.command.debug.toggle"; + public static final String PERMISSION_END = "onembxmastree.command.end"; + public static final String PERMISSION_TREE_OVERRIDE = "onembxmastree.tree.override"; private static final List COMMANDS = Arrays.asList("help", "give", "end", "gifts", "reload", "addhand", "debug"); private static final Set DEBUG_TOGGLE_KEYS = new LinkedHashSet<>(Arrays.asList( "core.commands.legacy-command-enabled", @@ -29,6 +40,7 @@ public class XMasCommand implements CommandExecutor, TabCompleter { "core.particles-enabled", "xmas.luck.enabled" )); + private static final Map PERMISSIONS = createPermissionDescriptions(); private static final int DEBUG_PAGE_SIZE = 8; private static XMasCommand registeredExecutor; private static PluginCommand legacyAliasCommand; @@ -65,12 +77,20 @@ public boolean onCommand(CommandSender sender, Command command, String label, St String action = args[0].toLowerCase(Locale.ENGLISH); switch (action) { case "help": { + if (!hasPermission(sender, PERMISSION_HELP)) { + sendNoPermission(sender); + break; + } for (String line : getHelpLines()) { TextUtils.sendRawMessage(sender, line); } break; } case "give": { + if (!hasPermission(sender, PERMISSION_GIVE)) { + sendNoPermission(sender); + break; + } if (args.length > 1) { String name = args[1]; Player player = Bukkit.getPlayer(name); @@ -85,10 +105,18 @@ public boolean onCommand(CommandSender sender, Command command, String label, St break; } case "end": { + if (!hasPermission(sender, PERMISSION_END)) { + sendNoPermission(sender); + break; + } plugin.end(); break; } case "gifts": { + if (!hasPermission(sender, PERMISSION_GIFTS)) { + sendNoPermission(sender); + break; + } Random random = new Random(); for (MagicTree magicTree : XMas.getAllTrees()) { for (int i = 0; i < 3 + random.nextInt(4); i++) { @@ -99,8 +127,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St break; } case "reload": { - if (!sender.hasPermission("xmas.admin")) { - TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + if (!hasPermission(sender, PERMISSION_RELOAD)) { + sendNoPermission(sender); break; } plugin.reloadPluginConfig(); @@ -112,8 +140,8 @@ public boolean onCommand(CommandSender sender, Command command, String label, St TextUtils.sendRawMessage(sender, "Only players can use this command."); break; } - if (!sender.hasPermission("xmas.admin")) { - TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + if (!hasPermission(sender, PERMISSION_ADDHAND)) { + sendNoPermission(sender); break; } ItemStack item = player.getInventory().getItemInMainHand(); @@ -134,7 +162,11 @@ public boolean onCommand(CommandSender sender, Command command, String label, St return false; } } else { - sendStatus(sender); + if (!hasPermission(sender, PERMISSION_STATUS)) { + sendNoPermission(sender); + } else { + sendStatus(sender); + } } return true; } @@ -145,7 +177,7 @@ public List onTabComplete(CommandSender sender, Command command, String if (args.length == 1) { String typed = args[0].toLowerCase(Locale.ENGLISH); for (String subCommand : COMMANDS) { - if (subCommand.startsWith(typed)) { + if (subCommand.startsWith(typed) && canUseSubCommand(sender, subCommand)) { suggestions.add(subCommand); } } @@ -199,13 +231,17 @@ private List getStatusLines() { } private void handleDebug(CommandSender sender, String[] args) { - if (!sender.hasPermission("xmas.admin")) { - TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + if (args.length >= 2 && args[1].equalsIgnoreCase("toggle")) { + if (!hasPermission(sender, PERMISSION_DEBUG_TOGGLE)) { + sendNoPermission(sender); + return; + } + handleDebugToggle(sender, args); return; } - if (args.length >= 2 && args[1].equalsIgnoreCase("toggle")) { - handleDebugToggle(sender, args); + if (!hasPermission(sender, PERMISSION_DEBUG)) { + sendNoPermission(sender); return; } @@ -263,7 +299,9 @@ private void sendDebugPage(CommandSender sender, int requestedPage) { } lines.add(""); lines.add("Permissions"); - lines.add("xmas.admin - allows all " + TextUtils.DISPLAY_NAME + " admin commands"); + for (Map.Entry permission : PERMISSIONS.entrySet()) { + lines.add("" + permission.getKey() + " - " + permission.getValue()); + } lines.add(""); lines.add("Placeholders"); lines.add("Requires PlaceholderAPI. Use '_' after prefix, then dotted keys."); @@ -312,6 +350,10 @@ private boolean isLegacyAliasEnabled() { return plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true); } + public static boolean canOverrideTree(CommandSender sender) { + return hasPermission(sender, PERMISSION_TREE_OVERRIDE); + } + private static void syncLegacyAlias(Main plugin, XMasCommand executor) { CommandMap commandMap = getCommandMap(); if (commandMap == null) { @@ -346,7 +388,7 @@ private static void syncLegacyAlias(Main plugin, XMasCommand executor) { } aliasCommand.setDescription("Legacy alias for /" + PRIMARY_COMMAND); aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|debug|end]"); - aliasCommand.setPermission("xmas.admin"); + aliasCommand.setPermission(null); aliasCommand.setExecutor(executor); aliasCommand.setTabCompleter(executor); commandMap.register(plugin.getDescription().getName().toLowerCase(Locale.ENGLISH), aliasCommand); @@ -420,4 +462,41 @@ private List filterStartingWith(List values, String typed) { return matches; } + private boolean canUseSubCommand(CommandSender sender, String subCommand) { + return switch (subCommand.toLowerCase(Locale.ENGLISH)) { + case "help" -> hasPermission(sender, PERMISSION_HELP); + case "give" -> hasPermission(sender, PERMISSION_GIVE); + case "end" -> hasPermission(sender, PERMISSION_END); + case "gifts" -> hasPermission(sender, PERMISSION_GIFTS); + case "reload" -> hasPermission(sender, PERMISSION_RELOAD); + case "addhand" -> hasPermission(sender, PERMISSION_ADDHAND); + case "debug" -> hasPermission(sender, PERMISSION_DEBUG) || hasPermission(sender, PERMISSION_DEBUG_TOGGLE); + default -> false; + }; + } + + private static boolean hasPermission(CommandSender sender, String permission) { + return sender.hasPermission(PERMISSION_ADMIN) || sender.hasPermission(permission); + } + + private void sendNoPermission(CommandSender sender) { + TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + } + + private static Map createPermissionDescriptions() { + Map permissions = new LinkedHashMap<>(); + permissions.put(PERMISSION_ADMIN, "allows all " + TextUtils.DISPLAY_NAME + " commands and overrides"); + permissions.put(PERMISSION_STATUS, "shows /" + PRIMARY_COMMAND + " status output"); + permissions.put(PERMISSION_HELP, "shows /" + PRIMARY_COMMAND + " help output"); + permissions.put(PERMISSION_GIVE, "allows /" + PRIMARY_COMMAND + " give"); + permissions.put(PERMISSION_GIFTS, "allows /" + PRIMARY_COMMAND + " gifts"); + permissions.put(PERMISSION_ADDHAND, "allows /" + PRIMARY_COMMAND + " addhand"); + permissions.put(PERMISSION_RELOAD, "allows /" + PRIMARY_COMMAND + " reload"); + permissions.put(PERMISSION_DEBUG, "allows /" + PRIMARY_COMMAND + " debug"); + permissions.put(PERMISSION_DEBUG_TOGGLE, "allows /" + PRIMARY_COMMAND + " debug toggle"); + permissions.put(PERMISSION_END, "allows /" + PRIMARY_COMMAND + " end"); + permissions.put(PERMISSION_TREE_OVERRIDE, "allows managing other players' trees"); + return permissions; + } + } diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java index 2567550..f9f26f5 100644 --- a/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java @@ -19,7 +19,7 @@ public String getIdentifier() { @Override public String getAuthor() { - return "1MB / mrfdev"; + return "mrfloris"; } @Override diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 92ca44d..781a596 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -8,8 +8,48 @@ commands: xmastree: description: Manage the 1MB XMas Tree event usage: / [help|give|gifts|addhand|reload|debug|end] - permission: xmas.admin permissions: - xmas.admin: - description: Allows all XMas Tree admin commands + onembxmastree.admin: + description: Allows all XMas Tree commands and overrides + default: op + children: + onembxmastree.command.status: true + onembxmastree.command.help: true + onembxmastree.command.give: true + onembxmastree.command.gifts: true + onembxmastree.command.addhand: true + onembxmastree.command.reload: true + onembxmastree.command.debug: true + onembxmastree.command.debug.toggle: true + onembxmastree.command.end: true + onembxmastree.tree.override: true + onembxmastree.command.status: + description: Allows viewing /xmastree status + default: true + onembxmastree.command.help: + description: Allows viewing /xmastree help + default: true + onembxmastree.command.give: + description: Allows /xmastree give + default: op + onembxmastree.command.gifts: + description: Allows /xmastree gifts + default: op + onembxmastree.command.addhand: + description: Allows /xmastree addhand + default: op + onembxmastree.command.reload: + description: Allows /xmastree reload + default: op + onembxmastree.command.debug: + description: Allows /xmastree debug + default: op + onembxmastree.command.debug.toggle: + description: Allows /xmastree debug toggle + default: op + onembxmastree.command.end: + description: Allows /xmastree end + default: op + onembxmastree.tree.override: + description: Allows managing other players' trees default: op From 2d6a2ebc4c932a414026c0432acd20c0051a214a Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Tue, 21 Apr 2026 21:55:49 +0200 Subject: [PATCH 04/10] Modernize XMas Tree for Paper 26.1.2 / Java 25 - move the project from the legacy deployed build to an actively maintained Paper 26.1.2 / Java 25 Gradle build - simplify the build around the active Paper 26.1.2 target and remove the retired 1.21.11 local server dependency - keep build output clean and predictable in build/libs with the new 2026 versioned jar naming - keep legacy tree data compatible by continuing to read plugins/X-Mas/trees.yml - add world alias migration support so old saved trees can survive renamed worlds - preserve old event data while modernizing the runtime and admin tooling - make /xmastree the primary command - keep /xmas as an optional legacy alias controlled by config - fix legacy alias reload/unregister behavior so reload no longer crashes when alias settings change - improve /xmastree help output and keep it aligned with the actual command surface - add granular permissions under onembxmastree.* - replace the old xmas.admin permission with onembxmastree.admin - add separate permissions for status, help, give, gifts, addhand, reload, debug, debug.toggle, end, and tree.override - add a modern debug system with named categories: status, commands, permissions, placeholders, and config - keep numeric debug pages working as a legacy shortcut - improve debug output formatting with clearer key/value coloring - make invalid debug page/section requests return a helpful response instead of silently falling back - add /xmastree debug toggle true|false for live boolean config changes - keep tab completion focused on named debug categories instead of numeric page suggestions - add optional PlaceholderAPI support with the onembxmastree namespace - add placeholders for event state, end time, end countdown, auto-end, resource-back, particles, luck, tree totals, owner totals, player tree count, and plugin version - document placeholders in the README and show them in debug output - modernize message handling with MiniMessage support while keeping legacy color compatibility - improve player-facing text, prefixes, debug output, and help text - change the visible plugin/chat identity toward XMas Tree for clearer user-facing output - make the Christmas Crystal display name non-italic - fix resource-back so destroying a tree returns only the materials actually spent on that tree - fix the old refund dupe issue where the plugin could return more than the player had used - improve refund delivery with fallback order: chest -> barrel -> player inventory -> floor drops - reduce the loud grow/ingredient sound behavior - make first-hit and repeat-hit grow sound volumes configurable and reloadable - support silent/quiet/loud tuning through config without server restarts - modernize material and item handling for current Paper names - use safer material matching/validation to avoid legacy enum failures - improve displayed item names so materials such as Redstone Dust render properly in requirement output - add configurable per-stage particle effects using modern Paper particle names - harden config and item parsing: restrict present texture URLs to textures.minecraft.net cap Base64 gift payload handling skip invalid or legacy material names safely - update config comments and improve documentation for installation, building, commands, permissions, placeholders, support, and credits - point support to the GitHub issues page - refresh .gitignore for local dev/test folders and obvious OS/build junk i consider this a tested and worth public beta that can be used to figure out any bugs, report them on github as an issue. december is not around the corner, so we have a summer to find issues. Enjoy. --- README.md | 62 +++-- build.gradle | 22 +- .../java/ru/meloncode/xmas/XMasCommand.java | 233 +++++++++++++----- .../ru/meloncode/xmas/XMasPlaceholders.java | 25 ++ src/main/resources/locales/default.yml | 7 +- src/main/resources/locales/en.yml | 7 +- src/main/resources/plugin.yml | 2 +- 7 files changed, 256 insertions(+), 102 deletions(-) diff --git a/README.md b/README.md index c79ac56..60bf4b8 100644 --- a/README.md +++ b/README.md @@ -6,16 +6,15 @@ This fork keeps the old X-Mas event data usable for winter 2026 while moving the ![X-Mas tree preview](http://puu.sh/dKlK1/85c3dad454.jpg) -## Current targets +## Current target -The Gradle build creates the legacy reference jar and the current Paper 26.1.2 target jar in `build/libs`: +The Gradle build creates the current Paper 26.1.2 target jar in `build/libs`: | Jar | Purpose | | --- | --- | -| `1MB-XMas-2026-v2.0.0-004-v21-1.21.8.jar` | Legacy reference jar copied from the deployed 2025 server jar. | -| `1MB-XMas-2026-v2.0.1-011-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | +| `1MB-XMas-2026-v2.0.1-021-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | -The checked-in source targets Paper 26.1.2. The legacy jar is preserved so the deployed working 2025 behavior can be compared or rolled back during testing. +The checked-in source targets Paper 26.1.2 only. ## Features @@ -28,7 +27,7 @@ The checked-in source targets Paper 26.1.2. The legacy jar is preserved so the d - Existing `plugins/X-Mas/trees.yml` data remains the event data source. - Optional resource refunds when a tree is destroyed or cleaned up after the event. - Configurable per-stage particles using Paper 26.1.2 particle names. -- `/xmastree debug` pages for status, commands, permissions, placeholders, and global boolean toggles. +- `/xmastree debug` sections for `status`, `commands`, `permissions`, `placeholders`, and `config`, plus live global boolean toggles. - Primary `/xmastree` command with an optional legacy `/xmas` alias. - Optional PlaceholderAPI placeholders for CMI holograms, ajLeaderboards, scoreboards, and menus. - Legacy `trees.yml` world-name alias support for renamed destination worlds. @@ -44,7 +43,7 @@ The checked-in source targets Paper 26.1.2. The legacy jar is preserved so the d For the 2026 target, use the modern Paper 26.1.2 jar: -- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-011-v25-26.1.2.jar` +- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-021-v25-26.1.2.jar` ## Building @@ -54,9 +53,8 @@ Requirements: - Gradle - The current local dev/test setup in this repo uses `servers/Server-Two-Paper-26.1.2` for Paper API jars and local smoke testing - The current local dev/test setup in this repo uses `servers/Server-Two-Paper-26.1.2/plugins/PlaceholderAPI-2.12.3-DEV-265.jar` for the optional PlaceholderAPI compile-time classpath -- The deployed legacy jar in `servers/Server-One-Paper-1.21.11/plugins` is only needed if you want the `legacyJar` copy task -Build the current Paper 26.1.2 jar and the legacy reference jar: +Build the current Paper 26.1.2 jar: ```bash gradle clean buildAllJars @@ -74,12 +72,6 @@ The `paper2612Jar` task is kept as an alias: gradle paper2612Jar ``` -Copy the deployed legacy jar into the requested legacy filename: - -```bash -gradle legacyJar -``` - End users do not need the `servers/` folder. The build output jars are written to `build/libs/`, and those are the files you install on a Paper server. In this workspace, the current Gradle setup compiles against the Paper 26.1.2 API jars found in `servers/Server-Two-Paper-26.1.2`. If that folder is missing or has not been started far enough for Paper to download its libraries, Gradle will not have the local Paper API classpath it currently expects. @@ -98,10 +90,44 @@ If `core.commands.legacy-command-enabled` is `true`, the legacy `/xmas` alias is | `/xmastree gifts` | Spawns a small batch of presents under every loaded Christmas tree. | | `/xmastree addhand` | Adds the item in your main hand to the gift list and saves it to `config.yml`. | | `/xmastree reload` | Reloads config, locale, present heads, gifts, luck settings, command alias settings, and tree level requirements. | -| `/xmastree debug [page]` | Shows paginated status, commands, permissions, placeholders, and toggleable global config keys. | +| `/xmastree debug` | Opens the `status` debug section by default. | +| `/xmastree debug [section\|page]` | Shows debug output for `status`, `commands`, `permissions`, `placeholders`, or `config`. Numeric pages `1-5` still work as a legacy shortcut. | | `/xmastree debug toggle true\|false` | Toggles supported global boolean config keys and reloads the plugin config. | | `/xmastree end` | Ends the event and sets `core.plugin-enabled` to `false`. | +### Debug sections + +The preferred debug syntax is category-based: + +| Section | Example | Purpose | +| --- | --- | --- | +| `status` | `/xmastree debug status` | Event state, end date, auto-end, refund state, particles, loaded tree count, and owner count. | +| `commands` | `/xmastree debug commands` | Command list, debug syntax, and legacy alias state. | +| `permissions` | `/xmastree debug permissions` | All registered `onembxmastree.*` permissions and what they allow. | +| `placeholders` | `/xmastree debug placeholders` | All built-in PlaceholderAPI placeholders plus their descriptions. | +| `config` | `/xmastree debug config` | The current values of the toggleable global config keys. | + +Numeric compatibility remains available for existing habits and old screenshots: + +| Page | Section | +| --- | --- | +| `1` | `status` | +| `2` | `commands` | +| `3` | `permissions` | +| `4` | `placeholders` | +| `5` | `config` | + +### Debug toggle keys + +`/xmastree debug toggle true|false` currently supports: + +- `core.commands.legacy-command-enabled` +- `core.plugin-enabled` +- `core.holiday-ends.enabled` +- `core.holiday-ends.resource-back` +- `core.particles-enabled` +- `xmas.luck.enabled` + ## Permissions | Permission | Default | Description | @@ -113,7 +139,7 @@ If `core.commands.legacy-command-enabled` is `true`, the legacy `/xmas` alias is | `onembxmastree.command.gifts` | `op` | Allows `/xmastree gifts`. | | `onembxmastree.command.addhand` | `op` | Allows `/xmastree addhand`. | | `onembxmastree.command.reload` | `op` | Allows `/xmastree reload`. | -| `onembxmastree.command.debug` | `op` | Allows `/xmastree debug [page]`. | +| `onembxmastree.command.debug` | `op` | Allows `/xmastree debug [section\|page]`. | | `onembxmastree.command.debug.toggle` | `op` | Allows `/xmastree debug toggle true\|false`. | | `onembxmastree.command.end` | `op` | Allows `/xmastree end`. | | `onembxmastree.tree.override` | `op` | Allows managing other players' trees. | @@ -211,7 +237,7 @@ The dotted key after `onembxmastree_` is supported to keep the placeholders read | `%onembxmastree_trees.total%` | `14` | Total loaded X-Mas trees. | | `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | | `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | -| `%onembxmastree_version%` | `2.0.1-011` | Loaded plugin version. | +| `%onembxmastree_version%` | `2.0.1-021` | Loaded plugin version. | CMI hologram example: diff --git a/build.gradle b/build.gradle index cc34b26..e41b8e4 100644 --- a/build.gradle +++ b/build.gradle @@ -3,14 +3,10 @@ plugins { } group = 'com.onemb.xmas' -version = '2.0.1-011' +version = '2.0.1-021' +def paper2612ArchiveName = '1MB-XMas-2026-v2.0.1-021-v25-26.1.2.jar' -def legacyArchiveName = '1MB-XMas-2026-v2.0.0-004-v21-1.21.8.jar' -def paper2612ArchiveName = '1MB-XMas-2026-v2.0.1-011-v25-26.1.2.jar' - -def serverOne = layout.projectDirectory.dir('servers/Server-One-Paper-1.21.11') def serverTwo = layout.projectDirectory.dir('servers/Server-Two-Paper-26.1.2') -def legacyServerJar = serverOne.file('plugins/1MB-X-Mas_2025-1.21.8.jar') def placeholderApiJar = serverTwo.file('plugins/PlaceholderAPI-2.12.3-DEV-265.jar') def paper2612Api = serverTwo.file('libraries/io/papermc/paper/paper-api/26.1.2.build.18-alpha/paper-api-26.1.2.build.18-alpha.jar') @@ -50,20 +46,12 @@ tasks.register('paper2612Jar') { dependsOn tasks.named('jar') } -tasks.register('legacyJar', Copy) { - description = 'Copies the deployed legacy jar into the requested 2026 legacy filename.' - group = 'build' - from(legacyServerJar) - into(layout.buildDirectory.dir('libs')) - rename { legacyArchiveName } -} - tasks.register('buildAllJars') { - description = 'Builds the legacy reference jar plus the modern Paper 26.1.2 target jar.' + description = 'Builds the current Paper 26.1.2 target jar.' group = 'build' - dependsOn tasks.named('legacyJar'), tasks.named('jar') + dependsOn tasks.named('jar') } tasks.named('assemble') { - dependsOn tasks.named('legacyJar'), tasks.named('paper2612Jar') + dependsOn tasks.named('paper2612Jar') } diff --git a/src/main/java/ru/meloncode/xmas/XMasCommand.java b/src/main/java/ru/meloncode/xmas/XMasCommand.java index 05dd0cd..1d6973a 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -40,8 +40,8 @@ public class XMasCommand implements CommandExecutor, TabCompleter { "core.particles-enabled", "xmas.luck.enabled" )); + private static final Map DEBUG_SECTIONS = createDebugSections(); private static final Map PERMISSIONS = createPermissionDescriptions(); - private static final int DEBUG_PAGE_SIZE = 8; private static XMasCommand registeredExecutor; private static PluginCommand legacyAliasCommand; @@ -190,7 +190,11 @@ public List onTabComplete(CommandSender sender, Command command, String } } else if (args[0].equalsIgnoreCase("debug")) { if (args.length == 2) { - suggestions.addAll(filterStartingWith(Arrays.asList("1", "2", "3", "toggle"), args[1])); + List debugSuggestions = new ArrayList<>(DEBUG_SECTIONS.keySet()); + if (hasPermission(sender, PERMISSION_DEBUG_TOGGLE)) { + debugSuggestions.add("toggle"); + } + suggestions.addAll(filterStartingWith(debugSuggestions, args[1])); } else if (args.length == 3 && args[1].equalsIgnoreCase("toggle")) { suggestions.addAll(filterStartingWith(new ArrayList<>(DEBUG_TOGGLE_KEYS), args[2])); } else if (args.length == 4 && args[1].equalsIgnoreCase("toggle")) { @@ -216,17 +220,20 @@ private List getStatusLines() { SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy kk-mm-ss"); List lines = new ArrayList<>(); - lines.add("" + TextUtils.DISPLAY_NAME + " " + plugin.getDescription().getVersion() + " Plugin Status"); + lines.add("" + TextUtils.DISPLAY_NAME + " " + plugin.getDescription().getVersion() + " Plugin Status"); lines.add(""); - lines.add("Event Status: " + (Main.inProgress ? "In Progress" : "Holidays End")); + lines.add(formatKeyValue("Event Status", Main.inProgress ? "In Progress" : "Holidays End")); if (Main.inProgress) { - lines.add("Current Time: " + sdf.format(System.currentTimeMillis())); - lines.add("Holidays end: " + sdf.format(Main.endTime)); + lines.add(formatKeyValue("Current Time", "" + sdf.format(System.currentTimeMillis()) + "")); + lines.add(formatKeyValue("Holidays End", "" + sdf.format(Main.endTime) + "")); } - lines.add("Auto-End: " + (Main.autoEnd ? "Yes" : "No") + " | Resource Back: " + (Main.resourceBack ? "Yes" : "No") + " | Particles: " + (Main.particlesEnabled ? "Yes" : "No")); + lines.add(formatKeyValue("Auto-End", booleanValue(Main.autoEnd))); + lines.add(formatKeyValue("Resource Back", booleanValue(Main.resourceBack))); + lines.add(formatKeyValue("Particles", booleanValue(Main.particlesEnabled))); lines.add(""); - lines.add("There are " + treeCount + " magic trees owned by " + owners.size() + " players"); - lines.add("Use " + commandPath("help") + " for command list"); + lines.add(formatKeyValue("Loaded Trees", "" + treeCount + "")); + lines.add(formatKeyValue("Tree Owners", "" + owners.size() + "")); + lines.add(formatKeyValue("Help", "" + commandPath("help") + "")); return lines; } @@ -245,32 +252,42 @@ private void handleDebug(CommandSender sender, String[] args) { return; } - int page = 1; - if (args.length >= 2) { - try { - page = Integer.parseInt(args[1]); - } catch (NumberFormatException ignored) { - page = 1; - } + if (args.length < 2) { + sendDebugSection(sender, "status"); + return; } - sendDebugPage(sender, page); + + String requested = args[1]; + try { + int page = Integer.parseInt(requested); + sendDebugPage(sender, page); + return; + } catch (NumberFormatException ignored) { + } + + String section = normalizeDebugSection(requested); + if (section == null) { + sendInvalidDebugSelection(sender, requested, DEBUG_SECTIONS.size()); + return; + } + sendDebugSection(sender, section); } private void handleDebugToggle(CommandSender sender, String[] args) { if (args.length < 4) { - TextUtils.sendRawMessage(sender, "Usage: " + commandPath("debug toggle true|false")); - TextUtils.sendRawMessage(sender, "Keys: " + String.join(", ", DEBUG_TOGGLE_KEYS)); + TextUtils.sendRawMessage(sender, formatKeyValue("Usage", "" + commandPath("debug toggle true|false") + "")); + TextUtils.sendRawMessage(sender, formatKeyValue("Keys", "" + String.join(", ", DEBUG_TOGGLE_KEYS) + "")); return; } String key = args[2].toLowerCase(Locale.ENGLISH); if (!DEBUG_TOGGLE_KEYS.contains(key)) { - TextUtils.sendRawMessage(sender, "Unknown toggle key: " + args[2]); - TextUtils.sendRawMessage(sender, "Keys: " + String.join(", ", DEBUG_TOGGLE_KEYS)); + TextUtils.sendRawMessage(sender, formatKeyValue("Unknown Toggle Key", "" + args[2] + "")); + TextUtils.sendRawMessage(sender, formatKeyValue("Keys", "" + String.join(", ", DEBUG_TOGGLE_KEYS) + "")); return; } if (!args[3].equalsIgnoreCase("true") && !args[3].equalsIgnoreCase("false")) { - TextUtils.sendRawMessage(sender, "Value must be true or false."); + TextUtils.sendRawMessage(sender, formatKeyValue("Value", "must be true or false")); return; } @@ -278,60 +295,119 @@ private void handleDebugToggle(CommandSender sender, String[] args) { plugin.getConfig().set(key, value); plugin.saveConfig(); plugin.reloadPluginConfig(); - TextUtils.sendRawMessage(sender, "Set " + key + " to " + value + " and reloaded " + TextUtils.DISPLAY_NAME + "."); + TextUtils.sendRawMessage(sender, formatKeyValue("Updated", "" + key + " -> " + booleanValue(value))); } - private void sendDebugPage(CommandSender sender, int requestedPage) { - List lines = new ArrayList<>(getStatusLines()); - lines.add(""); - lines.add("Commands"); - lines.add("" + commandPath("") + " - status"); - lines.add("" + commandPath("help") + " - command list"); - lines.add("" + commandPath("give ") + " - give a Christmas Crystal"); - lines.add("" + commandPath("gifts") + " - spawn presents under all trees"); - lines.add("" + commandPath("addhand") + " - add held item to gifts"); - lines.add("" + commandPath("reload") + " - reload config and locale"); - lines.add("" + commandPath("end") + " - end the event"); - lines.add("" + commandPath("debug [page]") + " - extended debug output"); - lines.add("" + commandPath("debug toggle true|false") + " - toggle global booleans"); + private LinkedHashMap> buildDebugSections() { + LinkedHashMap> sections = new LinkedHashMap<>(); + sections.put("status", getStatusLines()); + + List commandsPage = new ArrayList<>(); + commandsPage.add(""); + commandsPage.add(formatSectionTitle("Commands")); + commandsPage.add(formatListEntry(commandPath(""), "status")); + commandsPage.add(formatListEntry(commandPath("help"), "command list")); + commandsPage.add(formatListEntry(commandPath("give "), "give a Christmas Crystal")); + commandsPage.add(formatListEntry(commandPath("gifts"), "spawn presents under all trees")); + commandsPage.add(formatListEntry(commandPath("addhand"), "add held item to gifts")); + commandsPage.add(formatListEntry(commandPath("reload"), "reload config and locale")); + commandsPage.add(formatListEntry(commandPath("end"), "end the event")); + commandsPage.add(formatListEntry(commandPath("debug"), "open the status debug section")); + commandsPage.add(formatListEntry(commandPath("debug [section|page]"), "extended debug output by category")); + commandsPage.add(formatListEntry(commandPath("debug toggle true|false"), "toggle global booleans")); if (isLegacyAliasEnabled()) { - lines.add("Legacy alias enabled: /" + LEGACY_COMMAND); + commandsPage.add(formatKeyValue("Legacy Alias", "/" + LEGACY_COMMAND + "")); } - lines.add(""); - lines.add("Permissions"); + sections.put("commands", commandsPage); + + List permissionsPage = new ArrayList<>(); + permissionsPage.add(""); + permissionsPage.add(formatSectionTitle("Permissions")); for (Map.Entry permission : PERMISSIONS.entrySet()) { - lines.add("" + permission.getKey() + " - " + permission.getValue()); + permissionsPage.add(formatListEntry(permission.getKey(), permission.getValue())); } - lines.add(""); - lines.add("Placeholders"); - lines.add("Requires PlaceholderAPI. Use '_' after prefix, then dotted keys."); + sections.put("permissions", permissionsPage); + + List placeholdersPage = new ArrayList<>(); + placeholdersPage.add(""); + placeholdersPage.add(formatSectionTitle("Placeholders")); + placeholdersPage.add(formatKeyValue("Notes", "Requires PlaceholderAPI. Use '_' after prefix, then dotted keys.")); for (String placeholder : XMasPlaceholders.EXAMPLES) { - lines.add("" + placeholder); + placeholdersPage.add(formatListEntry(placeholder, XMasPlaceholders.DESCRIPTIONS.getOrDefault(placeholder, "registered placeholder"))); } - lines.add(""); - lines.add("Toggleable Config Keys"); + sections.put("placeholders", placeholdersPage); + + List togglesPage = new ArrayList<>(); + togglesPage.add(""); + togglesPage.add(formatSectionTitle("Toggleable Config Keys")); for (String key : DEBUG_TOGGLE_KEYS) { - lines.add("" + key + " = " + plugin.getConfig().getBoolean(key)); + togglesPage.add(formatKeyValue(key, booleanValue(plugin.getConfig().getBoolean(key)))); } + sections.put("config", togglesPage); - int pages = Math.max(1, (int) Math.ceil((double) lines.size() / DEBUG_PAGE_SIZE)); - int page = Math.max(1, Math.min(requestedPage, pages)); - int start = (page - 1) * DEBUG_PAGE_SIZE; - int end = Math.min(start + DEBUG_PAGE_SIZE, lines.size()); + return sections; + } + + private String normalizeDebugSection(String requested) { + if (requested == null || requested.isBlank()) { + return "status"; + } + return switch (requested.toLowerCase(Locale.ENGLISH)) { + case "status" -> "status"; + case "commands", "command" -> "commands"; + case "permissions", "permission", "perms", "perm" -> "permissions"; + case "placeholders", "placeholder", "papi" -> "placeholders"; + case "config", "configuration", "cfg", "toggles" -> "config"; + default -> null; + }; + } - TextUtils.sendRawMessage(sender, "" + TextUtils.DISPLAY_NAME + " Debug page " + page + "/" + pages); - for (int i = start; i < end; i++) { - TextUtils.sendRawMessage(sender, lines.get(i)); + private void sendDebugSection(CommandSender sender, String sectionKey) { + LinkedHashMap> sections = buildDebugSections(); + if (!sections.containsKey(sectionKey)) { + sendInvalidDebugSelection(sender, sectionKey, sections.size()); + return; } - if (page < pages) { - TextUtils.sendRawMessage(sender, "Next: " + commandPath("debug " + (page + 1))); + renderDebugSection(sender, sectionKey, sections); + } + + private void sendDebugPage(CommandSender sender, int requestedPage) { + LinkedHashMap> sections = buildDebugSections(); + List sectionKeys = new ArrayList<>(sections.keySet()); + if (requestedPage < 1 || requestedPage > sectionKeys.size()) { + sendInvalidDebugSelection(sender, Integer.toString(requestedPage), sectionKeys.size()); + return; + } + renderDebugSection(sender, sectionKeys.get(requestedPage - 1), sections); + } + + private void renderDebugSection(CommandSender sender, String sectionKey, LinkedHashMap> sections) { + List sectionKeys = new ArrayList<>(sections.keySet()); + int sectionIndex = sectionKeys.indexOf(sectionKey); + int page = sectionIndex + 1; + int pageCount = sectionKeys.size(); + + TextUtils.sendRawMessage(sender, "" + TextUtils.DISPLAY_NAME + " Debug " + DEBUG_SECTIONS.getOrDefault(sectionKey, sectionKey) + " (" + page + "/" + pageCount + ")"); + for (String line : sections.get(sectionKey)) { + TextUtils.sendRawMessage(sender, line); + } + if (page < pageCount) { + String nextSection = sectionKeys.get(sectionIndex + 1); + TextUtils.sendRawMessage(sender, formatKeyValue("Next", "" + commandPath("debug " + nextSection) + "")); } } + private void sendInvalidDebugSelection(CommandSender sender, String requested, int pageCount) { + TextUtils.sendRawMessage(sender, formatKeyValue("Debug Sections", "" + String.join(", ", DEBUG_SECTIONS.keySet()) + "")); + TextUtils.sendRawMessage(sender, formatKeyValue("Debug Pages", "1-" + pageCount + "")); + TextUtils.sendRawMessage(sender, formatKeyValue("Requested", "" + requested + "")); + TextUtils.sendRawMessage(sender, formatKeyValue("Try", "" + commandPath("debug status") + "")); + } + private List getHelpLines() { List lines = new ArrayList<>(); for (String line : LocaleManager.COMMAND_HELP) { - lines.add(line.replace("/xmas", "/" + PRIMARY_COMMAND)); + lines.add(line.replaceAll("/" + LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND)); } if (isLegacyAliasEnabled()) { lines.add("Legacy alias: /" + LEGACY_COMMAND + " still works."); @@ -387,7 +463,7 @@ private static void syncLegacyAlias(Main plugin, XMasCommand executor) { return; } aliasCommand.setDescription("Legacy alias for /" + PRIMARY_COMMAND); - aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|debug|end]"); + aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|debug [section|page]|end]"); aliasCommand.setPermission(null); aliasCommand.setExecutor(executor); aliasCommand.setTabCompleter(executor); @@ -413,13 +489,15 @@ private static void unregisterLegacyAlias(CommandMap commandMap) { knownCommandsField.setAccessible(true); Object rawKnownCommands = knownCommandsField.get(simpleCommandMap); if (rawKnownCommands instanceof Map rawMap) { - Iterator> iterator = rawMap.entrySet().iterator(); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); + List keysToRemove = new ArrayList<>(); + for (Map.Entry entry : rawMap.entrySet()) { if (entry.getValue() == legacyAliasCommand) { - iterator.remove(); + keysToRemove.add(entry.getKey()); } } + for (Object key : keysToRemove) { + removeKnownCommand(rawMap, key); + } } } catch (ReflectiveOperationException ignored) { } @@ -427,6 +505,11 @@ private static void unregisterLegacyAlias(CommandMap commandMap) { legacyAliasCommand = null; } + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void removeKnownCommand(Map rawMap, Object key) { + ((Map) rawMap).remove(key); + } + private static CommandMap getCommandMap() { try { Field commandMapField = Bukkit.getServer().getClass().getDeclaredField("commandMap"); @@ -483,6 +566,22 @@ private void sendNoPermission(CommandSender sender) { TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); } + private String formatSectionTitle(String title) { + return "" + title + ""; + } + + private String formatListEntry(String key, String value) { + return "" + key + " : " + value + ""; + } + + private String formatKeyValue(String key, String value) { + return "" + key + ": " + value; + } + + private String booleanValue(boolean value) { + return value ? "true" : "false"; + } + private static Map createPermissionDescriptions() { Map permissions = new LinkedHashMap<>(); permissions.put(PERMISSION_ADMIN, "allows all " + TextUtils.DISPLAY_NAME + " commands and overrides"); @@ -499,4 +598,14 @@ private static Map createPermissionDescriptions() { return permissions; } + private static Map createDebugSections() { + Map sections = new LinkedHashMap<>(); + sections.put("status", "Status"); + sections.put("commands", "Commands"); + sections.put("permissions", "Permissions"); + sections.put("placeholders", "Placeholders"); + sections.put("config", "Config"); + return sections; + } + } diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java index e2cafbc..c38a46c 100644 --- a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java @@ -6,9 +6,11 @@ import java.util.Arrays; import java.util.Collection; import java.util.Date; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; import java.util.UUID; @@ -33,6 +35,7 @@ final class XMasPlaceholders { "%onembxmastree_player.trees%", "%onembxmastree_version%" ); + public static final Map DESCRIPTIONS = createDescriptions(); private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd-MM-yyyy HH-mm-ss"); private XMasPlaceholders() { @@ -124,4 +127,26 @@ private static int countPlayerTrees(OfflinePlayer player) { } return count; } + + private static Map createDescriptions() { + Map descriptions = new LinkedHashMap<>(); + descriptions.put("%onembxmastree_event.active%", "whether the event is currently active"); + descriptions.put("%onembxmastree_event.active_text%", "human-readable active state"); + descriptions.put("%onembxmastree_event.status%", "current event status text"); + descriptions.put("%onembxmastree_event.starts_at%", "event start mode"); + descriptions.put("%onembxmastree_event.ends_at%", "configured event end date"); + descriptions.put("%onembxmastree_event.ends_in%", "time remaining until the event ends"); + descriptions.put("%onembxmastree_event.ends_timestamp%", "event end timestamp in milliseconds"); + descriptions.put("%onembxmastree_event.auto_end%", "whether automatic ending is enabled"); + descriptions.put("%onembxmastree_resource.back%", "whether resource refunds are enabled"); + descriptions.put("%onembxmastree_resource.back_text%", "human-readable refund state"); + descriptions.put("%onembxmastree_particles.enabled%", "whether XMas Tree particles are enabled"); + descriptions.put("%onembxmastree_luck.enabled%", "whether gift luck chance is enabled"); + descriptions.put("%onembxmastree_luck.chance%", "gift luck chance as a percent"); + descriptions.put("%onembxmastree_trees.total%", "total loaded tree count"); + descriptions.put("%onembxmastree_trees.owners%", "number of unique tree owners"); + descriptions.put("%onembxmastree_player.trees%", "loaded trees owned by the placeholder player"); + descriptions.put("%onembxmastree_version%", "loaded plugin version"); + return descriptions; + } } diff --git a/src/main/resources/locales/default.yml b/src/main/resources/locales/default.yml index 8112fbb..dcd933d 100644 --- a/src/main/resources/locales/default.yml +++ b/src/main/resources/locales/default.yml @@ -50,11 +50,14 @@ crystal: command: help: - 'Use /xmastree to show plugin version and status' - - 'Use /xmastree give player to give a player a Christmas Crystal' + - 'Use /xmastree help to show this command list' + - 'Use /xmastree give <player> to give a player a Christmas Crystal' - 'Use /xmastree gifts to spawn presents under all trees' - 'Use /xmastree addhand to add your held item as a gift' - 'Use /xmastree reload to reload the plugin config' - - 'Use /xmastree debug for paginated status, commands, permissions, placeholders, and toggles' + - 'Use /xmastree debug to open the status debug section' + - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, and toggles' + - 'Use /xmastree debug toggle <key> true|false to change supported global toggles' - 'Use /xmastree end to end the event' player-offline: 'Player not found' no-player-name: 'Missing player name' diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml index 17b9664..0368c92 100644 --- a/src/main/resources/locales/en.yml +++ b/src/main/resources/locales/en.yml @@ -41,11 +41,14 @@ help: command: help: - 'Use /xmastree to show plugin version and status' - - 'Use /xmastree give player to give a player a Christmas Crystal' + - 'Use /xmastree help to show this command list' + - 'Use /xmastree give <player> to give a player a Christmas Crystal' - 'Use /xmastree gifts to spawn presents under all trees' - 'Use /xmastree addhand to add your held item as a gift' - 'Use /xmastree reload to reload the plugin config' - - 'Use /xmastree debug for paginated status, commands, permissions, placeholders, and toggles' + - 'Use /xmastree debug to open the status debug section' + - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, and toggles' + - 'Use /xmastree debug toggle <key> true|false to change supported global toggles' - 'Use /xmastree end to end the event' player-offline: 'Player not found' no-player-name: 'Missing player name' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 781a596..f14bfd9 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,7 +7,7 @@ softdepend: [Multiverse-Core, PlotMe, PlotSquared, Plotz, PlaceholderAPI] commands: xmastree: description: Manage the 1MB XMas Tree event - usage: / [help|give|gifts|addhand|reload|debug|end] + usage: / [help|give|gifts|addhand|reload|debug [section|page]|end] permissions: onembxmastree.admin: description: Allows all XMas Tree commands and overrides From 7080616620c15ddc595b88183fcf1b77bb450348 Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Tue, 21 Apr 2026 22:03:01 +0200 Subject: [PATCH 05/10] - v2 changelog --- README.md | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) diff --git a/README.md b/README.md index 60bf4b8..5f1c8a2 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,62 @@ The checked-in source targets Paper 26.1.2 only. - Optional PlaceholderAPI placeholders for CMI holograms, ajLeaderboards, scoreboards, and menus. - Legacy `trees.yml` world-name alias support for renamed destination worlds. +## v2 changelog + +- modernize the plugin from the legacy deployed build to an actively maintained Paper 26.1.2 / Java 25 Gradle build +- simplify the build around the active Paper 26.1.2 target and remove the retired 1.21.11 local server dependency +- keep build output clean and predictable in `build/libs` with the 2026 versioned jar naming + +- keep legacy tree data compatible by continuing to read `plugins/X-Mas/trees.yml` +- add world alias migration support so old saved trees can survive renamed worlds +- preserve old event data while modernizing the runtime and admin tooling + +- make `/xmastree` the primary command +- keep `/xmas` as an optional legacy alias controlled by config +- fix legacy alias reload and unregister behavior so reload no longer crashes when alias settings change +- improve `/xmastree help` output and keep it aligned with the actual command surface + +- add granular permissions under `onembxmastree.*` +- replace the old `xmas.admin` permission with `onembxmastree.admin` +- add separate permissions for `status`, `help`, `give`, `gifts`, `addhand`, `reload`, `debug`, `debug.toggle`, `end`, and `tree.override` + +- add a modern debug system with named categories: `status`, `commands`, `permissions`, `placeholders`, and `config` +- keep numeric debug pages working as a legacy shortcut +- improve debug output formatting with clearer key/value coloring +- make invalid debug page or section requests return a helpful response instead of silently falling back +- add `/xmastree debug toggle true|false` for live boolean config changes +- keep tab completion focused on named debug categories instead of numeric page suggestions + +- add optional PlaceholderAPI support with the `onembxmastree` namespace +- add placeholders for event state, end time, end countdown, auto-end, resource-back, particles, luck, tree totals, owner totals, player tree count, and plugin version +- document placeholders in the README and show them in debug output + +- modernize message handling with MiniMessage support while keeping legacy color compatibility +- improve player-facing text, prefixes, debug output, and help text +- change the visible plugin and chat identity toward `XMas Tree` for clearer user-facing output +- make the Christmas Crystal display name non-italic + +- fix `resource-back` so destroying a tree returns only the materials actually spent on that tree +- fix the old refund dupe issue where the plugin could return more than the player had used +- improve refund delivery with fallback order: chest, barrel, player inventory, then floor drops + +- reduce the loud grow and ingredient sound behavior +- make first-hit and repeat-hit grow sound volumes configurable and reloadable +- support silent, quiet, and loud tuning through config without server restarts + +- modernize material and item handling for current Paper names +- use safer material matching and validation to avoid legacy enum failures +- improve displayed item names so materials such as Redstone Dust render properly in requirement output +- add configurable per-stage particle effects using modern Paper particle names + +- harden config and item parsing by restricting present texture URLs to `textures.minecraft.net` +- cap Base64 gift payload handling +- skip invalid or legacy material names safely + +- update config comments and improve documentation for installation, building, commands, permissions, placeholders, support, and credits +- point support to the GitHub issues page +- refresh `.gitignore` for local dev/test folders and obvious OS/build junk + ## Installation 1. Stop the Paper server. From 20618d0fe8adfeb95cf5db3c8a0e89730493a184 Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Fri, 24 Apr 2026 19:13:19 +0200 Subject: [PATCH 06/10] - clean up deprecated warnings --- .gitignore | 3 + README.md | 32 +++++--- build.gradle | 74 +++++++++++++++---- src/main/java/ru/meloncode/xmas/Events.java | 19 ++--- .../java/ru/meloncode/xmas/ItemMaker.java | 11 +-- .../java/ru/meloncode/xmas/MagicTree.java | 23 +++--- src/main/java/ru/meloncode/xmas/Main.java | 6 ++ src/main/java/ru/meloncode/xmas/XMas.java | 10 +-- .../java/ru/meloncode/xmas/XMasCommand.java | 4 +- .../xmas/XMasPlaceholderExpansion.java | 2 +- .../ru/meloncode/xmas/XMasPlaceholders.java | 2 +- .../ru/meloncode/xmas/utils/ConfigUtils.java | 18 ++++- src/main/resources/plugin.yml | 2 +- todo.log | 8 ++ 14 files changed, 152 insertions(+), 62 deletions(-) create mode 100644 todo.log diff --git a/.gitignore b/.gitignore index 2b1070e..38d9c5f 100644 --- a/.gitignore +++ b/.gitignore @@ -30,3 +30,6 @@ Thumbs.db # Local test servers and generated Paper data servers/ + +# Local release jars for the centralized Paper runner +libs/ diff --git a/README.md b/README.md index 5f1c8a2..8509ec3 100644 --- a/README.md +++ b/README.md @@ -12,9 +12,9 @@ The Gradle build creates the current Paper 26.1.2 target jar in `build/libs`: | Jar | Purpose | | --- | --- | -| `1MB-XMas-2026-v2.0.1-021-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | +| `1MB-XMas-2026-v2.0.1-023-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | -The checked-in source targets Paper 26.1.2 only. +The checked-in source compiles against the centralized Paper 26.1.2 cache and declares a plugin compatibility floor of `api-version: 1.21.11` so the same jar can be tested on Paper 1.21.11 and Paper 26.1.2. ## Features @@ -99,7 +99,7 @@ The checked-in source targets Paper 26.1.2 only. For the 2026 target, use the modern Paper 26.1.2 jar: -- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-021-v25-26.1.2.jar` +- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-023-v25-26.1.2.jar` ## Building @@ -107,8 +107,10 @@ Requirements: - JDK 25 - Gradle -- The current local dev/test setup in this repo uses `servers/Server-Two-Paper-26.1.2` for Paper API jars and local smoke testing -- The current local dev/test setup in this repo uses `servers/Server-Two-Paper-26.1.2/plugins/PlaceholderAPI-2.12.3-DEV-265.jar` for the optional PlaceholderAPI compile-time classpath +- Centralized Paper server cache at `/Users/floris/Projects/Codex/servers/cache/Paper-26.1.2` +- Centralized PlaceholderAPI jar at `/Users/floris/Projects/Codex/servers/cache/Paper-26.1.2/plugins/PlaceholderAPI-2.12.3-DEV-265.jar` + +This repo no longer uses a local `servers/` folder for compilation or testing. If a local `servers/` folder still exists here, it is ignored and treated as retired local data. Build the current Paper 26.1.2 jar: @@ -128,9 +130,21 @@ The `paper2612Jar` task is kept as an alias: gradle paper2612Jar ``` -End users do not need the `servers/` folder. The build output jars are written to `build/libs/`, and those are the files you install on a Paper server. +`buildAllJars` now: + +- compiles against centralized Paper API `26.1.2.build.20-alpha` +- keeps the Java release target at `25` +- writes the standard jar to `build/libs/` +- copies the same jar into `libs/` for the centralized test runner +- prints the active build config, compile target, and declared plugin API floor + +You can also inspect the build metadata directly with: + +```bash +gradle printBuildConfig +``` -In this workspace, the current Gradle setup compiles against the Paper 26.1.2 API jars found in `servers/Server-Two-Paper-26.1.2`. If that folder is missing or has not been started far enough for Paper to download its libraries, Gradle will not have the local Paper API classpath it currently expects. +End users do not need any `servers/` folder. The installable jars are written to `build/libs/`, and this project also keeps a local copy in `libs/` for shared test-runner use. ## Commands @@ -293,7 +307,7 @@ The dotted key after `onembxmastree_` is supported to keep the placeholders read | `%onembxmastree_trees.total%` | `14` | Total loaded X-Mas trees. | | `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | | `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | -| `%onembxmastree_version%` | `2.0.1-021` | Loaded plugin version. | +| `%onembxmastree_version%` | `2.0.1-023` | Loaded plugin version. | CMI hologram example: @@ -319,7 +333,7 @@ ajLeaderboards placeholder examples: - When saved world names no longer match the current server world names, `migration.world-aliases` can remap them without rewriting `trees.yml`. - Existing present head player-name entries are still accepted, but new configs should prefer Mojang texture URLs. - The modern jars are compiled with Java 25 bytecode and should be run on Java 25. -- The Paper 26.1.2 jar is the intended winter 2026 target. Paper 1.21.11 compatibility is no longer part of the active test path. +- The Paper 26.1.2 jar is the intended winter 2026 target, and the same jar now declares `api-version: 1.21.11` so it can be smoke-tested on both Paper 1.21.11 and Paper 26.1.2. ## Security notes diff --git a/build.gradle b/build.gradle index e41b8e4..68714ea 100644 --- a/build.gradle +++ b/build.gradle @@ -3,36 +3,55 @@ plugins { } group = 'com.onemb.xmas' -version = '2.0.1-021' -def paper2612ArchiveName = '1MB-XMas-2026-v2.0.1-021-v25-26.1.2.jar' +version = '2.0.1-023' +def projectVersion = version.toString() -def serverTwo = layout.projectDirectory.dir('servers/Server-Two-Paper-26.1.2') -def placeholderApiJar = serverTwo.file('plugins/PlaceholderAPI-2.12.3-DEV-265.jar') +def javaRelease = 25 +def paperCompileVersion = '26.1.2.build.20-alpha' +def paperCompatibilityFloor = '1.21.11' +def sharedServersRoot = file(System.getenv('CODEX_SHARED_SERVERS_ROOT') ?: '/Users/floris/Projects/Codex/servers') +def sharedPaperCache = new File(sharedServersRoot, 'cache/Paper-26.1.2') +def paper2612LibrariesDir = new File(sharedPaperCache, 'libraries') +def paper2612Api = new File(sharedPaperCache, "libraries/io/papermc/paper/paper-api/${paperCompileVersion}/paper-api-${paperCompileVersion}.jar") +def placeholderApiJar = new File(sharedPaperCache, 'plugins/PlaceholderAPI-2.12.3-DEV-265.jar') +def paper2612ArchiveName = "1MB-XMas-2026-v${projectVersion}-v25-26.1.2.jar" -def paper2612Api = serverTwo.file('libraries/io/papermc/paper/paper-api/26.1.2.build.18-alpha/paper-api-26.1.2.build.18-alpha.jar') - -def paper2612Classpath = files(paper2612Api) + fileTree(dir: serverTwo.dir('libraries').asFile, include: '**/*.jar') +[ + [paper2612Api, "Paper API jar"], + [paper2612LibrariesDir, "Paper libraries directory"], + [placeholderApiJar, "PlaceholderAPI jar"], +].each { entry -> + File path = entry[0] as File + String label = entry[1] as String + if (!path.exists()) { + throw new GradleException("${label} not found at ${path}. This project now uses the centralized Paper cache under ${sharedServersRoot}.") + } +} java { toolchain { - languageVersion = JavaLanguageVersion.of(25) + languageVersion = JavaLanguageVersion.of(javaRelease) } } dependencies { - compileOnly paper2612Classpath + compileOnly files(paper2612Api) + compileOnly fileTree(dir: paper2612LibrariesDir, include: '**/*.jar') compileOnly files(placeholderApiJar) } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' - options.release = 25 + options.release = javaRelease } tasks.named('processResources') { filteringCharset = 'UTF-8' filesMatching('plugin.yml') { - expand(version: project.version) + expand( + version: projectVersion, + apiVersionFloor: paperCompatibilityFloor + ) } } @@ -40,16 +59,41 @@ tasks.named('jar') { archiveFileName = paper2612ArchiveName } -tasks.register('paper2612Jar') { - description = 'Assembles the Paper 26.1.2 Java 25 plugin jar.' +tasks.register('releaseJar', Copy) { + description = 'Copies the newest plugin jar into libs/ for the centralized Paper runner.' group = 'build' dependsOn tasks.named('jar') + from(tasks.named('jar')) + into(layout.projectDirectory.dir('libs')) +} + +tasks.register('paper2612Jar') { + description = 'Assembles the Paper 26.1.2 Java 25 plugin jar and copies it into libs/.' + group = 'build' + dependsOn tasks.named('releaseJar') +} + +tasks.register('printBuildConfig') { + description = 'Prints the active centralized build and compatibility configuration.' + group = 'help' + doLast { + println 'XMasTree build config' + println " version: ${projectVersion}" + println " compile target: Paper API ${paperCompileVersion}" + println " plugin api-version floor: ${paperCompatibilityFloor}" + println " java release target: ${javaRelease}" + println " centralized Paper cache: ${sharedPaperCache}" + println " PlaceholderAPI compile jar: ${placeholderApiJar}" + println " build/libs output: ${layout.buildDirectory.file("libs/${paper2612ArchiveName}").get().asFile}" + println " libs/ release copy: ${layout.projectDirectory.file("libs/${paper2612ArchiveName}").asFile}" + } } tasks.register('buildAllJars') { - description = 'Builds the current Paper 26.1.2 target jar.' + description = 'Builds the current Paper 26.1.2 target jar and copies it into libs/.' group = 'build' - dependsOn tasks.named('jar') + dependsOn tasks.named('releaseJar') + finalizedBy tasks.named('printBuildConfig') } tasks.named('assemble') { diff --git a/src/main/java/ru/meloncode/xmas/Events.java b/src/main/java/ru/meloncode/xmas/Events.java index 604ca7d..730b32d 100644 --- a/src/main/java/ru/meloncode/xmas/Events.java +++ b/src/main/java/ru/meloncode/xmas/Events.java @@ -4,6 +4,7 @@ import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.entity.EntityType; +import org.bukkit.entity.Firework; import org.bukkit.entity.Player; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -311,10 +312,10 @@ public void disableDecay(LeavesDecayEvent e) @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) private void disableFireworkDamage(EntityDamageByEntityEvent e) { - if (e.getDamager().getType() == EntityType.FIREWORK_ROCKET) { - if (e.getDamager().hasMetadata("nodamage")) { - e.setCancelled(true); - } + if (e.getDamager().getType() == EntityType.FIREWORK_ROCKET + && e.getDamager() instanceof Firework firework + && firework.getPersistentDataContainer().has(Main.getNoDamageFireworkKey(), PersistentDataType.BYTE)) { + e.setCancelled(true); } } @@ -332,11 +333,11 @@ private void chunkLoad(ChunkLoadEvent e) } private String getHeadIdentifier(SkullMeta meta) { - if (meta.getOwnerProfile() != null - && meta.getOwnerProfile().getTextures() != null - && meta.getOwnerProfile().getTextures().getSkin() != null) { - return meta.getOwnerProfile().getTextures().getSkin().toString(); + if (meta.getPlayerProfile() != null + && meta.getPlayerProfile().getTextures() != null + && meta.getPlayerProfile().getTextures().getSkin() != null) { + return meta.getPlayerProfile().getTextures().getSkin().toString(); } - return meta.getOwner(); + return meta.getOwningPlayer() != null ? meta.getOwningPlayer().getName() : null; } } diff --git a/src/main/java/ru/meloncode/xmas/ItemMaker.java b/src/main/java/ru/meloncode/xmas/ItemMaker.java index eabe9c8..ea75c15 100644 --- a/src/main/java/ru/meloncode/xmas/ItemMaker.java +++ b/src/main/java/ru/meloncode/xmas/ItemMaker.java @@ -2,6 +2,7 @@ //I plan to make this plugin bigger. So... +import net.kyori.adventure.text.Component; import org.bukkit.Material; import org.bukkit.enchantments.Enchantment; import org.bukkit.inventory.ItemStack; @@ -77,14 +78,14 @@ public ItemMaker setLore(List lore) { } public ItemMaker addLoreLine(String line) { - List lore; - if (im.getLore() != null) { - lore = im.getLore(); + List lore; + if (im.lore() != null) { + lore = new ArrayList<>(im.lore()); } else { lore = new ArrayList<>(); } - lore.add(line); - im.lore(TextUtils.parseList(lore)); + lore.add(TextUtils.parse(line)); + im.lore(lore); return this; } diff --git a/src/main/java/ru/meloncode/xmas/MagicTree.java b/src/main/java/ru/meloncode/xmas/MagicTree.java index 4dc8f10..4e07950 100644 --- a/src/main/java/ru/meloncode/xmas/MagicTree.java +++ b/src/main/java/ru/meloncode/xmas/MagicTree.java @@ -1,5 +1,6 @@ package ru.meloncode.xmas; +import com.destroystokyo.paper.profile.PlayerProfile; import org.bukkit.*; import org.bukkit.FireworkEffect.Type; import org.bukkit.block.*; @@ -9,13 +10,13 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.FireworkMeta; -import org.bukkit.metadata.FixedMetadataValue; -import org.bukkit.profile.PlayerProfile; import org.bukkit.profile.PlayerTextures; +import org.bukkit.persistence.PersistentDataType; import ru.meloncode.xmas.utils.TextUtils; import org.bukkit.util.Vector; import java.net.MalformedURLException; +import java.net.URI; import java.net.URL; import java.util.*; import java.util.Map.Entry; @@ -175,7 +176,7 @@ private void levelUp() { FireworkMeta meta = fw.getFireworkMeta(); meta.addEffect(FireworkEffect.builder().trail(true).withColor(Color.RED).withFade(Color.LIME).withFlicker().with(Type.BURST).build()); fw.setFireworkMeta(meta); - fw.setMetadata("nodamage", new FixedMetadataValue(Main.getInstance(), true)); + fw.getPersistentDataContainer().set(Main.getNoDamageFireworkKey(), PersistentDataType.BYTE, (byte) 1); } build(); save(); @@ -209,7 +210,6 @@ public void build() { } } - @SuppressWarnings("deprecation") public void spawnPresent() { if (!location.getChunk().isLoaded()) { @@ -232,13 +232,10 @@ public void spawnPresent() { face = BlockFace.values()[Main.RANDOM.nextInt(BlockFace.values().length)]; } while (face == BlockFace.DOWN || face == BlockFace.UP || face == BlockFace.SELF); - //skull.setRotation(face); Rotatable skullRotatable = (Rotatable) skull.getBlockData(); skullRotatable.setRotation(face); - skull.setRotation(face); - //skull.setSkullType(SkullType.PLAYER); + skull.setBlockData(skullRotatable); skull.setType(Material.PLAYER_HEAD); - //skull.setOwner(); applyConfiguredHead(skull, Main.getHeads().get(Main.RANDOM.nextInt(Main.getHeads().size()))); skull.update(true); } @@ -251,21 +248,21 @@ private void applyConfiguredHead(Skull skull, String configuredHead) { } String trimmedHead = configuredHead.trim(); if (!trimmedHead.contains("://")) { - skull.setOwningPlayer(Bukkit.getOfflinePlayer(trimmedHead)); + skull.setPlayerProfile(Bukkit.createProfile(trimmedHead)); return; } try { - URL skinUrl = new URL(trimmedHead); + URL skinUrl = URI.create(trimmedHead).toURL(); if (!"textures.minecraft.net".equalsIgnoreCase(skinUrl.getHost())) { Bukkit.getLogger().warning("[X-Mas] Ignoring non-Mojang present skin URL: " + trimmedHead); return; } - PlayerProfile profile = Bukkit.createPlayerProfile(UUID.randomUUID()); + PlayerProfile profile = Bukkit.createProfile(UUID.randomUUID()); PlayerTextures textures = profile.getTextures(); textures.setSkin(skinUrl); profile.setTextures(textures); - skull.setOwnerProfile(profile); - } catch (MalformedURLException e) { + skull.setPlayerProfile(profile); + } catch (IllegalArgumentException | MalformedURLException e) { Bukkit.getLogger().warning("[X-Mas] Invalid present skin URL: " + trimmedHead); } } diff --git a/src/main/java/ru/meloncode/xmas/Main.java b/src/main/java/ru/meloncode/xmas/Main.java index ca3dab7..a2f9c18 100644 --- a/src/main/java/ru/meloncode/xmas/Main.java +++ b/src/main/java/ru/meloncode/xmas/Main.java @@ -42,6 +42,7 @@ public class Main extends JavaPlugin implements Listener { private static int UPDATE_SPEED; private static int PARTICLES_DELAY; private static NamespacedKey crystalKey; + private static NamespacedKey noDamageFireworkKey; private static List heads; private static Plugin plugin; private static final int MAX_SERIALIZED_GIFT_LENGTH = 65536; @@ -61,6 +62,10 @@ public static NamespacedKey getCrystalKey() { return crystalKey; } + public static NamespacedKey getNoDamageFireworkKey() { + return noDamageFireworkKey; + } + @Override public void onLoad() { plugin = this; @@ -70,6 +75,7 @@ public void onLoad() { public void onEnable() { this.saveDefaults(); crystalKey = new NamespacedKey(this, "xmas_crystal"); + noDamageFireworkKey = new NamespacedKey(this, "no_damage_firework"); config = getConfig(); locale = config.getString("core.locale"); diff --git a/src/main/java/ru/meloncode/xmas/XMas.java b/src/main/java/ru/meloncode/xmas/XMas.java index 507d79c..3c4d7e7 100644 --- a/src/main/java/ru/meloncode/xmas/XMas.java +++ b/src/main/java/ru/meloncode/xmas/XMas.java @@ -93,12 +93,12 @@ public static void processPresent(Block block, Player player) { } static String getHeadIdentifier(Skull skull) { - if (skull.getOwnerProfile() != null - && skull.getOwnerProfile().getTextures() != null - && skull.getOwnerProfile().getTextures().getSkin() != null) { - return skull.getOwnerProfile().getTextures().getSkin().toString(); + if (skull.getPlayerProfile() != null + && skull.getPlayerProfile().getTextures() != null + && skull.getPlayerProfile().getTextures().getSkin() != null) { + return skull.getPlayerProfile().getTextures().getSkin().toString(); } - return skull.getOwner(); + return skull.getOwningPlayer() != null ? skull.getOwningPlayer().getName() : null; } public static List getTreesPlayerOwn(Player player) { diff --git a/src/main/java/ru/meloncode/xmas/XMasCommand.java b/src/main/java/ru/meloncode/xmas/XMasCommand.java index 1d6973a..821a0d1 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -220,7 +220,7 @@ private List getStatusLines() { SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy kk-mm-ss"); List lines = new ArrayList<>(); - lines.add("" + TextUtils.DISPLAY_NAME + " " + plugin.getDescription().getVersion() + " Plugin Status"); + lines.add("" + TextUtils.DISPLAY_NAME + " " + plugin.getPluginMeta().getVersion() + " Plugin Status"); lines.add(""); lines.add(formatKeyValue("Event Status", Main.inProgress ? "In Progress" : "Holidays End")); if (Main.inProgress) { @@ -467,7 +467,7 @@ private static void syncLegacyAlias(Main plugin, XMasCommand executor) { aliasCommand.setPermission(null); aliasCommand.setExecutor(executor); aliasCommand.setTabCompleter(executor); - commandMap.register(plugin.getDescription().getName().toLowerCase(Locale.ENGLISH), aliasCommand); + commandMap.register(plugin.getPluginMeta().getName().toLowerCase(Locale.ENGLISH), aliasCommand); legacyAliasCommand = aliasCommand; plugin.getLogger().info("Registered legacy alias '/" + LEGACY_COMMAND + "' for '/" + PRIMARY_COMMAND + "'."); } diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java index f9f26f5..c3d726f 100644 --- a/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java @@ -24,7 +24,7 @@ public String getAuthor() { @Override public String getVersion() { - return plugin.getDescription().getVersion(); + return plugin.getPluginMeta().getVersion(); } @Override diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java index c38a46c..c46c4b8 100644 --- a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java @@ -63,7 +63,7 @@ public static String resolve(Main plugin, OfflinePlayer player, String params) { case "trees_total" -> Integer.toString(XMas.getAllTrees().size()); case "trees_owners" -> Integer.toString(countOwners(XMas.getAllTrees())); case "player_trees" -> Integer.toString(countPlayerTrees(player)); - case "version" -> plugin.getDescription().getVersion(); + case "version" -> plugin.getPluginMeta().getVersion(); default -> null; }; } diff --git a/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java index 552986b..8358dcd 100644 --- a/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java @@ -1,14 +1,30 @@ package ru.meloncode.xmas.utils; +import org.bukkit.Bukkit; +import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.logging.Level; public class ConfigUtils { public static FileConfiguration loadConfig(File file) { - return YamlConfiguration.loadConfiguration(file); + YamlConfiguration configuration = new YamlConfiguration(); + if (!file.exists()) { + return configuration; + } + + try { + configuration.loadFromString(Files.readString(file.toPath(), StandardCharsets.UTF_8)); + } catch (IOException | InvalidConfigurationException exception) { + Bukkit.getLogger().log(Level.WARNING, "Failed to load YAML configuration from " + file.getPath(), exception); + } + return configuration; } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f14bfd9..7149a5f 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -2,7 +2,7 @@ name: X-Mas version: ${version} main: ru.meloncode.xmas.Main load: POSTWORLD -api-version: '1.21' +api-version: '${apiVersionFloor}' softdepend: [Multiverse-Core, PlotMe, PlotSquared, Plotz, PlaceholderAPI] commands: xmastree: diff --git a/todo.log b/todo.log new file mode 100644 index 0000000..96abf8d --- /dev/null +++ b/todo.log @@ -0,0 +1,8 @@ +Remaining warnings and deferred deprecation cleanup +================================================= + +- `compileJava` still prints the generic note that some source uses deprecated API. +- The remaining known spots are the block-skull profile accessors and setters in: + - `src/main/java/ru/meloncode/xmas/MagicTree.java` + - `src/main/java/ru/meloncode/xmas/XMas.java` +- These were left in place on purpose because the newer block-skull `ResolvableProfile` route is not a clearly low-risk drop-in if we want to preserve the current texture URL and player-name behavior on both Paper versions. From b7315a0818f41eff23916f1ba6d13da594170a74 Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Tue, 28 Apr 2026 10:28:25 +0200 Subject: [PATCH 07/10] Improve config.yml handling and preserve comments on reload/save - add a shared managed config flow for config.yml using YamlConfiguration comment parsing - preserve admin values and existing comments while safely adding missing defaults and missing template comments - route startup, reload, and command-driven config saves through the same helper - expand config.yml comments so every setting documents defaults, valid values, format, and reload behavior - document config sync behavior in the README - bump build to 2.0.1-024 --- README.md | 7 +- build.gradle | 2 +- src/main/java/ru/meloncode/xmas/Main.java | 30 +- .../ru/meloncode/xmas/utils/ConfigUtils.java | 114 ++++- src/main/resources/config.yml | 475 ++++++++++++++++-- 5 files changed, 577 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 8509ec3..6b9d1f0 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The Gradle build creates the current Paper 26.1.2 target jar in `build/libs`: | Jar | Purpose | | --- | --- | -| `1MB-XMas-2026-v2.0.1-023-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | +| `1MB-XMas-2026-v2.0.1-024-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | The checked-in source compiles against the centralized Paper 26.1.2 cache and declares a plugin compatibility floor of `api-version: 1.21.11` so the same jar can be tested on Paper 1.21.11 and Paper 26.1.2. @@ -31,6 +31,7 @@ The checked-in source compiles against the centralized Paper 26.1.2 cache and de - Primary `/xmastree` command with an optional legacy `/xmas` alias. - Optional PlaceholderAPI placeholders for CMI holograms, ajLeaderboards, scoreboards, and menus. - Legacy `trees.yml` world-name alias support for renamed destination worlds. +- Comment-preserving `config.yml` syncing that keeps admin values while safely adding missing defaults and missing template comments. ## v2 changelog @@ -99,7 +100,7 @@ The checked-in source compiles against the centralized Paper 26.1.2 cache and de For the 2026 target, use the modern Paper 26.1.2 jar: -- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-023-v25-26.1.2.jar` +- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-024-v25-26.1.2.jar` ## Building @@ -307,7 +308,7 @@ The dotted key after `onembxmastree_` is supported to keep the placeholders read | `%onembxmastree_trees.total%` | `14` | Total loaded X-Mas trees. | | `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | | `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | -| `%onembxmastree_version%` | `2.0.1-023` | Loaded plugin version. | +| `%onembxmastree_version%` | `2.0.1-024` | Loaded plugin version. | CMI hologram example: diff --git a/build.gradle b/build.gradle index 68714ea..8a24e5b 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { } group = 'com.onemb.xmas' -version = '2.0.1-023' +version = '2.0.1-024' def projectVersion = version.toString() def javaRelease = 25 diff --git a/src/main/java/ru/meloncode/xmas/Main.java b/src/main/java/ru/meloncode/xmas/Main.java index a2f9c18..8d94184 100644 --- a/src/main/java/ru/meloncode/xmas/Main.java +++ b/src/main/java/ru/meloncode/xmas/Main.java @@ -16,6 +16,7 @@ import org.bukkit.plugin.Plugin; import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.util.Vector; +import ru.meloncode.xmas.utils.ConfigUtils; import ru.meloncode.xmas.utils.TextUtils; import java.io.File; @@ -25,6 +26,7 @@ import java.util.*; public class Main extends JavaPlugin implements Listener { + private static final String CONFIG_RESOURCE_PATH = "config.yml"; // Yeah. That's as it should be. static final Random RANDOM = new Random(Calendar.getInstance().get(Calendar.YEAR)); @@ -66,6 +68,32 @@ public static NamespacedKey getNoDamageFireworkKey() { return noDamageFireworkKey; } + private File getPluginConfigFile() { + return new File(getDataFolder(), CONFIG_RESOURCE_PATH); + } + + @Override + public FileConfiguration getConfig() { + if (config == null) { + config = ConfigUtils.loadManagedConfig(this, CONFIG_RESOURCE_PATH, getPluginConfigFile()); + } + return config; + } + + @Override + public void reloadConfig() { + config = ConfigUtils.loadManagedConfig(this, CONFIG_RESOURCE_PATH, getPluginConfigFile()); + } + + @Override + public void saveConfig() { + if (config == null) { + return; + } + ConfigUtils.synchronizeWithResource(this, CONFIG_RESOURCE_PATH, config); + ConfigUtils.saveConfig(getPluginConfigFile(), config); + } + @Override public void onLoad() { plugin = this; @@ -346,7 +374,7 @@ public void end() { } private void saveDefaults() { - this.saveDefaultConfig(); + reloadConfig(); plugin.saveResource("locales/default.yml", true); List defaults = Arrays.asList("locales/en.yml", "locales/ru.yml", "locales/ru_santa.yml", "trees.yml"); for (String path : defaults) diff --git a/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java index 8358dcd..9e59551 100644 --- a/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java @@ -4,17 +4,20 @@ import org.bukkit.configuration.InvalidConfigurationException; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; import java.io.File; import java.io.IOException; +import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.util.List; import java.util.logging.Level; public class ConfigUtils { - public static FileConfiguration loadConfig(File file) { - YamlConfiguration configuration = new YamlConfiguration(); + public static YamlConfiguration loadConfig(File file) { + YamlConfiguration configuration = newConfiguration(); if (!file.exists()) { return configuration; } @@ -27,4 +30,111 @@ public static FileConfiguration loadConfig(File file) { return configuration; } + public static YamlConfiguration loadManagedConfig(JavaPlugin plugin, String resourcePath, File file) { + YamlConfiguration configuration = loadConfig(file); + boolean changed = synchronizeWithResource(plugin, resourcePath, configuration); + if (!file.exists() || changed) { + saveConfig(file, configuration); + } + return configuration; + } + + public static boolean synchronizeWithResource(JavaPlugin plugin, String resourcePath, FileConfiguration configuration) { + if (!(configuration instanceof YamlConfiguration yamlConfiguration)) { + return false; + } + YamlConfiguration defaults = loadResourceConfig(plugin, resourcePath); + return mergeDefaultsAndComments(yamlConfiguration, defaults); + } + + public static void saveConfig(File file, FileConfiguration configuration) { + File parent = file.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + Bukkit.getLogger().warning("Unable to create configuration directory " + parent.getPath()); + return; + } + + try { + configuration.save(file); + } catch (IOException exception) { + Bukkit.getLogger().log(Level.WARNING, "Failed to save YAML configuration to " + file.getPath(), exception); + } + } + + private static YamlConfiguration loadResourceConfig(JavaPlugin plugin, String resourcePath) { + YamlConfiguration configuration = newConfiguration(); + try (InputStream inputStream = plugin.getResource(resourcePath)) { + if (inputStream == null) { + plugin.getLogger().warning("Missing bundled configuration resource: " + resourcePath); + return configuration; + } + configuration.loadFromString(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)); + } catch (IOException | InvalidConfigurationException exception) { + plugin.getLogger().log(Level.WARNING, "Failed to load bundled configuration resource " + resourcePath, exception); + } + return configuration; + } + + private static YamlConfiguration newConfiguration() { + YamlConfiguration configuration = new YamlConfiguration(); + configuration.options().parseComments(true); + return configuration; + } + + private static boolean mergeDefaultsAndComments(YamlConfiguration target, YamlConfiguration defaults) { + boolean changed = false; + + if (isBlank(target.options().getHeader()) && !isBlank(defaults.options().getHeader())) { + target.options().setHeader(defaults.options().getHeader()); + changed = true; + } + if (isBlank(target.options().getFooter()) && !isBlank(defaults.options().getFooter())) { + target.options().setFooter(defaults.options().getFooter()); + changed = true; + } + + for (String path : defaults.getKeys(true)) { + if (defaults.isConfigurationSection(path)) { + if (!target.isConfigurationSection(path)) { + target.createSection(path); + changed = true; + } + } else if (!target.contains(path, true)) { + target.set(path, defaults.get(path)); + changed = true; + } + + if (copyMissingComments(target, path, defaults.getComments(path), defaults.getInlineComments(path))) { + changed = true; + } + } + + return changed; + } + + private static boolean copyMissingComments(YamlConfiguration target, String path, List blockComments, List inlineComments) { + boolean changed = false; + if (isBlank(target.getComments(path)) && !isBlank(blockComments)) { + target.setComments(path, blockComments); + changed = true; + } + if (isBlank(target.getInlineComments(path)) && !isBlank(inlineComments)) { + target.setInlineComments(path, inlineComments); + changed = true; + } + return changed; + } + + private static boolean isBlank(List comments) { + if (comments == null || comments.isEmpty()) { + return true; + } + for (String line : comments) { + if (line != null && !line.isBlank()) { + return false; + } + } + return true; + } + } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 31f61bb..1825a4e 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,67 +1,163 @@ +# XMas Tree main configuration +# File: plugins/X-Mas/config.yml +# +# General config behavior: +# - Existing values are preserved when the plugin adds new defaults. +# - /xmastree reload re-reads this file without a full server restart unless noted otherwise below. +# - /xmastree debug toggle true|false can live-toggle a few global booleans and then reload the config. +# - Comments from this bundled template are merged into missing comment slots when the plugin loads the config. + core: - # Master switch for the event. When false, players can no longer create or grow trees. + # Master switch for the XMas Tree event. + # Default: true + # Safe values: true or false + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree debug toggle core.plugin-enabled true|false applies immediately. + # - Turning the event off is immediate. + # - Turning it back on after the plugin has already ended the event is safest with a full server restart. plugin-enabled: true - # Locale file to load from plugins/X-Mas/locales/.yml. + # Locale file name loaded from plugins/X-Mas/locales/.yml. + # Default: en + # Safe values: a locale file name without .yml, for example en, ru, or ru_santa + # Reload behavior: /xmastree reload applies immediately. locale: en - # Maximum number of magic Christmas trees a single player can own. + # Maximum number of XMas trees one player may own at the same time. + # Default: 3 + # Safe values: 1 or higher + # Reload behavior: /xmastree reload applies immediately for future placement checks. tree-limit: 3 commands: - # Primary admin command is /xmastree. - # When true, the legacy /xmas alias is also registered. - # /xmastree reload applies changes without a server restart. + # Registers the legacy /xmas alias alongside the primary /xmastree command. + # Default: true + # Safe values: true or false + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree debug toggle core.commands.legacy-command-enabled true|false applies immediately. legacy-command-enabled: true # Automatic end-of-event behavior. holiday-ends: - # When true, the plugin will switch plugin-enabled to false after the date below. + # Enables time-based event shutdown. + # Default: true + # Safe values: true or false + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree debug toggle core.holiday-ends.enabled true|false applies immediately. enabled: true - # Date/time when the event ends. Format: dd-MM-yyyy HH-mm-ss. + # Date/time when the event should end automatically. + # Default: 10-01-2027 03-33-33 + # Required format: dd-MM-yyyy HH-mm-ss + # Example: 24-12-2026 18-00-00 + # Reload behavior: /xmastree reload applies immediately. date: 10-01-2027 03-33-33 - # When true, destroyed or ended trees return spent upgrade resources. - # The plugin tries a chest first, then a barrel, then the player's inventory, then floor drops. + # Returns spent tree-upgrade materials when a tree is destroyed or cleaned up after the event. + # Default: true + # Safe values: true or false + # Refund order: chest -> barrel -> player inventory -> floor drops + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree debug toggle core.holiday-ends.resource-back true|false applies immediately. resource-back: true - # Main tree update interval in ticks. 20 ticks is roughly 1 second. - # Gift cooldown values are scaled by this value. Must be greater than 0. + # Interval for the main tree update task. + # Default: 7 + # Units: server ticks (20 ticks = about 1 second) + # Safe values: any whole number greater than 0 + # Notes: + # - This value is also used when scaling stage gift cooldowns into task ticks. + # - Smaller values make the plugin check trees more often. + # Reload behavior: + # - /xmastree reload re-reads the value. + # - A full server restart is recommended to recreate the scheduled task with the new cadence. update-speed: 7 - # Particle update interval in ticks. Keep this higher if particles become noisy. + # Interval for the particle task. + # Default: 35 + # Units: server ticks + # Safe values: any whole number greater than 0 + # Notes: + # - Smaller values make particle updates happen more often and can look noisier. + # Reload behavior: + # - /xmastree reload re-reads the value. + # - A full server restart is recommended to recreate the scheduled task with the new cadence. particles-delay: 35 - # Global particle switch. Can be toggled with /xmastree debug toggle core.particles-enabled true|false. + # Global particle master switch for tree visual effects. + # Default: true + # Safe values: true or false + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree debug toggle core.particles-enabled true|false applies immediately. particles-enabled: true sounds: grow: - # Volume for the first accepted item of each material requirement. - # Use 0.0 for silent, 0.1 for quiet, 0.5 for half volume, and 1.0 for full volume. + # Volume for the first accepted item of a material requirement. + # Default: 0.5 + # Safe values: decimal numbers from 0.0 to 1.0 + # Examples: 0.0 = silent, 0.1 = quiet, 0.5 = half volume, 1.0 = full volume + # Reload behavior: /xmastree reload applies immediately. first-volume: 0.5 # Volume for repeated accepted items of the same material requirement. - # Example: first redstone dust uses first-volume, later redstone dust uses repeat-volume. + # Default: 0.2 + # Safe values: decimal numbers from 0.0 to 1.0 + # Example: the first redstone dust uses first-volume, later redstone dust uses repeat-volume + # Reload behavior: /xmastree reload applies immediately. repeat-volume: 0.2 xmas: luck: - # When true, gift openings can fail and drop the fallback item instead. + # Enables the optional luck check for presents. + # Default: false + # Safe values: true or false + # Notes: + # - When disabled, presents always use the main gift pool. + # - When enabled, the configured luck chance decides whether the normal gift is used. + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree debug toggle xmas.luck.enabled true|false applies immediately. enabled: false - # Success chance from 1-100 when luck is enabled. + # Success chance for the present luck system. + # Default: 75 + # Safe values: whole numbers from 1 to 100 + # Notes: + # - The plugin currently reads this as a percentage and divides by 100 internally. + # - Values outside the normal 1-100 range are not recommended. + # Reload behavior: /xmastree reload applies immediately. chance: 75 - # Present head skins. Prefer official MineSkin/Mojang texture URLs. - # For safety, texture URLs must use textures.minecraft.net. Old player-name entries still work. + # Present head skins used for spawned gifts under trees. + # Default: the two textures.minecraft.net URLs below + # Safe values: + # - preferred: textures.minecraft.net URLs + # - legacy compatibility: old player-name entries still load + # Security note: modern URL entries are restricted to textures.minecraft.net + # Reload behavior: /xmastree reload applies immediately for newly spawned presents. presents: - http://textures.minecraft.net/texture/21bc9d42b0041e8f95cb9b26628fdaf50cd0e36f7bb9d6b3a4d2af3949da97d6 - http://textures.minecraft.net/texture/2b1ec7dc753061ca174424ea45cf9490b39cd5dcca477d138a603e6be755ec72 - # Gift list for presents. Use modern Paper/Bukkit material names, optionally MATERIAL:AMOUNT. - # Admins can also run /xmastree addhand to save the exact held item as a Base64 entry. + # Gift pool for presents. + # Default: the material list below + # Safe values: + # - MATERIAL + # - MATERIAL:AMOUNT + # - Base64 item strings saved by /xmastree addhand + # Notes: + # - Use modern Paper/Bukkit material names only. + # - Invalid or legacy material names are skipped safely. + # - /xmastree addhand appends the exact held item and saves it immediately. + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree addhand applies immediately and persists to disk. gifts: - DIAMOND - EMERALD @@ -79,124 +175,415 @@ xmas: - DIAMOND_HOE - NAME_TAG - # Tree levels. gift-cooldown is in seconds before scaling by core.update-speed. - # lvlup sections define the modern material names and counts needed for the next level. - # Particle enum reference for Paper 26.1.2: - # https://jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html - # Configured particles currently support simple particles and DUST. + # Tree stage settings. + # Notes: + # - gift-cooldown values are written in seconds and converted using core.update-speed. + # - lvlup sections use modern material names and item counts for the next stage. + # - Particle names should come from the Paper Particle enum: + # https://jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html + # - Existing loaded trees may keep their current stage objects until the next full restart. + # /xmastree reload is best for new trees and newly loaded trees, while a restart is recommended + # when you want every existing tree to use the refreshed stage settings. tree-lvl: sapling: - # -1 disables present spawning for this level. + # Delay before present spawns at the sapling stage. + # Default: -1 + # Units: seconds before scaling by core.update-speed + # Safe values: -1 to disable present spawning, or 0 and higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. gift-cooldown: -1 - # Visual effects for this stage. Set enabled: false to disable an effect slot. + particles: ambient: + # Enables the ambient particle effect for saplings. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true + # Particle type for the sapling ambient effect. + # Default: SPORE_BLOSSOM_AIR + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. particle: SPORE_BLOSSOM_AIR + # Horizontal X offset for the sapling ambient particle spread. + # Default: 0.35 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-x: 0.35 + # Vertical Y offset for the sapling ambient particle spread. + # Default: 0.45 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-y: 0.45 + # Horizontal Z offset for the sapling ambient particle spread. + # Default: 0.35 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-z: 0.35 + # Particle speed value passed to the Paper particle API. + # Default: 0.01 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 0.01 + # Number of ambient particles spawned per effect tick. + # Default: 2 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. count: 2 swag: + # Enables the ornament-style particle effect for saplings. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: false body: + # Enables the trunk/body particle effect for saplings. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: false - # Materials required to grow from sapling to small_tree. + lvlup: + # Diamonds required to grow from sapling to small_tree. + # Default: 1 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. DIAMOND: 1 + # Redstone dust required to grow from sapling to small_tree. + # Default: 10 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. REDSTONE: 10 + # Ender pearls required to grow from sapling to small_tree. + # Default: 1 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. ENDER_PEARL: 1 + small_tree: - # Seconds between present spawn attempts for small trees. + # Delay before present spawns at the small_tree stage. + # Default: 300 + # Units: seconds before scaling by core.update-speed + # Safe values: 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. gift-cooldown: 300 - # Small trees get a little more ambient motion and light ornament sparkle. + particles: ambient: + # Enables the ambient particle effect for small trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true + # Particle type for the small tree ambient effect. + # Default: CHERRY_LEAVES + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. particle: CHERRY_LEAVES + # Horizontal X offset for the small tree ambient particle spread. + # Default: 1.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-x: 1.0 + # Vertical Y offset for the small tree ambient particle spread. + # Default: 1.5 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-y: 1.5 + # Horizontal Z offset for the small tree ambient particle spread. + # Default: 1.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-z: 1.0 + # Particle speed value passed to the Paper particle API. + # Default: 0.01 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 0.01 + # Number of ambient particles spawned per effect tick. + # Default: 3 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. count: 3 swag: + # Enables the ornament-style particle effect for small trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true + # Particle type for the small tree ornament effect. + # Default: DUST + # Safe values: a valid Particle enum name supported by the plugin's simple particle loader + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. particle: DUST + # Horizontal X offset for the small tree ornament particle spread. + # Default: 0.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-x: 0.25 + # Vertical Y offset for the small tree ornament particle spread. + # Default: 0.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-y: 0.25 + # Horizontal Z offset for the small tree ornament particle spread. + # Default: 0.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-z: 0.25 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 0.0 + # Number of ornament particles spawned per effect tick. + # Default: 8 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. count: 8 body: + # Enables the trunk/body particle effect for small trees. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: false - # Materials required to grow from small_tree to tree. + lvlup: + # Diamonds required to grow from small_tree to tree. + # Default: 3 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. DIAMOND: 3 + # Gold ingots required to grow from small_tree to tree. + # Default: 5 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. GOLD_INGOT: 5 + # Blaze powder required to grow from small_tree to tree. + # Default: 10 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. BLAZE_POWDER: 10 + # Snowballs required to grow from small_tree to tree. + # Default: 30 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. SNOWBALL: 30 + tree: - # Seconds between present spawn attempts for full trees. + # Delay before present spawns at the tree stage. + # Default: 180 + # Units: seconds before scaling by core.update-speed + # Safe values: 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. gift-cooldown: 180 - # Full trees are more visible, with snow and firefly sparkle. + particles: ambient: + # Enables the ambient particle effect for full trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true + # Particle type for the full tree ambient effect. + # Default: SNOWFLAKE + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. particle: SNOWFLAKE + # Horizontal X offset for the full tree ambient particle spread. + # Default: 1.5 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-x: 1.5 + # Vertical Y offset for the full tree ambient particle spread. + # Default: 3.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-y: 3.0 + # Horizontal Z offset for the full tree ambient particle spread. + # Default: 1.5 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-z: 1.5 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 0.0 + # Number of ambient particles spawned per effect tick. + # Default: 8 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. count: 8 swag: + # Enables the ornament-style particle effect for full trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true + # Particle type for the full tree ornament effect. + # Default: FIREFLY + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. particle: FIREFLY + # Horizontal X offset for the full tree ornament particle spread. + # Default: 0.35 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-x: 0.35 + # Vertical Y offset for the full tree ornament particle spread. + # Default: 0.35 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-y: 0.35 + # Horizontal Z offset for the full tree ornament particle spread. + # Default: 0.35 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-z: 0.35 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 0.0 + # Number of ornament particles spawned per effect tick. + # Default: 4 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. count: 4 body: + # Enables the trunk/body particle effect for full trees. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: false - # Materials required to grow from tree to magic_tree. + lvlup: + # Diamonds required to grow from tree to magic_tree. + # Default: 5 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. DIAMOND: 5 + # Emeralds required to grow from tree to magic_tree. + # Default: 3 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. EMERALD: 3 + # Gold nuggets required to grow from tree to magic_tree. + # Default: 8 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. GOLD_NUGGET: 8 + # Glowstone dust required to grow from tree to magic_tree. + # Default: 16 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. GLOWSTONE_DUST: 16 + magic_tree: - # Seconds between present spawn attempts for max-level magic trees. + # Delay before present spawns at the max-level magic_tree stage. + # Default: 120 + # Units: seconds before scaling by core.update-speed + # Safe values: 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. gift-cooldown: 120 - # Magic trees use the strongest ambient set. + particles: ambient: + # Enables the ambient particle effect for magic trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true + # Particle type for the magic tree ambient effect. + # Default: FIREFLY + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. particle: FIREFLY + # Horizontal X offset for the magic tree ambient particle spread. + # Default: 2.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-x: 2.25 + # Vertical Y offset for the magic tree ambient particle spread. + # Default: 2.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-y: 2.25 + # Horizontal Z offset for the magic tree ambient particle spread. + # Default: 2.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-z: 2.25 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 0.0 + # Number of ambient particles spawned per effect tick. + # Default: 8 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. count: 8 swag: + # Enables the ornament-style particle effect for magic trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true + # Particle type for the magic tree ornament effect. + # Default: DUST + # Safe values: a valid Particle enum name supported by the plugin's simple particle loader + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. particle: DUST + # Horizontal X offset for the magic tree ornament particle spread. + # Default: 0.3 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-x: 0.3 + # Vertical Y offset for the magic tree ornament particle spread. + # Default: 0.3 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-y: 0.3 + # Horizontal Z offset for the magic tree ornament particle spread. + # Default: 0.3 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. offset-z: 0.3 + # Particle speed value passed to the Paper particle API. + # Default: 10.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 10.0 + # Number of ornament particles spawned per effect tick. + # Default: 16 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. count: 16 body: + # Enables the trunk/body particle effect for magic trees. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: false - # Max-level trees have no next-level requirements. - lvlup: + + # Max-level trees have no next-stage ingredient requirements. + # Default: empty section + # Safe values: leave empty + # Reload behavior: informational only; no practical effect unless custom code starts reading it later. + lvlup: {} migration: - # Map saved world names from legacy trees.yml data to the current server world names. - # This keeps old player trees loadable even when the destination server uses different world names. + # Maps world names from legacy trees.yml data to current world names. + # Default: empty map + # Safe format: + # old_world_name: new_world_name # Example: - # world-aliases: # general: world # wild: world # santa: santa_event + # Reload behavior: + # - File edits are saved immediately when the file is written. + # - A full server restart or world reload is recommended before expecting already-saved trees to load using new aliases. world-aliases: {} From 4ce953c9f0b57f2378083f81586c4b3c3d203755 Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Sun, 21 Jun 2026 19:39:56 +0200 Subject: [PATCH 08/10] Modernize XMasTree for Paper 26.2 Target Paper API 26.2 build 29 on Java 25, consolidate translations, improve MiniMessage/theme handling, add admin debug/test/data tools, add gift pool management, preserve legacy tree data migration paths, and refresh README/todo documentation. --- .gitignore | 3 + README.md | 127 ++- build.gradle | 57 +- src/main/java/ru/meloncode/xmas/Events.java | 48 +- .../java/ru/meloncode/xmas/LocaleManager.java | 353 +++++-- .../java/ru/meloncode/xmas/MagicTree.java | 59 +- src/main/java/ru/meloncode/xmas/Main.java | 212 +++-- .../ru/meloncode/xmas/ParticleContainer.java | 4 +- .../ru/meloncode/xmas/TreeSerializer.java | 239 ++++- src/main/java/ru/meloncode/xmas/XMas.java | 49 +- .../java/ru/meloncode/xmas/XMasCommand.java | 873 ++++++++++++++++-- .../ru/meloncode/xmas/XMasPlaceholders.java | 70 +- .../ru/meloncode/xmas/utils/ConfigUtils.java | 27 +- .../xmas/utils/HeadProfileUtils.java | 98 ++ .../ru/meloncode/xmas/utils/TextUtils.java | 120 ++- src/main/resources/config.yml | 7 +- src/main/resources/locales/default.yml | 64 -- src/main/resources/locales/en.yml | 55 -- src/main/resources/locales/hu.yml | 50 - src/main/resources/locales/ru.yml | 43 - src/main/resources/locales/ru_santa.yml | 43 - src/main/resources/plugin.yml | 16 +- src/main/resources/translations/locale_en.yml | 371 ++++++++ todo.log | 97 +- 24 files changed, 2408 insertions(+), 677 deletions(-) create mode 100644 src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java delete mode 100644 src/main/resources/locales/default.yml delete mode 100644 src/main/resources/locales/en.yml delete mode 100644 src/main/resources/locales/hu.yml delete mode 100644 src/main/resources/locales/ru.yml delete mode 100644 src/main/resources/locales/ru_santa.yml create mode 100644 src/main/resources/translations/locale_en.yml diff --git a/.gitignore b/.gitignore index 38d9c5f..3a246cc 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,9 @@ Thumbs.db # Local test servers and generated Paper data servers/ +cache/ +libraries/ +versions/ # Local release jars for the centralized Paper runner libs/ diff --git a/README.md b/README.md index 6b9d1f0..de7f2ee 100644 --- a/README.md +++ b/README.md @@ -8,13 +8,13 @@ This fork keeps the old X-Mas event data usable for winter 2026 while moving the ## Current target -The Gradle build creates the current Paper 26.1.2 target jar in `build/libs`: +The Gradle build creates the current Paper 26.2 target jar in `build/libs`: | Jar | Purpose | | --- | --- | -| `1MB-XMas-2026-v2.0.1-024-v25-26.1.2.jar` | Modern Paper 26.1.2 build, Java 25 bytecode. | +| `1MB-XMas-2026-v2.1.0-032-v25-26.2.jar` | Modern Paper 26.2 build, Java 25 bytecode. | -The checked-in source compiles against the centralized Paper 26.1.2 cache and declares a plugin compatibility floor of `api-version: 1.21.11` so the same jar can be tested on Paper 1.21.11 and Paper 26.1.2. +The checked-in source compiles against the Paper 26.2 API, targets Java 25, and declares `api-version: 26.2`. This fork now treats Paper 26.2 as the primary winter 2026 target, with future 26.x builds as the expected forward-compatibility path. ## Features @@ -26,17 +26,18 @@ The checked-in source compiles against the centralized Paper 26.1.2 cache and de - MiniMessage support for locale strings, crystal display text, and plugin messages. - Existing `plugins/X-Mas/trees.yml` data remains the event data source. - Optional resource refunds when a tree is destroyed or cleaned up after the event. -- Configurable per-stage particles using Paper 26.1.2 particle names. +- Configurable per-stage particles using Paper 26.2 particle names. - `/xmastree debug` sections for `status`, `commands`, `permissions`, `placeholders`, and `config`, plus live global boolean toggles. - Primary `/xmastree` command with an optional legacy `/xmas` alias. - Optional PlaceholderAPI placeholders for CMI holograms, ajLeaderboards, scoreboards, and menus. - Legacy `trees.yml` world-name alias support for renamed destination worlds. - Comment-preserving `config.yml` syncing that keeps admin values while safely adding missing defaults and missing template comments. +- Present heads are tagged with plugin PDC data, so gift handling no longer depends on deprecated skull profile reads. ## v2 changelog -- modernize the plugin from the legacy deployed build to an actively maintained Paper 26.1.2 / Java 25 Gradle build -- simplify the build around the active Paper 26.1.2 target and remove the retired 1.21.11 local server dependency +- modernize the plugin from the legacy deployed build to an actively maintained Paper 26.2 / Java 25 Gradle build +- simplify the build around the active Paper 26.2 target and remove the retired 1.x local server dependency - keep build output clean and predictable in `build/libs` with the 2026 versioned jar naming - keep legacy tree data compatible by continuing to read `plugins/X-Mas/trees.yml` @@ -58,6 +59,12 @@ The checked-in source compiles against the centralized Paper 26.1.2 cache and de - make invalid debug page or section requests return a helpful response instead of silently falling back - add `/xmastree debug toggle true|false` for live boolean config changes - keep tab completion focused on named debug categories instead of numeric page suggestions +- add `/xmastree inspect` for staff tree support checks, including owner, tree UUID, level, location, remaining requirements, refund preview, present timer, and scheduled presents +- make `/xmastree reload` print a short reload report with locale, gift/head/tree counts, key toggles, alias state, and sound volumes +- add `/xmastree test sound` and `/xmastree test particle` so admins can preview reloadable sound volume and particle settings without waiting for live tree interactions +- add `/xmastree data backup` and `/xmastree data validate` for safe `trees.yml` support checks before event work or migration testing +- add `/xmastree data migrate-world [dry-run|apply]` so saved tree world names can be reviewed and migrated safely with an automatic backup +- add `/xmastree gifts list`, `/xmastree gifts roll`, and `/xmastree gifts remove ` for command-line gift pool management before a GUI exists - add optional PlaceholderAPI support with the `onembxmastree` namespace - add placeholders for event state, end time, end countdown, auto-end, resource-back, particles, luck, tree totals, owner totals, player tree count, and plugin version @@ -88,6 +95,8 @@ The checked-in source compiles against the centralized Paper 26.1.2 cache and de - update config comments and improve documentation for installation, building, commands, permissions, placeholders, support, and credits - point support to the GitHub issues page - refresh `.gitignore` for local dev/test folders and obvious OS/build junk +- remove deprecated skull-profile reads from gameplay logic and add an opt-in Gradle deprecation lint switch for future work +- promote the fork to a Paper 26.2-first custom 1MB target with a minor version bump ## Installation @@ -98,9 +107,10 @@ The checked-in source compiles against the centralized Paper 26.1.2 cache and de 5. Start the server with Java 25. 6. Check the console for XMas Tree startup messages, then run `/xmastree` in game or console. -For the 2026 target, use the modern Paper 26.1.2 jar: +For the 2026 target, use the modern Paper 26.2 jar: -- Paper 26.1.2: `1MB-XMas-2026-v2.0.1-024-v25-26.1.2.jar` +- Paper 26.2: `1MB-XMas-2026-v2.1.0-032-v25-26.2.jar` +- Future 26.x: use the same jar for forward-compatibility testing ## Building @@ -108,36 +118,37 @@ Requirements: - JDK 25 - Gradle -- Centralized Paper server cache at `/Users/floris/Projects/Codex/servers/cache/Paper-26.1.2` -- Centralized PlaceholderAPI jar at `/Users/floris/Projects/Codex/servers/cache/Paper-26.1.2/plugins/PlaceholderAPI-2.12.3-DEV-265.jar` +- Centralized Paper server cache at `/Users/floris/Projects/Codex/servers/cache/Paper-26.2` +- PlaceholderAPI build `266` in the centralized 26.2 cache, sourced from the matching 26.2 1MB CMI-API server setup -This repo no longer uses a local `servers/` folder for compilation or testing. If a local `servers/` folder still exists here, it is ignored and treated as retired local data. +This repo no longer requires a local `servers/` folder for compilation. If an ignored local `servers/` folder exists here for ad-hoc compatibility testing, treat it as optional local data rather than part of the project. -Build the current Paper 26.1.2 jar: +Build the current Paper 26.2 jar: ```bash gradle clean buildAllJars ``` -Build only the Paper 26.1.2 jar: +Build only the Paper 26.2 jar: ```bash gradle jar ``` -The `paper2612Jar` task is kept as an alias: +The `paper262Jar` task is kept as an alias: ```bash -gradle paper2612Jar +gradle paper262Jar ``` `buildAllJars` now: -- compiles against centralized Paper API `26.1.2.build.20-alpha` +- compiles against centralized Paper API `26.2.build.29-alpha` +- declares plugin `api-version: 26.2` - keeps the Java release target at `25` - writes the standard jar to `build/libs/` - copies the same jar into `libs/` for the centralized test runner -- prints the active build config, compile target, and declared plugin API floor +- prints the active build config, compile target, declared plugin API version, and forward-compatibility target You can also inspect the build metadata directly with: @@ -145,6 +156,12 @@ You can also inspect the build metadata directly with: gradle printBuildConfig ``` +Run an explicit deprecated-API lint pass when doing future refactors: + +```bash +gradle clean compileJava -PlintDeprecatedApi=true +``` + End users do not need any `servers/` folder. The installable jars are written to `build/libs/`, and this project also keeps a local copy in `libs/` for shared test-runner use. ## Commands @@ -159,8 +176,21 @@ If `core.commands.legacy-command-enabled` is `true`, the legacy `/xmas` alias is | `/xmastree help` | Shows the command list. | | `/xmastree give ` | Gives an online player a Christmas Crystal. | | `/xmastree gifts` | Spawns a small batch of presents under every loaded Christmas tree. | +| `/xmastree gifts spawn` | Explicit form of `/xmastree gifts`; spawns presents under every loaded Christmas tree. | +| `/xmastree gifts list [page]` | Lists configured gift rewards by index. | +| `/xmastree gifts roll` | Previews one random gift roll without giving it to a player. | +| `/xmastree gifts remove ` | Removes a configured gift reward by the index shown in `/xmastree gifts list`. | | `/xmastree addhand` | Adds the item in your main hand to the gift list and saves it to `config.yml`. | -| `/xmastree reload` | Reloads config, locale, present heads, gifts, luck settings, command alias settings, and tree level requirements. | +| `/xmastree reload` | Reloads config, translations, theme, prefix, crystal item text, present heads, gifts, luck settings, command alias settings, and tree level requirements, then prints a short reload report. | +| `/xmastree inspect` | Inspects the tree you are looking at, or the nearest tree if no tree block is targeted. | +| `/xmastree inspect nearest ` | Inspects the nearest loaded tree to an online player. | +| `/xmastree inspect ` | Inspects a specific loaded tree by UUID. | +| `/xmastree test sound first [player]` | Plays the configured first ingredient sound volume for yourself or an online player. | +| `/xmastree test sound repeat [player]` | Plays the configured repeat ingredient sound volume for yourself or an online player. | +| `/xmastree test particle [all\|ambient\|swag\|body] [player]` | Previews configured particle effects for `sapling`, `small_tree`, `tree`, or `magic_tree`. | +| `/xmastree data backup` | Creates a timestamped copy of `plugins/X-Mas/trees.yml` in `plugins/X-Mas/backups/`. | +| `/xmastree data validate` | Reads `trees.yml` and reports missing worlds, invalid IDs, owners, levels, coordinates, requirements, and duplicate locations. | +| `/xmastree data migrate-world [dry-run\|apply]` | Counts or rewrites saved tree world names in `trees.yml`. `dry-run` is the default. `apply` creates a backup first, writes the change, and should be followed by a server restart before testing migrated trees. | | `/xmastree debug` | Opens the `status` debug section by default. | | `/xmastree debug [section\|page]` | Shows debug output for `status`, `commands`, `permissions`, `placeholders`, or `config`. Numeric pages `1-5` still work as a legacy shortcut. | | `/xmastree debug toggle true\|false` | Toggles supported global boolean config keys and reloads the plugin config. | @@ -210,6 +240,9 @@ Numeric compatibility remains available for existing habits and old screenshots: | `onembxmastree.command.gifts` | `op` | Allows `/xmastree gifts`. | | `onembxmastree.command.addhand` | `op` | Allows `/xmastree addhand`. | | `onembxmastree.command.reload` | `op` | Allows `/xmastree reload`. | +| `onembxmastree.command.inspect` | `op` | Allows `/xmastree inspect`. | +| `onembxmastree.command.test` | `op` | Allows `/xmastree test sound` and `/xmastree test particle`. | +| `onembxmastree.command.data` | `op` | Allows `/xmastree data backup` and `/xmastree data validate`. | | `onembxmastree.command.debug` | `op` | Allows `/xmastree debug [section\|page]`. | | `onembxmastree.command.debug.toggle` | `op` | Allows `/xmastree debug toggle true\|false`. | | `onembxmastree.command.end` | `op` | Allows `/xmastree end`. | @@ -225,13 +258,16 @@ If `core.holiday-ends.resource-back` is enabled, confirmed tree destruction retu Ingredient accept sounds can be tuned live in `config.yml` under `core.sounds.grow`. Use `0.0` for silent, `0.1` for quiet, `0.5` for half volume, and `1.0` for full volume. `/xmastree reload` applies the new values without a server restart. +Admins can preview those sound values with `/xmastree test sound first` and `/xmastree test sound repeat`. Particle configuration can be previewed with `/xmastree test particle sapling`, `/xmastree test particle small_tree`, `/xmastree test particle tree`, or `/xmastree test particle magic_tree`. + ## Configuration The plugin writes its files to `plugins/X-Mas`: - `config.yml` controls event timing, locale, tree limits, gift cooldowns, present skins, gift items, and level-up requirements. - `trees.yml` stores placed tree data and should be kept when upgrading an existing event. -- `locales/*.yml` controls player-facing messages and crystal display text. +- `translations/locale_en.yml` is the bundled English source of truth for player/admin text, theme colors, and prefixes. +- optional custom translations can be added as `translations/locale_.yml`. Use modern Paper/Bukkit material names such as `GOLD_INGOT`, `SPRUCE_LOG`, and `PLAYER_HEAD`. Legacy numeric IDs and old material names are skipped to avoid modern Paper exceptions. @@ -244,7 +280,13 @@ Gift entries in `xmas.gifts` can be simple material names: Admins can also hold an item and run `/xmastree addhand`. This saves the exact item as Base64 so custom names, lore, enchantments, and metadata can be used as gifts. -Legacy world-name remapping lives under `migration.world-aliases`. This is useful when an old `trees.yml` was saved in worlds like `general`, `wild`, or `santa`, but the new Paper 26.1.2 server uses different world names: +Gift pool maintenance commands: + +- `/xmastree gifts list [page]` shows the currently loaded gift rewards. +- `/xmastree gifts roll` previews one random gift selection without giving it to anyone. +- `/xmastree gifts remove ` removes the indexed gift from `xmas.gifts`, saves `config.yml`, and reloads the gift pool. + +Legacy world-name remapping lives under `migration.world-aliases`. This is useful when an old `trees.yml` was saved in worlds like `general`, `wild`, or `santa`, but the new Paper 26.2 server uses different world names: ```yaml migration: @@ -256,28 +298,55 @@ migration: The saved coordinates are preserved. If the destination world terrain or world border changed, some legacy tree locations may still need manual cleanup. +Admins can preview or apply a world-name rewrite directly against `trees.yml`: + +```text +/xmastree data migrate-world general world +/xmastree data migrate-world general world apply +``` + +The first command is a dry run. The `apply` command creates a timestamped backup in `plugins/X-Mas/backups/`, rewrites matching saved world names, and should be followed by a server restart before testing migrated legacy trees. + Present head entries in `xmas.presents` should use `textures.minecraft.net` URLs. Old player-name entries are still accepted for compatibility. -Per-stage particles live under `xmas.tree-lvl..particles`. Particle names should come from the Paper 26.1.2 `Particle` enum: +Per-stage particles live under `xmas.tree-lvl..particles`. Particle names should come from the Paper 26.2 `Particle` enum: -[jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html](https://jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html) +[jd.papermc.io/paper/26.2/org/bukkit/Particle.html](https://jd.papermc.io/paper/26.2/org/bukkit/Particle.html) The config currently supports simple particles and `DUST`. ## MiniMessage -Locale messages, crystal names, crystal lore, and command messages support MiniMessage: +Translation messages, crystal names, crystal lore, command/debug text, and prefixes support MiniMessage: ```yaml crystal: - name: Christmas Crystal + name: Christmas Crystal lore: - - Concentrated Christmas Spirit - - Use it on a spruce sapling to fill it with magic! + - Concentrated Christmas Spirit + - Use it on a spruce sapling to fill it with magic. ``` +The fork also ships a small semantic pastel tag set for locale files and command output: + +- `` main readable text +- `` softer secondary text +- `` mint accent text +- `` rose accent text +- `` warm label text +- `` command/path accent text +- ``, ``, ``, `` status tones + Legacy `&` color codes are still parsed for compatibility when a message does not contain MiniMessage tags. +The active translation path is: + +```text +plugins/X-Mas/translations/locale_en.yml +``` + +`core.locale: en` maps to `locale_en.yml`. A custom file such as `locale_fr.yml` can be loaded by setting `core.locale: fr`. + ## Placeholders PlaceholderAPI is optional. If PlaceholderAPI is installed, X-Mas registers the `onembxmastree` expansion. @@ -308,7 +377,7 @@ The dotted key after `onembxmastree_` is supported to keep the placeholders read | `%onembxmastree_trees.total%` | `14` | Total loaded X-Mas trees. | | `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | | `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | -| `%onembxmastree_version%` | `2.0.1-024` | Loaded plugin version. | +| `%onembxmastree_version%` | `2.1.0-032` | Loaded plugin version. | CMI hologram example: @@ -334,7 +403,7 @@ ajLeaderboards placeholder examples: - When saved world names no longer match the current server world names, `migration.world-aliases` can remap them without rewriting `trees.yml`. - Existing present head player-name entries are still accepted, but new configs should prefer Mojang texture URLs. - The modern jars are compiled with Java 25 bytecode and should be run on Java 25. -- The Paper 26.1.2 jar is the intended winter 2026 target, and the same jar now declares `api-version: 1.21.11` so it can be smoke-tested on both Paper 1.21.11 and Paper 26.1.2. +- The Paper 26.2 jar is the intended winter 2026 target, and the same jar should be used for forward-compatibility testing on newer 26.x builds. ## Security notes @@ -342,7 +411,7 @@ ajLeaderboards placeholder examples: - Present texture URLs are restricted to `textures.minecraft.net`. - Gift item Base64 entries are capped before deserialization. - Config material names are resolved with modern `Material.matchMaterial` and invalid or legacy values are skipped. -- Treat `config.yml` and locale files as trusted admin-controlled files, especially when using MiniMessage click or hover tags. +- Treat `config.yml` and translation files as trusted admin-controlled files, especially when using MiniMessage click or hover tags. ## Support diff --git a/build.gradle b/build.gradle index 8a24e5b..0ab0ad3 100644 --- a/build.gradle +++ b/build.gradle @@ -3,22 +3,28 @@ plugins { } group = 'com.onemb.xmas' -version = '2.0.1-024' +version = '2.1.0-032' def projectVersion = version.toString() +def enableDeprecationLint = providers.gradleProperty('lintDeprecatedApi').map { it.toBoolean() }.getOrElse(false) def javaRelease = 25 -def paperCompileVersion = '26.1.2.build.20-alpha' -def paperCompatibilityFloor = '1.21.11' +def paperCompileVersion = '26.2.build.29-alpha' +def declaredPluginApiVersion = '26.2' +def experimentalCompatibilityTarget = 'future 26.x' +def resourceExpansionProperties = [ + version : projectVersion, + apiVersion: declaredPluginApiVersion +] def sharedServersRoot = file(System.getenv('CODEX_SHARED_SERVERS_ROOT') ?: '/Users/floris/Projects/Codex/servers') -def sharedPaperCache = new File(sharedServersRoot, 'cache/Paper-26.1.2') -def paper2612LibrariesDir = new File(sharedPaperCache, 'libraries') -def paper2612Api = new File(sharedPaperCache, "libraries/io/papermc/paper/paper-api/${paperCompileVersion}/paper-api-${paperCompileVersion}.jar") -def placeholderApiJar = new File(sharedPaperCache, 'plugins/PlaceholderAPI-2.12.3-DEV-265.jar') -def paper2612ArchiveName = "1MB-XMas-2026-v${projectVersion}-v25-26.1.2.jar" +def sharedPaperCache = new File(sharedServersRoot, 'cache/Paper-26.2') +def paper262LibrariesDir = new File(sharedPaperCache, 'libraries') +def paper262Api = new File(sharedPaperCache, "libraries/io/papermc/paper/paper-api/${paperCompileVersion}/paper-api-${paperCompileVersion}.jar") +def placeholderApiJar = new File(sharedPaperCache, 'plugins/PlaceholderAPI-2.12.3-DEV-266.jar') +def paper262ArchiveName = "1MB-XMas-2026-v${projectVersion}-v25-26.2.jar" [ - [paper2612Api, "Paper API jar"], - [paper2612LibrariesDir, "Paper libraries directory"], + [paper262Api, "Paper API jar"], + [paper262LibrariesDir, "Paper libraries directory"], [placeholderApiJar, "PlaceholderAPI jar"], ].each { entry -> File path = entry[0] as File @@ -35,28 +41,29 @@ java { } dependencies { - compileOnly files(paper2612Api) - compileOnly fileTree(dir: paper2612LibrariesDir, include: '**/*.jar') + compileOnly files(paper262Api) + compileOnly fileTree(dir: paper262LibrariesDir, include: '**/*.jar') compileOnly files(placeholderApiJar) } tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' options.release = javaRelease + if (enableDeprecationLint) { + options.compilerArgs.add('-Xlint:deprecation') + } } tasks.named('processResources') { filteringCharset = 'UTF-8' + inputs.properties(resourceExpansionProperties) filesMatching('plugin.yml') { - expand( - version: projectVersion, - apiVersionFloor: paperCompatibilityFloor - ) + expand(resourceExpansionProperties) } } tasks.named('jar') { - archiveFileName = paper2612ArchiveName + archiveFileName = paper262ArchiveName } tasks.register('releaseJar', Copy) { @@ -67,8 +74,8 @@ tasks.register('releaseJar', Copy) { into(layout.projectDirectory.dir('libs')) } -tasks.register('paper2612Jar') { - description = 'Assembles the Paper 26.1.2 Java 25 plugin jar and copies it into libs/.' +tasks.register('paper262Jar') { + description = 'Assembles the Paper 26.2 Java 25 plugin jar and copies it into libs/.' group = 'build' dependsOn tasks.named('releaseJar') } @@ -80,22 +87,24 @@ tasks.register('printBuildConfig') { println 'XMasTree build config' println " version: ${projectVersion}" println " compile target: Paper API ${paperCompileVersion}" - println " plugin api-version floor: ${paperCompatibilityFloor}" + println " declared plugin api-version: ${declaredPluginApiVersion}" + println " experimental compatibility target: Paper ${experimentalCompatibilityTarget}" println " java release target: ${javaRelease}" println " centralized Paper cache: ${sharedPaperCache}" println " PlaceholderAPI compile jar: ${placeholderApiJar}" - println " build/libs output: ${layout.buildDirectory.file("libs/${paper2612ArchiveName}").get().asFile}" - println " libs/ release copy: ${layout.projectDirectory.file("libs/${paper2612ArchiveName}").asFile}" + println " deprecation lint enabled: ${enableDeprecationLint}" + println " build/libs output: ${layout.buildDirectory.file("libs/${paper262ArchiveName}").get().asFile}" + println " libs/ release copy: ${layout.projectDirectory.file("libs/${paper262ArchiveName}").asFile}" } } tasks.register('buildAllJars') { - description = 'Builds the current Paper 26.1.2 target jar and copies it into libs/.' + description = 'Builds the current Paper 26.2 target jar and copies it into libs/.' group = 'build' dependsOn tasks.named('releaseJar') finalizedBy tasks.named('printBuildConfig') } tasks.named('assemble') { - dependsOn tasks.named('paper2612Jar') + dependsOn tasks.named('paper262Jar') } diff --git a/src/main/java/ru/meloncode/xmas/Events.java b/src/main/java/ru/meloncode/xmas/Events.java index 730b32d..a44ee63 100644 --- a/src/main/java/ru/meloncode/xmas/Events.java +++ b/src/main/java/ru/meloncode/xmas/Events.java @@ -12,14 +12,12 @@ import org.bukkit.event.block.*; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityExplodeEvent; -import org.bukkit.event.entity.ItemSpawnEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.StructureGrowEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.inventory.meta.SkullMeta; import org.bukkit.persistence.PersistentDataType; import ru.meloncode.xmas.utils.TextUtils; @@ -41,7 +39,7 @@ public void onPlayerOpenPresent(PlayerInteractEvent event) { if (event.getHand() == EquipmentSlot.OFF_HAND) return; //Event firing for both hands if (event.getAction() == Action.RIGHT_CLICK_BLOCK) { Block block = event.getClickedBlock(); - if (block != null && block.getType() == Material.PLAYER_HEAD) { + if (XMas.isPresentHead(block)) { XMas.processPresent(block, event.getPlayer()); } } @@ -50,7 +48,8 @@ public void onPlayerOpenPresent(PlayerInteractEvent event) { @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) public void onPlayerOpenPresent(BlockBreakEvent event) { Block block = event.getBlock(); - if (block != null && block.getType() == Material.PLAYER_HEAD) { + if (XMas.isPresentHead(block)) { + event.setDropItems(false); XMas.processPresent(block, event.getPlayer()); } } @@ -170,18 +169,6 @@ public void onBlockBreakByExplosion(EntityExplodeEvent event) { } } - @EventHandler - public void onItemSpawn(ItemSpawnEvent event) { - ItemStack item = event.getEntity().getItemStack(); - if (item.getType() == Material.PLAYER_HEAD) { - SkullMeta meta = (SkullMeta) item.getItemMeta(); - String headId = getHeadIdentifier(meta); - if (headId != null && Main.getHeads().contains(headId)) { - event.setCancelled(true); - } - } - } - @EventHandler public void onPistonRetract(BlockPistonRetractEvent event) { if (MagicTree.isBlockBelongs(event.getBlock())) { @@ -227,14 +214,14 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { XMas.removeTree(tree); } if (Main.inProgress) - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_COMPLETE); + TextUtils.sendMessage(player, TextUtils.success(LocaleManager.DESTROY_COMPLETE)); } else { destroyers.put(player.getUniqueId(), System.currentTimeMillis()); if (Main.inProgress) - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_WARNING); - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_TUT); + TextUtils.sendMessage(player, TextUtils.warning(LocaleManager.DESTROY_WARNING)); + TextUtils.sendMessage(player, TextUtils.muted(LocaleManager.DESTROY_TUT)); if (Main.resourceBack) { - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_RESOURCE_BACK); + TextUtils.sendMessage(player, TextUtils.info(LocaleManager.DESTROY_RESOURCE_BACK)); } } else { @@ -247,9 +234,9 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { case SPRUCE_LEAVES: case GLOWSTONE: if (Main.inProgress) - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_LEAVES_SANTA); + TextUtils.sendMessage(player, TextUtils.accent(LocaleManager.DESTROY_LEAVES_SANTA)); if (player.getUniqueId().equals(tree.getOwner()) || XMasCommand.canOverrideTree(player)) { - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_LEAVES_TUT); + TextUtils.sendMessage(player, TextUtils.warning(LocaleManager.DESTROY_LEAVES_TUT)); } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); } @@ -263,14 +250,14 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { } else { XMas.removeTree(tree); } - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_COMPLETE); + TextUtils.sendMessage(player, TextUtils.success(LocaleManager.DESTROY_COMPLETE)); } else { destroyers.put(player.getUniqueId(), System.currentTimeMillis()); if (Main.inProgress) - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_SAPLING); - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_TUT); + TextUtils.sendMessage(player, TextUtils.warning(LocaleManager.DESTROY_SAPLING)); + TextUtils.sendMessage(player, TextUtils.muted(LocaleManager.DESTROY_TUT)); if (Main.resourceBack) { - TextUtils.sendMessage(player, "" + LocaleManager.DESTROY_RESOURCE_BACK); + TextUtils.sendMessage(player, TextUtils.info(LocaleManager.DESTROY_RESOURCE_BACK)); } } } else { @@ -331,13 +318,4 @@ private void chunkLoad(ChunkLoadEvent e) tree.spawnScheduledPresents(); } } - - private String getHeadIdentifier(SkullMeta meta) { - if (meta.getPlayerProfile() != null - && meta.getPlayerProfile().getTextures() != null - && meta.getPlayerProfile().getTextures().getSkin() != null) { - return meta.getPlayerProfile().getTextures().getSkin().toString(); - } - return meta.getOwningPlayer() != null ? meta.getOwningPlayer().getName() : null; - } } diff --git a/src/main/java/ru/meloncode/xmas/LocaleManager.java b/src/main/java/ru/meloncode/xmas/LocaleManager.java index 91cd990..88db720 100644 --- a/src/main/java/ru/meloncode/xmas/LocaleManager.java +++ b/src/main/java/ru/meloncode/xmas/LocaleManager.java @@ -1,17 +1,33 @@ package ru.meloncode.xmas; import org.bukkit.configuration.file.FileConfiguration; +import org.bukkit.configuration.file.YamlConfiguration; import ru.meloncode.xmas.utils.ConfigUtils; import ru.meloncode.xmas.utils.TextUtils; import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; import java.util.ArrayList; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; -public class LocaleManager { +public final class LocaleManager { - private static final FileConfiguration def_locale = ConfigUtils.loadConfig(new File(Main.getInstance().getDataFolder() + "/locales/default.yml")); - public static String PLUGIN_NAME; + public static final String DEFAULT_LOCALE_CODE = "en"; + private static final String DEFAULT_LOCALE_RESOURCE_PATH = "translations/locale_en.yml"; + private static final String TRANSLATIONS_DIRECTORY = "translations"; + private static final String LEGACY_LOCALES_DIRECTORY = "locales"; + + private static final String BOOTSTRAP_PLUGIN_NAME = "XMas Tree"; + private static final String BOOTSTRAP_PREFIX = "[{plugin_name}] "; + private static final String BOOTSTRAP_CONSOLE_PREFIX = "[{plugin_name}] "; + + private static final Map BOOTSTRAP_THEME = createBootstrapTheme(); + + public static String PLUGIN_NAME = BOOTSTRAP_PLUGIN_NAME; public static String PLUGIN_ENABLED; public static String GROW_LVL_PROGRESS; public static String GROW_LVL_READY; @@ -29,94 +45,297 @@ public class LocaleManager { public static String DESTROY_TUT; public static String DESTROY_COMPLETE; public static String CRYSTAL_NAME; - public static List CRYSTAL_LORE; + public static List CRYSTAL_LORE = new ArrayList<>(); public static String GIFT_LUCK; public static String GIFT_FAIL; public static String TIMEOUT; public static String HAPPY_NEW_YEAR; - public static List COMMAND_HELP; + public static List COMMAND_HELP = new ArrayList<>(); public static String COMMAND_PLAYER_OFFLINE; public static String COMMAND_NO_PLAYER_NAME; public static String COMMAND_GIVEAWAY; + + private static FileConfiguration defaultLocale; + private static FileConfiguration bundledDefaultLocale; private static FileConfiguration locale; + private static String activeLocaleCode = DEFAULT_LOCALE_CODE; + private static String chatPrefix = BOOTSTRAP_PREFIX; + private static String consolePrefix = BOOTSTRAP_CONSOLE_PREFIX; + private static Map themeAliases = buildThemeAliases(BOOTSTRAP_THEME); + + private LocaleManager() { + } - public static void loadLocale(String lang) { - File file = new File(Main.getInstance().getDataFolder() + "/locales/" + lang + ".yml"); - if (!file.exists()) { - TextUtils.sendConsoleMessage("Can't load locale '" + lang + "'"); - TextUtils.sendConsoleMessage("Switching to default locale 'en'"); - locale = def_locale; + public static void loadLocale(String localeCode) { + Main plugin = (Main) Main.getInstance(); + activeLocaleCode = normalizeLocaleCode(localeCode); + + File englishFile = getTranslationFile(plugin, DEFAULT_LOCALE_CODE); + migrateLegacyLocaleIfNeeded(plugin, DEFAULT_LOCALE_CODE, englishFile); + bundledDefaultLocale = ConfigUtils.loadResourceConfig(plugin, DEFAULT_LOCALE_RESOURCE_PATH); + defaultLocale = ConfigUtils.loadManagedConfig(plugin, DEFAULT_LOCALE_RESOURCE_PATH, englishFile); + + File requestedFile = getTranslationFile(plugin, activeLocaleCode); + if (!DEFAULT_LOCALE_CODE.equals(activeLocaleCode)) { + migrateLegacyLocaleIfNeeded(plugin, activeLocaleCode, requestedFile); + } + + if (DEFAULT_LOCALE_CODE.equals(activeLocaleCode)) { + locale = defaultLocale; + } else if (requestedFile.exists()) { + YamlConfiguration requestedLocale = ConfigUtils.loadConfig(requestedFile); + boolean changed = ConfigUtils.synchronizeWithDefaults(requestedLocale, defaultLocale); + locale = requestedLocale; + if (changed) { + ConfigUtils.saveConfig(requestedFile, requestedLocale); + } } else { - locale = ConfigUtils.loadConfig(file); - TextUtils.sendConsoleMessage("Locale '" + lang + "' successfully loaded"); + locale = defaultLocale; } + loadStrings(); + if (DEFAULT_LOCALE_CODE.equals(activeLocaleCode) || requestedFile.exists()) { + TextUtils.sendConsoleMessage(TextUtils.success(text("console.translation.loaded", "Loaded translation '{file}'.").replace("{file}", requestedFile.getName()))); + } else { + TextUtils.sendConsoleMessage(TextUtils.warning(text("console.translation.missing", "Translation '{file}' was not found.").replace("{file}", requestedFile.getName()))); + TextUtils.sendConsoleMessage(TextUtils.muted(text("console.translation.fallback", "Falling back to locale_en.yml."))); + } + } + + public static String text(String path) { + return text(path, null); + } + + public static String text(String path, String fallback) { + String value = resolveString(locale, path); + if (value != null) { + return replaceCommonTokens(value); + } + + value = resolveString(defaultLocale, path); + if (value != null) { + return replaceCommonTokens(value); + } + + return fallback == null ? null : replaceCommonTokens(fallback); + } + + public static String text(String path, String fallback, String... replacements) { + String value = text(path, fallback); + if (value == null || replacements == null) { + return value; + } + for (int i = 0; i + 1 < replacements.length; i += 2) { + value = value.replace(replacements[i], String.valueOf(replacements[i + 1])); + } + return value; + } + + public static List textList(String path) { + List values = resolveStringList(locale, path); + if (!values.isEmpty()) { + return replaceCommonTokens(values); + } + + values = resolveStringList(defaultLocale, path); + if (!values.isEmpty()) { + return replaceCommonTokens(values); + } + + return new ArrayList<>(); + } + + public static List bundledTextList(String path) { + List values = resolveStringList(bundledDefaultLocale, path); + return replaceCommonTokens(values); + } + + public static String replaceCommonTokens(String input) { + if (input == null) { + return null; + } + return input.replace("{plugin_name}", PLUGIN_NAME != null ? PLUGIN_NAME : BOOTSTRAP_PLUGIN_NAME); + } + + public static List replaceCommonTokens(List lines) { + List replaced = new ArrayList<>(); + for (String line : lines) { + replaced.add(replaceCommonTokens(line)); + } + return replaced; + } + + public static Map getThemeAliases() { + return themeAliases; + } + + public static String getChatPrefix() { + return replaceCommonTokens(chatPrefix); + } + + public static String getConsolePrefix() { + return replaceCommonTokens(consolePrefix); + } + + public static String getActiveLocaleCode() { + return activeLocaleCode; } private static void loadStrings() { - PLUGIN_NAME = getString("plugin-name"); - PLUGIN_ENABLED = getString("messages.plugin-enabled"); - GROW_LVL_PROGRESS = getString("messages.tree.grow-lvl-progress"); - GROW_LVL_READY = getString("messages.tree.grow-lvl-ready"); - GROW_LEVEL_MAX = getString("messages.tree.grow-lvl-max"); - GROW_REQ_LIST_TITLE = getString("messages.tree.grow-req-list-title"); - GROW_REQ_LIST_HINT = getString("messages.tree.grow-req-list-hint"); - GROW_NOT_ENOUGH_PLACE = getString("messages.tree.grow-not-enough-place"); - TREE_LIMIT = getString("messages.tree.tree-limit"); - DESTROY_SAPLING = getString("messages.tree.destroy-sapling"); - DESTROY_LEAVES_SANTA = getString("messages.tree.destroy-leaves-santa"); - DESTROY_LEAVES_TUT = getString("messages.tree.destroy-leaves-tut"); - DESTROY_WARNING = getString("messages.tree.destroy-warning"); - DESTROY_RESOURCE_BACK = getString("messages.tree.destroy-resource-back"); - DESTROY_TUT = getString("messages.tree.destroy-tut"); - DESTROY_COMPLETE = getString("messages.tree.destroy-complete"); - DESTROY_FAIL_OWNER = getString("messages.tree.destroy-fail-owner"); - CRYSTAL_NAME = getString("crystal.name"); - CRYSTAL_LORE = getStringList("crystal.lore"); - GIFT_LUCK = getString("messages.gift.luck-message"); - GIFT_FAIL = getString("messages.gift.unluck-message"); - TIMEOUT = getString("messages.timeout"); - HAPPY_NEW_YEAR = getString("messages.final-wish"); - - COMMAND_HELP = getStringList("command.help"); - COMMAND_PLAYER_OFFLINE = getString("command.player-offline"); - COMMAND_NO_PLAYER_NAME = getString("command.no-player-name"); - COMMAND_GIVEAWAY = getString("command.giveaway"); - - } - - private static String getString(String path) { - if (locale == null) - throw new NullPointerException("Locale not loaded"); + PLUGIN_NAME = text("plugin-name", BOOTSTRAP_PLUGIN_NAME); + chatPrefix = text("format.prefix", BOOTSTRAP_PREFIX); + consolePrefix = text("format.console-prefix", BOOTSTRAP_CONSOLE_PREFIX); + themeAliases = buildThemeAliases(readTheme()); - try { - String message = locale.getString(path); - return message.contains("_UNUSED") ? null : message; - } catch (NullPointerException e) { - TextUtils.sendConsoleMessage("Unable to find '" + path + "' in locale " + Main.getInstance().getConfig().getString("core.locale") + ". Bad File?"); - TextUtils.sendConsoleMessage("Using default locale to get value"); - return def_locale.getString(path); + PLUGIN_ENABLED = text("messages.plugin-enabled"); + GROW_LVL_PROGRESS = text("messages.tree.grow-lvl-progress"); + GROW_LVL_READY = text("messages.tree.grow-lvl-ready"); + GROW_LEVEL_MAX = text("messages.tree.grow-lvl-max"); + GROW_REQ_LIST_TITLE = text("messages.tree.grow-req-list-title"); + GROW_REQ_LIST_HINT = text("messages.tree.grow-req-list-hint"); + GROW_NOT_ENOUGH_PLACE = text("messages.tree.grow-not-enough-place"); + TREE_LIMIT = text("messages.tree.tree-limit"); + DESTROY_SAPLING = text("messages.tree.destroy-sapling"); + DESTROY_LEAVES_SANTA = text("messages.tree.destroy-leaves-santa"); + DESTROY_LEAVES_TUT = text("messages.tree.destroy-leaves-tut"); + DESTROY_WARNING = text("messages.tree.destroy-warning"); + DESTROY_RESOURCE_BACK = text("messages.tree.destroy-resource-back"); + DESTROY_TUT = text("messages.tree.destroy-tut"); + DESTROY_COMPLETE = text("messages.tree.destroy-complete"); + DESTROY_FAIL_OWNER = text("messages.tree.destroy-fail-owner"); + CRYSTAL_NAME = text("crystal.name"); + CRYSTAL_LORE = textList("crystal.lore"); + GIFT_LUCK = text("messages.gift.luck-message"); + GIFT_FAIL = text("messages.gift.unluck-message"); + TIMEOUT = text("messages.timeout"); + HAPPY_NEW_YEAR = text("messages.final-wish"); + + COMMAND_HELP = textList("command.help"); + COMMAND_PLAYER_OFFLINE = text("command.player-offline"); + COMMAND_NO_PLAYER_NAME = text("command.no-player-name"); + COMMAND_GIVEAWAY = text("command.giveaway"); + } + + private static Map readTheme() { + Map theme = new LinkedHashMap<>(BOOTSTRAP_THEME); + theme.put("xm-text", text("theme.text", BOOTSTRAP_THEME.get("xm-text"))); + theme.put("xm-muted", text("theme.muted", BOOTSTRAP_THEME.get("xm-muted"))); + theme.put("xm-accent", text("theme.accent", BOOTSTRAP_THEME.get("xm-accent"))); + theme.put("xm-accent-2", text("theme.accent-secondary", BOOTSTRAP_THEME.get("xm-accent-2"))); + theme.put("xm-label", text("theme.label", BOOTSTRAP_THEME.get("xm-label"))); + theme.put("xm-success", text("theme.success", BOOTSTRAP_THEME.get("xm-success"))); + theme.put("xm-warning", text("theme.warning", BOOTSTRAP_THEME.get("xm-warning"))); + theme.put("xm-error", text("theme.error", BOOTSTRAP_THEME.get("xm-error"))); + theme.put("xm-info", text("theme.info", BOOTSTRAP_THEME.get("xm-info"))); + theme.put("xm-command", text("theme.command", text("theme.accent-secondary", BOOTSTRAP_THEME.get("xm-command")))); + return theme; + } + + private static Map buildThemeAliases(Map theme) { + Map aliases = new LinkedHashMap<>(); + for (Map.Entry entry : theme.entrySet()) { + aliases.put("<" + entry.getKey() + ">", "<" + entry.getValue() + ">"); + aliases.put("", ""); } + return aliases; } - private static List getStringList(String path) { - if (locale == null) - throw new NullPointerException("Locale not loaded"); + private static String resolveString(FileConfiguration configuration, String path) { + if (configuration == null || path == null) { + return null; + } - try { - List raw = locale.getStringList(path); - List list = new ArrayList<>(); - for (String s : raw) { - list.add(s); - } + String message = configuration.getString(path); + if (message == null || message.contains("_UNUSED")) { + return null; + } + return message; + } + + private static List resolveStringList(FileConfiguration configuration, String path) { + List list = new ArrayList<>(); + if (configuration == null || path == null) { + return list; + } + + List raw = configuration.getStringList(path); + if (raw == null) { return list; + } + for (String line : raw) { + list.add(line); + } + return list; + } - } catch (IllegalArgumentException e) { - TextUtils.sendConsoleMessage("Unable to find '" + path + "' in locale " + Main.getInstance().getConfig().getString("core.locale") + ". Bad File?"); - TextUtils.sendConsoleMessage("Using default locale to get value"); - return def_locale.getStringList(path); + private static File getTranslationFile(Main plugin, String localeCode) { + return new File(plugin.getDataFolder(), TRANSLATIONS_DIRECTORY + "/locale_" + localeCode + ".yml"); + } + + private static String normalizeLocaleCode(String localeCode) { + if (localeCode == null || localeCode.isBlank()) { + return DEFAULT_LOCALE_CODE; } + String normalized = localeCode.trim().toLowerCase(); + if (normalized.startsWith("locale_")) { + normalized = normalized.substring("locale_".length()); + } + if (normalized.endsWith(".yml")) { + normalized = normalized.substring(0, normalized.length() - 4); + } + if (normalized.startsWith("translations/")) { + normalized = normalized.substring("translations/".length()); + } + return normalized.isBlank() ? DEFAULT_LOCALE_CODE : normalized; + } + + private static void migrateLegacyLocaleIfNeeded(Main plugin, String localeCode, File targetFile) { + if (targetFile.exists()) { + return; + } + + File legacyLocaleFile = new File(plugin.getDataFolder(), LEGACY_LOCALES_DIRECTORY + "/" + localeCode + ".yml"); + File legacyDefaultFile = new File(plugin.getDataFolder(), LEGACY_LOCALES_DIRECTORY + "/default.yml"); + File sourceFile = legacyLocaleFile.exists() ? legacyLocaleFile : (DEFAULT_LOCALE_CODE.equals(localeCode) && legacyDefaultFile.exists() ? legacyDefaultFile : null); + + if (sourceFile == null || !sourceFile.exists()) { + return; + } + + File parent = targetFile.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + plugin.getLogger().warning(text("console.translation.create-directory-failed", "Unable to create translations directory at {directory}", + "{directory}", parent.getPath())); + return; + } + + try { + Files.copy(sourceFile.toPath(), targetFile.toPath(), StandardCopyOption.REPLACE_EXISTING); + plugin.getLogger().info(text("console.translation.migrated-legacy", "Migrated legacy locale '{source}' to '{target}'.", + "{source}", sourceFile.getName(), + "{target}", targetFile.getPath())); + } catch (IOException exception) { + plugin.getLogger().warning(text("console.translation.migrate-legacy-failed", "Unable to migrate legacy locale '{source}' to '{target}': {error}", + "{source}", sourceFile.getPath(), + "{target}", targetFile.getPath(), + "{error}", exception.getMessage())); + } + } + + private static Map createBootstrapTheme() { + Map theme = new LinkedHashMap<>(); + theme.put("xm-text", "#f7f1e8"); + theme.put("xm-muted", "#c7c0bb"); + theme.put("xm-accent", "#9fe3d6"); + theme.put("xm-accent-2", "#f4c2d7"); + theme.put("xm-label", "#f3d38f"); + theme.put("xm-success", "#b9e8b5"); + theme.put("xm-warning", "#f6d58b"); + theme.put("xm-error", "#f3a7a7"); + theme.put("xm-info", "#a9d4ff"); + theme.put("xm-command", "#f4c2d7"); + return theme; } } diff --git a/src/main/java/ru/meloncode/xmas/MagicTree.java b/src/main/java/ru/meloncode/xmas/MagicTree.java index 4e07950..cb4dc44 100644 --- a/src/main/java/ru/meloncode/xmas/MagicTree.java +++ b/src/main/java/ru/meloncode/xmas/MagicTree.java @@ -1,6 +1,5 @@ package ru.meloncode.xmas; -import com.destroystokyo.paper.profile.PlayerProfile; import org.bukkit.*; import org.bukkit.FireworkEffect.Type; import org.bukkit.block.*; @@ -10,14 +9,11 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.FireworkMeta; -import org.bukkit.profile.PlayerTextures; import org.bukkit.persistence.PersistentDataType; +import ru.meloncode.xmas.utils.HeadProfileUtils; import ru.meloncode.xmas.utils.TextUtils; import org.bukkit.util.Vector; -import java.net.MalformedURLException; -import java.net.URI; -import java.net.URL; import java.util.*; import java.util.Map.Entry; import java.util.concurrent.ConcurrentHashMap; @@ -225,8 +221,7 @@ public void spawnPresent() { { pBlock.setType(Material.PLAYER_HEAD); BlockState state = pBlock.getState(); - if (state instanceof Skull) { - Skull skull = (Skull) state; + if (state instanceof Skull skull) { BlockFace face; do { face = BlockFace.values()[Main.RANDOM.nextInt(BlockFace.values().length)]; @@ -236,37 +231,17 @@ public void spawnPresent() { skullRotatable.setRotation(face); skull.setBlockData(skullRotatable); skull.setType(Material.PLAYER_HEAD); - applyConfiguredHead(skull, Main.getHeads().get(Main.RANDOM.nextInt(Main.getHeads().size()))); + HeadProfileUtils.applyConfiguredHead( + skull, + Main.getHeads().get(Main.RANDOM.nextInt(Main.getHeads().size())), + Main.getInstance().getLogger() + ); + skull.getPersistentDataContainer().set(Main.getPresentHeadKey(), PersistentDataType.BYTE, (byte) 1); skull.update(true); } } } - private void applyConfiguredHead(Skull skull, String configuredHead) { - if (configuredHead == null || configuredHead.trim().isEmpty()) { - return; - } - String trimmedHead = configuredHead.trim(); - if (!trimmedHead.contains("://")) { - skull.setPlayerProfile(Bukkit.createProfile(trimmedHead)); - return; - } - try { - URL skinUrl = URI.create(trimmedHead).toURL(); - if (!"textures.minecraft.net".equalsIgnoreCase(skinUrl.getHost())) { - Bukkit.getLogger().warning("[X-Mas] Ignoring non-Mojang present skin URL: " + trimmedHead); - return; - } - PlayerProfile profile = Bukkit.createProfile(UUID.randomUUID()); - PlayerTextures textures = profile.getTextures(); - textures.setSkin(skinUrl); - profile.setTextures(textures); - skull.setPlayerProfile(profile); - } catch (IllegalArgumentException | MalformedURLException e) { - Bukkit.getLogger().warning("[X-Mas] Invalid present skin URL: " + trimmedHead); - } - } - public boolean canLevelUp() { return getLevelupRequirements().size() == 0; } @@ -300,7 +275,7 @@ private void clearNearbyPresents() { continue; } bl = location.clone().add(x, 0, z).getBlock(); - if (bl.getType() == Material.PLAYER_HEAD) { + if (XMas.isPresentHead(bl)) { bl.setType(Material.AIR); } } @@ -316,14 +291,14 @@ private void refundResources(Player refundTarget) { List leftovers = putRefundsInContainer(Material.CHEST, refundItems); if (leftovers != null) { dropRefunds(leftovers); - notifyRefund(refundTarget, "Your tree resources were returned in a chest."); + notifyRefund(refundTarget, TextUtils.success(LocaleManager.text("messages.tree.refund.chest", "Your tree resources were returned in a chest."))); return; } leftovers = putRefundsInContainer(Material.BARREL, refundItems); if (leftovers != null) { dropRefunds(leftovers); - notifyRefund(refundTarget, "Your tree resources were returned in a barrel."); + notifyRefund(refundTarget, TextUtils.success(LocaleManager.text("messages.tree.refund.barrel", "Your tree resources were returned in a barrel."))); return; } @@ -331,9 +306,9 @@ private void refundResources(Player refundTarget) { dropRefunds(leftovers); if (refundTarget != null) { if (leftovers.isEmpty()) { - notifyRefund(refundTarget, "Your tree resources were returned to your inventory."); + notifyRefund(refundTarget, TextUtils.success(LocaleManager.text("messages.tree.refund.inventory", "Your tree resources were returned to your inventory."))); } else { - notifyRefund(refundTarget, "Your tree resources were returned. Inventory overflow dropped at the tree."); + notifyRefund(refundTarget, TextUtils.info(LocaleManager.text("messages.tree.refund.inventory-overflow", "Your tree resources were returned. Inventory overflow dropped at the tree."))); } } } @@ -359,6 +334,10 @@ private List collectRefundItems() { return refundItems; } + public List getRefundPreviewItems() { + return collectRefundItems(); + } + private void addRequirements(List refundItems, Map requirements) { if (requirements == null || requirements.isEmpty()) { return; @@ -379,7 +358,9 @@ private List putRefundsInContainer(Material containerMaterial, List heads; private static Plugin plugin; private static final int MAX_SERIALIZED_GIFT_LENGTH = 65536; @@ -52,6 +55,20 @@ public class Main extends JavaPlugin implements Listener { private String locale; private XMasPlaceholderExpansion placeholderExpansion; + public record ReloadSummary( + String locale, + int giftCount, + int presentHeadCount, + int treeCount, + int treeOwnerCount, + boolean particlesEnabled, + boolean resourceBack, + boolean legacyAliasEnabled, + float growFirstSoundVolume, + float growRepeatSoundVolume + ) { + } + public static Plugin getInstance() { return plugin; } @@ -68,6 +85,10 @@ public static NamespacedKey getNoDamageFireworkKey() { return noDamageFireworkKey; } + public static NamespacedKey getPresentHeadKey() { + return presentHeadKey; + } + private File getPluginConfigFile() { return new File(getDataFolder(), CONFIG_RESOURCE_PATH); } @@ -104,15 +125,18 @@ public void onEnable() { this.saveDefaults(); crystalKey = new NamespacedKey(this, "xmas_crystal"); noDamageFireworkKey = new NamespacedKey(this, "no_damage_firework"); + presentHeadKey = new NamespacedKey(this, "present_head"); + crystalRecipeKey = new NamespacedKey(this, "xmas"); config = getConfig(); - locale = config.getString("core.locale"); + locale = config.getString("core.locale", LocaleManager.DEFAULT_LOCALE_CODE); + LocaleManager.loadLocale(locale); SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy kk-mm-ss"); inProgress = config.getBoolean("core.plugin-enabled", true); UPDATE_SPEED = config.getInt("core.update-speed"); if (UPDATE_SPEED <= 0) { - TextUtils.sendConsoleMessage("Update speed must be > 0"); - TextUtils.sendConsoleMessage("Setting value to default"); + TextUtils.sendConsoleMessage(TextUtils.warning(LocaleManager.text("console.config.update-speed-invalid", "Update speed must be > 0"))); + TextUtils.sendConsoleMessage(TextUtils.muted(LocaleManager.text("console.config.update-speed-reset", "Setting value to default"))); config.set("core.update-speed", 7); UPDATE_SPEED = 7; } @@ -132,17 +156,16 @@ public void onEnable() { date = sdf.parse(config.getString("core.holiday-ends.date")); endTime = date.getTime(); } catch (ParseException e1) { - TextUtils.sendConsoleMessage("Unable to load date"); + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.config.unable-load-holiday-end-date", "Unable to load holiday end date"))); } defineTreeLevels(); for (World world : getServer().getWorlds()) { TreeSerializer.loadTrees(this, world); } - LocaleManager.loadLocale(locale); heads = config.getStringList("xmas.presents"); if (heads.size() == 0) { - getLogger().warning("[X-Mas] Warning! No heads loaded! Presents can't spawn without box!"); + getLogger().warning(LocaleManager.text("console.gifts.no-heads", "Warning! No heads loaded. Presents cannot spawn without a box head.")); return; } gifts = new ArrayList<>(); @@ -151,11 +174,12 @@ public void onEnable() { if (item != null) { gifts.add(item); } else { - getLogger().warning("[X-Mas] Failed to load gift item: " + serializedItem); + getLogger().warning(LocaleManager.text("console.gifts.load-item-failed", "Failed to load gift item: {item}", + "{item}", serializedItem)); } } if (gifts.size() == 0) { - getLogger().warning("[X-Mas] Warning! No gifts loaded! No X-Mas without gifts!"); + getLogger().warning(LocaleManager.text("console.gifts.no-gifts", "Warning! No gifts loaded. No X-Mas without gifts.")); return; } @@ -164,30 +188,8 @@ public void onEnable() { new Events().registerListener(); new MagicTask(this).runTaskTimer(this, 5, UPDATE_SPEED); new PlayParticlesTask(this).runTaskTimer(this, 5, PARTICLES_DELAY); - XMas.XMAS_CRYSTAL = new ItemMaker(Material.EMERALD, LocaleManager.CRYSTAL_NAME, LocaleManager.CRYSTAL_LORE).make(); - ItemMeta crystalMeta = XMas.XMAS_CRYSTAL.getItemMeta(); - if (crystalMeta != null) { - crystalMeta.getPersistentDataContainer().set(crystalKey, PersistentDataType.BYTE, (byte) 1); - XMas.XMAS_CRYSTAL.setItemMeta(crystalMeta); - } - - ShapedRecipe grinderRecipe; - grinderRecipe = new ShapedRecipe(new NamespacedKey(this, "xmas"), XMas.XMAS_CRYSTAL).shape(" d ", "ded", " d ").setIngredient('d', Material.DIAMOND).setIngredient('e', Material.EMERALD); - Iterator recipes = getServer().recipeIterator(); - boolean registered = false; - while (recipes.hasNext()) { - Recipe recipe = recipes.next(); - if (recipe.equals(grinderRecipe)) { - registered = true; - break; - } - - } - try { - if (!registered) - getServer().addRecipe(grinderRecipe); - } catch (Exception ignored) { - } + refreshCrystalItem(); + registerCrystalRecipe(); XMasCommand.register(this); registerPlaceholderApi(); TextUtils.sendConsoleMessage(LocaleManager.PLUGIN_ENABLED); @@ -205,10 +207,11 @@ public void onWorldUnload(WorldUnloadEvent event) { } } - public void reloadPluginConfig() { + public ReloadSummary reloadPluginConfig() { reloadConfig(); config = getConfig(); - locale = config.getString("core.locale"); + locale = config.getString("core.locale", LocaleManager.DEFAULT_LOCALE_CODE); + LocaleManager.loadLocale(locale); inProgress = config.getBoolean("core.plugin-enabled", true); UPDATE_SPEED = config.getInt("core.update-speed"); @@ -229,11 +232,10 @@ public void reloadPluginConfig() { SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy kk-mm-ss"); endTime = sdf.parse(config.getString("core.holiday-ends.date")).getTime(); } catch (ParseException e) { - TextUtils.sendConsoleMessage("Invalid holiday end date in config.yml"); + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.config.invalid-holiday-end-date", "Invalid holiday end date in config.yml"))); } defineTreeLevels(); - LocaleManager.loadLocale(locale); heads = config.getStringList("xmas.presents"); gifts = new ArrayList<>(); @@ -242,14 +244,36 @@ public void reloadPluginConfig() { if (item != null) { gifts.add(item); } else { - getLogger().warning("[X-Mas] Failed to deserialize gift item: " + serializedItem); + getLogger().warning(LocaleManager.text("console.gifts.deserialize-item-failed", "Failed to deserialize gift item: {item}", + "{item}", serializedItem)); } } LUCK_CHANCE_ENABLED = config.getBoolean("xmas.luck.enabled"); LUCK_CHANCE = (float) config.getInt("xmas.luck.chance") / 100; + refreshCrystalItem(); + registerCrystalRecipe(); XMasCommand.refreshCommandConfiguration(this); - TextUtils.sendConsoleMessage("Configuration reloaded!"); + return createReloadSummary(); + } + + public ReloadSummary createReloadSummary() { + Set owners = new HashSet<>(); + for (MagicTree tree : XMas.getAllTrees()) { + owners.add(tree.getOwner()); + } + return new ReloadSummary( + LocaleManager.getActiveLocaleCode(), + gifts != null ? gifts.size() : 0, + heads != null ? heads.size() : 0, + XMas.getAllTrees().size(), + owners.size(), + particlesEnabled, + resourceBack, + getConfig().getBoolean("core.commands.legacy-command-enabled", true), + growFirstSoundVolume, + growRepeatSoundVolume + ); } private void loadSoundConfig() { @@ -268,7 +292,8 @@ public void addGiftItem(ItemStack item) { ItemStack gift = item.clone(); String serializedItem = serializeItem(gift); if (serializedItem == null) { - getLogger().warning("Failed to serialize item for saving to the gift list. Item: " + gift); + getLogger().warning(LocaleManager.text("console.gifts.serialize-list-item-failed", "Failed to serialize item for saving to the gift list. Item: {item}", + "{item}", gift.toString())); return; } @@ -279,12 +304,67 @@ public void addGiftItem(ItemStack item) { gifts.add(gift); } + public List getGiftItems() { + List snapshot = new ArrayList<>(); + if (gifts == null) { + return snapshot; + } + for (ItemStack gift : gifts) { + if (gift != null) { + snapshot.add(gift.clone()); + } + } + return snapshot; + } + + public ItemStack rollGiftItem() { + if (gifts == null || gifts.isEmpty()) { + return null; + } + return gifts.get(RANDOM.nextInt(gifts.size())).clone(); + } + + public ItemStack removeGiftItem(int displayIndex) { + if (displayIndex < 1) { + return null; + } + + List giftList = config.getStringList("xmas.gifts"); + int validIndex = 0; + for (int rawIndex = 0; rawIndex < giftList.size(); rawIndex++) { + ItemStack item = deserializeItem(giftList.get(rawIndex)); + if (item == null) { + continue; + } + validIndex++; + if (validIndex == displayIndex) { + giftList.remove(rawIndex); + config.set("xmas.gifts", giftList); + saveConfig(); + reloadGiftItemsFromConfig(); + return item; + } + } + return null; + } + + private void reloadGiftItemsFromConfig() { + gifts = new ArrayList<>(); + for (String serializedItem : config.getStringList("xmas.gifts")) { + ItemStack item = deserializeItem(serializedItem); + if (item != null) { + gifts.add(item); + } + } + } + private String serializeItem(ItemStack item) { try { byte[] serializedBytes = item.serializeAsBytes(); return Base64.getEncoder().encodeToString(serializedBytes); } catch (Exception e) { - getLogger().severe("Failed to serialize item: " + e.getMessage()); + getLogger().severe(LocaleManager.text("console.gifts.serialize-item-failed", "Failed to serialize item: {error}", + "{error}", e.getMessage())); return null; } } @@ -304,7 +384,8 @@ public static ItemStack deserializeItem(String serializedItem) { } if (trimmed.length() > MAX_SERIALIZED_GIFT_LENGTH) { - Bukkit.getLogger().severe("[X-Mas] Gift item is too large to deserialize safely: " + trimmed.length() + " characters"); + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.too-large", "Gift item is too large to deserialize safely: {length} characters", + "{length}", Integer.toString(trimmed.length()))); return null; } @@ -312,10 +393,12 @@ public static ItemStack deserializeItem(String serializedItem) { byte[] serializedBytes = Base64.getDecoder().decode(trimmed); return ItemStack.deserializeBytes(serializedBytes); } catch (IllegalArgumentException e) { - Bukkit.getLogger().severe("[X-Mas] Invalid material name or Base64 gift item: " + trimmed); + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.invalid-material-or-base64", "Invalid material name or Base64 gift item: {item}", + "{item}", trimmed)); return null; } catch (Exception e) { - Bukkit.getLogger().severe("[X-Mas] Failed to deserialize gift item: " + e.getMessage()); + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.deserialize-error", "Failed to deserialize gift item: {error}", + "{error}", e.getMessage())); return null; } } @@ -360,14 +443,15 @@ private void registerPlaceholderApi() { } placeholderExpansion = new XMasPlaceholderExpansion(this); if (placeholderExpansion.register()) { - getLogger().info("Registered PlaceholderAPI expansion: " + XMasPlaceholders.IDENTIFIER); + getLogger().info(LocaleManager.text("console.placeholder.registered", "Registered PlaceholderAPI expansion: {identifier}", + "{identifier}", XMasPlaceholders.IDENTIFIER)); } else { - getLogger().warning("PlaceholderAPI is present, but placeholder registration failed."); + getLogger().warning(LocaleManager.text("console.placeholder.registration-failed", "PlaceholderAPI is present, but placeholder registration failed.")); } } public void end() { - Bukkit.broadcast(TextUtils.parse("" + LocaleManager.HAPPY_NEW_YEAR)); + Bukkit.broadcast(TextUtils.parse(TextUtils.accent(LocaleManager.HAPPY_NEW_YEAR))); inProgress = false; config.set("core.plugin-enabled", false); saveConfig(); @@ -375,10 +459,31 @@ public void end() { private void saveDefaults() { reloadConfig(); - plugin.saveResource("locales/default.yml", true); - List defaults = Arrays.asList("locales/en.yml", "locales/ru.yml", "locales/ru_santa.yml", "trees.yml"); - for (String path : defaults) - if (!new File(getDataFolder(), '/' + path).exists()) plugin.saveResource(path, false); + File treesFile = new File(getDataFolder(), TREES_RESOURCE_PATH); + if (!treesFile.exists()) { + plugin.saveResource(TREES_RESOURCE_PATH, false); + } + } + + private void refreshCrystalItem() { + XMas.XMAS_CRYSTAL = new ItemMaker(Material.EMERALD, LocaleManager.CRYSTAL_NAME, LocaleManager.CRYSTAL_LORE).make(); + ItemMeta crystalMeta = XMas.XMAS_CRYSTAL.getItemMeta(); + if (crystalMeta != null) { + crystalMeta.getPersistentDataContainer().set(crystalKey, PersistentDataType.BYTE, (byte) 1); + XMas.XMAS_CRYSTAL.setItemMeta(crystalMeta); + } + } + + private void registerCrystalRecipe() { + getServer().removeRecipe(crystalRecipeKey); + ShapedRecipe grinderRecipe = new ShapedRecipe(crystalRecipeKey, XMas.XMAS_CRYSTAL) + .shape(" d ", "ded", " d ") + .setIngredient('d', Material.DIAMOND) + .setIngredient('e', Material.EMERALD); + try { + getServer().addRecipe(grinderRecipe); + } catch (Exception ignored) { + } } private void defineTreeLevels() { @@ -534,12 +639,15 @@ private ParticleContainer getParticleEffect(String level, String effect, Particl try { particle = Particle.valueOf(configuredParticle.trim().toUpperCase(Locale.ENGLISH)); } catch (IllegalArgumentException e) { - getLogger().warning("[X-Mas] Unknown particle '" + configuredParticle + "' at " + path + ". Using fallback."); + getLogger().warning(LocaleManager.text("console.particles.unknown", "Unknown particle '{particle}' at {path}. Using fallback.", + "{particle}", configuredParticle, + "{path}", path)); return fallback; } if (particle.getDataType() != Void.class && particle != Particle.DUST) { - getLogger().warning("[X-Mas] Particle '" + particle.name() + "' needs extra data and is not supported in config yet. Using fallback."); + getLogger().warning(LocaleManager.text("console.particles.extra-data-unsupported", "Particle '{particle}' needs extra data and is not supported in config yet. Using fallback.", + "{particle}", particle.name())); return fallback; } diff --git a/src/main/java/ru/meloncode/xmas/ParticleContainer.java b/src/main/java/ru/meloncode/xmas/ParticleContainer.java index 6208ddf..0b2db2b 100644 --- a/src/main/java/ru/meloncode/xmas/ParticleContainer.java +++ b/src/main/java/ru/meloncode/xmas/ParticleContainer.java @@ -73,7 +73,9 @@ public void playEffect(Location location) { player.spawnParticle(type, location, count, offsetX, offsetY, offsetZ, speed); } } catch (Exception e) { - Bukkit.getLogger().warning("[X-Mas] Failed to spawn particle " + type + ": " + e.getMessage()); + Bukkit.getLogger().warning(LocaleManager.text("console.particles.spawn-failed", "Failed to spawn particle {particle}: {error}", + "{particle}", type.name(), + "{error}", e.getMessage())); } } } diff --git a/src/main/java/ru/meloncode/xmas/TreeSerializer.java b/src/main/java/ru/meloncode/xmas/TreeSerializer.java index 32904e6..02eb3cd 100644 --- a/src/main/java/ru/meloncode/xmas/TreeSerializer.java +++ b/src/main/java/ru/meloncode/xmas/TreeSerializer.java @@ -2,7 +2,9 @@ import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.Bukkit; import org.bukkit.World; +import org.bukkit.configuration.ConfigurationSection; import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.plugin.java.JavaPlugin; import ru.meloncode.xmas.utils.ConfigUtils; @@ -10,7 +12,13 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; import java.util.UUID; @@ -21,6 +29,38 @@ class TreeSerializer { private static final File treesFile = new File(Main.getInstance().getDataFolder() + "/trees.yml"); private static final FileConfiguration trees = ConfigUtils.loadConfig(treesFile); private static final Set loggedWorldAliasMappings = ConcurrentHashMap.newKeySet(); + private static final DateTimeFormatter BACKUP_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + + public record TreeDataValidationReport( + int storedTreeCount, + int loadedTreeCount, + Set invalidTreeIds, + Set invalidOwners, + Set invalidLevels, + Set invalidLocations, + Set missingWorlds, + Set invalidRequirements, + Set duplicateLocations + ) { + public boolean hasWarnings() { + return !invalidTreeIds.isEmpty() + || !invalidOwners.isEmpty() + || !invalidLevels.isEmpty() + || !invalidLocations.isEmpty() + || !missingWorlds.isEmpty() + || !invalidRequirements.isEmpty() + || !duplicateLocations.isEmpty(); + } + } + + public record WorldMigrationReport( + String sourceWorld, + String targetWorld, + int matchedTrees, + boolean applied, + File backupFile + ) { + } public static void loadTrees(JavaPlugin plugin, World world) { try { @@ -55,14 +95,15 @@ public static void loadTrees(JavaPlugin plugin, World world) { XMas.addMagicTree(new MagicTree(owner, treeUID, level, loc, requirements, presentCounter, scheduledPresents)); } catch (Exception e) { - plugin.getLogger().log(Level.SEVERE, String.format("Error while loading tree `%s`", cKey), e); + plugin.getLogger().log(Level.SEVERE, LocaleManager.text("console.trees.load-tree-error", "Error while loading tree {tree}", + "{tree}", cKey), e); } } } } } catch (Exception e) { - TextUtils.sendConsoleMessage("ERROR WHILE LOADING TREES"); - plugin.getLogger().log(Level.SEVERE, "Unable to load X-Mas trees", e); + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.trees.load-error", "Error while loading trees"))); + plugin.getLogger().log(Level.SEVERE, LocaleManager.text("console.trees.unable-load", "Unable to load X-Mas trees"), e); } } @@ -83,7 +124,7 @@ public static void saveTree(MagicTree tree) { try { trees.save(treesFile); } catch (IOException e) { - Main.getInstance().getLogger().log(Level.SEVERE, "Unable to save X-Mas tree data", e); + Main.getInstance().getLogger().log(Level.SEVERE, LocaleManager.text("console.trees.unable-save", "Unable to save X-Mas tree data"), e); } } @@ -92,8 +133,96 @@ public static void removeTree(MagicTree tree) { try { trees.save(treesFile); } catch (IOException e) { - Main.getInstance().getLogger().log(Level.SEVERE, "Unable to remove X-Mas tree data", e); + Main.getInstance().getLogger().log(Level.SEVERE, LocaleManager.text("console.trees.unable-remove", "Unable to remove X-Mas tree data"), e); + } + } + + public static File getTreesFile() { + return treesFile; + } + + public static File backupTreesFile() throws IOException { + File backupDirectory = new File(Main.getInstance().getDataFolder(), "backups"); + if (!backupDirectory.exists() && !backupDirectory.mkdirs()) { + throw new IOException("Unable to create backup directory " + backupDirectory.getPath()); + } + String timestamp = LocalDateTime.now().format(BACKUP_TIMESTAMP); + File backupFile = new File(backupDirectory, "trees-" + timestamp + ".yml"); + Files.copy(treesFile.toPath(), backupFile.toPath(), StandardCopyOption.COPY_ATTRIBUTES); + return backupFile; + } + + public static TreeDataValidationReport validateTreesFile() { + FileConfiguration data = ConfigUtils.loadConfig(treesFile); + ConfigurationSection treeSection = data.getConfigurationSection("trees"); + Set invalidTreeIds = new LinkedHashSet<>(); + Set invalidOwners = new LinkedHashSet<>(); + Set invalidLevels = new LinkedHashSet<>(); + Set invalidLocations = new LinkedHashSet<>(); + Set missingWorlds = new LinkedHashSet<>(); + Set invalidRequirements = new LinkedHashSet<>(); + Set duplicateLocations = new LinkedHashSet<>(); + Set seenLocations = new HashSet<>(); + + if (treeSection == null) { + return new TreeDataValidationReport( + 0, + XMas.getAllTrees().size(), + invalidTreeIds, + invalidOwners, + invalidLevels, + invalidLocations, + missingWorlds, + invalidRequirements, + duplicateLocations + ); + } + + for (String treeKey : treeSection.getKeys(false)) { + validateTreeId(treeKey, invalidTreeIds); + validateUuid(data.getString("trees." + treeKey + ".owner"), treeKey, invalidOwners); + validateLevel(data.getString("trees." + treeKey + ".level"), treeKey, invalidLevels); + validateLocation(data, treeKey, missingWorlds, invalidLocations, duplicateLocations, seenLocations); + validateRequirements(data.getConfigurationSection("trees." + treeKey + ".levelup"), treeKey, invalidRequirements); } + + return new TreeDataValidationReport( + treeSection.getKeys(false).size(), + XMas.getAllTrees().size(), + invalidTreeIds, + invalidOwners, + invalidLevels, + invalidLocations, + missingWorlds, + invalidRequirements, + duplicateLocations + ); + } + + public static WorldMigrationReport migrateWorldName(String sourceWorld, String targetWorld, boolean apply) throws IOException { + FileConfiguration data = ConfigUtils.loadConfig(treesFile); + ConfigurationSection treeSection = data.getConfigurationSection("trees"); + int matchedTrees = 0; + + if (treeSection != null) { + for (String treeKey : treeSection.getKeys(false)) { + String path = "trees." + treeKey + ".loc.world"; + String savedWorldName = data.getString(path); + if (savedWorldName != null && savedWorldName.equalsIgnoreCase(sourceWorld)) { + matchedTrees++; + if (apply) { + data.set(path, targetWorld); + } + } + } + } + + File backupFile = null; + if (apply && matchedTrees > 0) { + backupFile = backupTreesFile(); + ConfigUtils.saveConfig(treesFile, data); + } + return new WorldMigrationReport(sourceWorld, targetWorld, matchedTrees, apply, backupFile); } public static Map convertRequirementsMap(Map map) { @@ -105,18 +234,21 @@ public static Map convertRequirementsMap(Map try { cMaterial = Material.matchMaterial(sMaterial); if (cMaterial == null || cMaterial.isLegacy()) { - TextUtils.sendConsoleMessage("Can't find modern material '" + sMaterial + "' for tree level."); + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.trees.material-missing", "Cannot find modern material '{material}' for tree level.", + "{material}", sMaterial))); continue; } Object rawValue = map.get(sMaterial); if (!(rawValue instanceof Number)) { - TextUtils.sendConsoleMessage("Tree level material '" + sMaterial + "' must use a numeric amount."); + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.trees.material-numeric-required", "Tree level material '{material}' must use a numeric amount.", + "{material}", sMaterial))); continue; } value = ((Number) rawValue).intValue(); levelupRequirements.put(cMaterial, value); } catch (IllegalArgumentException e) { - TextUtils.sendConsoleMessage("Can't load material '" + sMaterial + "' for tree level."); + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.trees.material-load-failed", "Cannot load material '{material}' for tree level.", + "{material}", sMaterial))); } } return levelupRequirements; @@ -148,7 +280,96 @@ private static String getWorldAlias(String savedWorldName) { private static void logWorldAliasMapping(String savedWorldName, String worldName) { String mapping = savedWorldName + "->" + worldName; if (loggedWorldAliasMappings.add(mapping)) { - Main.getInstance().getLogger().info("Loading legacy X-Mas trees from saved world '" + savedWorldName + "' into '" + worldName + "' via migration.world-aliases."); + Main.getInstance().getLogger().info(LocaleManager.text("console.trees.world-alias", "Loading legacy X-Mas trees from saved world '{source}' into '{target}' via migration.world-aliases.", + "{source}", savedWorldName, + "{target}", worldName)); + } + } + + private static void validateTreeId(String treeKey, Set invalidTreeIds) { + try { + UUID.fromString(treeKey); + } catch (IllegalArgumentException exception) { + invalidTreeIds.add(treeKey); + } + } + + private static void validateUuid(String rawUuid, String treeKey, Set invalidOwners) { + try { + UUID.fromString(rawUuid); + } catch (IllegalArgumentException | NullPointerException exception) { + invalidOwners.add(treeKey); + } + } + + private static void validateLevel(String levelName, String treeKey, Set invalidLevels) { + try { + TreeLevel.fromString(levelName); + } catch (IllegalArgumentException exception) { + invalidLevels.add(treeKey + "=" + levelName); + } + } + + private static void validateLocation(FileConfiguration data, String treeKey, Set missingWorlds, Set invalidLocations, + Set duplicateLocations, Set seenLocations) { + String basePath = "trees." + treeKey + ".loc"; + String savedWorldName = data.getString(basePath + ".world"); + String resolvedWorldName = resolveWorldName(savedWorldName); + if (savedWorldName == null || savedWorldName.isBlank()) { + invalidLocations.add(treeKey + "=missing-world"); + return; + } + if (resolvedWorldName == null) { + missingWorlds.add(savedWorldName); + } + if (!data.isSet(basePath + ".x") || !data.isSet(basePath + ".y") || !data.isSet(basePath + ".z")) { + invalidLocations.add(treeKey + "=missing-coordinate"); + return; + } + + int x = data.getInt(basePath + ".x"); + int y = data.getInt(basePath + ".y"); + int z = data.getInt(basePath + ".z"); + if (y < -64 || y > 512) { + invalidLocations.add(treeKey + "=y:" + y); + } + + String locationKey = (resolvedWorldName != null ? resolvedWorldName : savedWorldName).toLowerCase() + ":" + x + ":" + y + ":" + z; + if (!seenLocations.add(locationKey)) { + duplicateLocations.add(locationKey); + } + } + + private static String resolveWorldName(String savedWorldName) { + if (savedWorldName == null || savedWorldName.isBlank()) { + return null; + } + World exactWorld = Bukkit.getWorld(savedWorldName); + if (exactWorld != null) { + return exactWorld.getName(); + } + String aliasWorldName = getWorldAlias(savedWorldName); + if (aliasWorldName == null || aliasWorldName.isBlank()) { + return null; + } + World aliasWorld = Bukkit.getWorld(aliasWorldName); + return aliasWorld != null ? aliasWorld.getName() : null; + } + + private static void validateRequirements(ConfigurationSection section, String treeKey, Set invalidRequirements) { + if (section == null) { + return; + } + for (String materialName : section.getKeys(false)) { + Material material = Material.matchMaterial(materialName); + if (material == null || material.isLegacy()) { + invalidRequirements.add(treeKey + "=" + materialName); + continue; + } + Object amount = section.get(materialName); + if (!(amount instanceof Number) || ((Number) amount).intValue() < 0) { + invalidRequirements.add(treeKey + "=" + materialName + ":" + amount); + } } } } diff --git a/src/main/java/ru/meloncode/xmas/XMas.java b/src/main/java/ru/meloncode/xmas/XMas.java index 3c4d7e7..20bafbb 100644 --- a/src/main/java/ru/meloncode/xmas/XMas.java +++ b/src/main/java/ru/meloncode/xmas/XMas.java @@ -8,6 +8,7 @@ import org.bukkit.block.Skull; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.persistence.PersistentDataType; import ru.meloncode.xmas.utils.LocationUtils; import ru.meloncode.xmas.utils.TextUtils; @@ -69,36 +70,34 @@ public static void removeTree(MagicTree tree, boolean unbuild) { } public static void processPresent(Block block, Player player) { - if (block.getType() == Material.PLAYER_HEAD) { - Skull skull = (Skull) block.getState(); - String headId = getHeadIdentifier(skull); - - if (headId != null && Main.getHeads().contains(headId)) { - Location loc = block.getLocation(); - World world = loc.getWorld(); - if (world != null) { - if (RANDOM.nextFloat() < Main.LUCK_CHANCE || !Main.LUCK_CHANCE_ENABLED) { - world.dropItemNaturally(loc, new ItemStack(Main.gifts.get(RANDOM.nextInt(Main.gifts.size())))); - Effects.TREE_SWAG.playEffect(loc); - TextUtils.sendMessage(player, LocaleManager.GIFT_LUCK); - } else { - Effects.SMOKE.playEffect(loc); - world.dropItemNaturally(loc, new ItemStack(Material.COAL)); - TextUtils.sendMessage(player, LocaleManager.GIFT_FAIL); - } - } - block.setType(Material.AIR); + if (!isPresentHead(block)) { + return; + } + + Location loc = block.getLocation(); + World world = loc.getWorld(); + if (world != null) { + if (RANDOM.nextFloat() < Main.LUCK_CHANCE || !Main.LUCK_CHANCE_ENABLED) { + world.dropItemNaturally(loc, new ItemStack(Main.gifts.get(RANDOM.nextInt(Main.gifts.size())))); + Effects.TREE_SWAG.playEffect(loc); + TextUtils.sendMessage(player, LocaleManager.GIFT_LUCK); + } else { + Effects.SMOKE.playEffect(loc); + world.dropItemNaturally(loc, new ItemStack(Material.COAL)); + TextUtils.sendMessage(player, LocaleManager.GIFT_FAIL); } + block.setType(Material.AIR); } } - static String getHeadIdentifier(Skull skull) { - if (skull.getPlayerProfile() != null - && skull.getPlayerProfile().getTextures() != null - && skull.getPlayerProfile().getTextures().getSkin() != null) { - return skull.getPlayerProfile().getTextures().getSkin().toString(); + public static boolean isPresentHead(Block block) { + if (block == null || block.getType() != Material.PLAYER_HEAD) { + return false; + } + if (!(block.getState() instanceof Skull skull)) { + return false; } - return skull.getOwningPlayer() != null ? skull.getOwningPlayer().getName() : null; + return skull.getPersistentDataContainer().has(Main.getPresentHeadKey(), PersistentDataType.BYTE); } public static List getTreesPlayerOwn(Player player) { diff --git a/src/main/java/ru/meloncode/xmas/XMasCommand.java b/src/main/java/ru/meloncode/xmas/XMasCommand.java index 821a0d1..d21333e 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -1,6 +1,12 @@ package ru.meloncode.xmas; import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.block.Block; import org.bukkit.command.CommandMap; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; @@ -10,14 +16,19 @@ import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; import ru.meloncode.xmas.utils.TextUtils; +import java.io.File; +import java.io.IOException; import java.lang.reflect.Constructor; import java.lang.reflect.Field; import java.text.SimpleDateFormat; import java.util.*; public class XMasCommand implements CommandExecutor, TabCompleter { + private static final PlainTextComponentSerializer PLAIN_TEXT = PlainTextComponentSerializer.plainText(); public static final String PRIMARY_COMMAND = "xmastree"; public static final String LEGACY_COMMAND = "xmas"; public static final String PERMISSION_ADMIN = "onembxmastree.admin"; @@ -29,9 +40,12 @@ public class XMasCommand implements CommandExecutor, TabCompleter { public static final String PERMISSION_RELOAD = "onembxmastree.command.reload"; public static final String PERMISSION_DEBUG = "onembxmastree.command.debug"; public static final String PERMISSION_DEBUG_TOGGLE = "onembxmastree.command.debug.toggle"; + public static final String PERMISSION_INSPECT = "onembxmastree.command.inspect"; + public static final String PERMISSION_TEST = "onembxmastree.command.test"; + public static final String PERMISSION_DATA = "onembxmastree.command.data"; public static final String PERMISSION_END = "onembxmastree.command.end"; public static final String PERMISSION_TREE_OVERRIDE = "onembxmastree.tree.override"; - private static final List COMMANDS = Arrays.asList("help", "give", "end", "gifts", "reload", "addhand", "debug"); + private static final List COMMANDS = Arrays.asList("help", "give", "end", "gifts", "reload", "addhand", "debug", "inspect", "test", "data"); private static final Set DEBUG_TOGGLE_KEYS = new LinkedHashSet<>(Arrays.asList( "core.commands.legacy-command-enabled", "core.plugin-enabled", @@ -41,7 +55,6 @@ public class XMasCommand implements CommandExecutor, TabCompleter { "xmas.luck.enabled" )); private static final Map DEBUG_SECTIONS = createDebugSections(); - private static final Map PERMISSIONS = createPermissionDescriptions(); private static XMasCommand registeredExecutor; private static PluginCommand legacyAliasCommand; @@ -117,13 +130,7 @@ public boolean onCommand(CommandSender sender, Command command, String label, St sendNoPermission(sender); break; } - Random random = new Random(); - for (MagicTree magicTree : XMas.getAllTrees()) { - for (int i = 0; i < 3 + random.nextInt(4); i++) { - magicTree.spawnPresent(); - } - } - Bukkit.broadcast(TextUtils.parse(LocaleManager.COMMAND_GIVEAWAY)); + handleGifts(sender, args); break; } case "reload": { @@ -131,13 +138,40 @@ public boolean onCommand(CommandSender sender, Command command, String label, St sendNoPermission(sender); break; } - plugin.reloadPluginConfig(); - TextUtils.sendRawMessage(sender, "" + TextUtils.DISPLAY_NAME + " configuration reloaded."); + Main.ReloadSummary summary = plugin.reloadPluginConfig(); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.reload-success", TextUtils.success(TextUtils.displayName() + " configuration reloaded."))); + for (String line : getReloadSummaryLines(summary)) { + TextUtils.sendRawMessage(sender, line); + } + break; + } + case "inspect": { + if (!hasPermission(sender, PERMISSION_INSPECT)) { + sendNoPermission(sender); + break; + } + handleInspect(sender, args); + break; + } + case "test": { + if (!hasPermission(sender, PERMISSION_TEST)) { + sendNoPermission(sender); + break; + } + handleTest(sender, args); + break; + } + case "data": { + if (!hasPermission(sender, PERMISSION_DATA)) { + sendNoPermission(sender); + break; + } + handleData(sender, args); break; } case "addhand": { if (!(sender instanceof Player player)) { - TextUtils.sendRawMessage(sender, "Only players can use this command."); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-only", TextUtils.error("Only players can use this command."))); break; } if (!hasPermission(sender, PERMISSION_ADDHAND)) { @@ -146,11 +180,15 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } ItemStack item = player.getInventory().getItemInMainHand(); if (item.getType().isAir()) { - TextUtils.sendRawMessage(player, "Hold an item before running " + commandPath("addhand") + "."); + TextUtils.sendRawMessage(player, replaceToken( + LocaleManager.text("command.hold-item-first", TextUtils.error("Hold an item before running {command}.")), + "command", + commandPath("addhand") + )); break; } plugin.addGiftItem(item.clone()); - TextUtils.sendRawMessage(player, "Added the held item to the gift list."); + TextUtils.sendRawMessage(player, LocaleManager.text("command.gift-added", TextUtils.success("Added the held item to the gift list."))); break; } case "debug": { @@ -188,6 +226,53 @@ public List onTabComplete(CommandSender sender, Command command, String suggestions.add(player.getName()); } } + } else if (args[0].equalsIgnoreCase("inspect")) { + if (args.length == 2) { + List inspectSuggestions = new ArrayList<>(); + inspectSuggestions.add("nearest"); + for (MagicTree tree : XMas.getAllTrees()) { + inspectSuggestions.add(tree.getTreeUID().toString()); + } + suggestions.addAll(filterStartingWith(inspectSuggestions, args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("nearest")) { + String typed = args[2].toLowerCase(Locale.ENGLISH); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase(Locale.ENGLISH).startsWith(typed)) { + suggestions.add(player.getName()); + } + } + } + } else if (args[0].equalsIgnoreCase("test")) { + if (args.length == 2) { + suggestions.addAll(filterStartingWith(Arrays.asList("sound", "particle"), args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("sound")) { + suggestions.addAll(filterStartingWith(Arrays.asList("first", "repeat"), args[2])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("particle")) { + suggestions.addAll(filterStartingWith(treeLevelNames(), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("particle")) { + suggestions.addAll(filterStartingWith(Arrays.asList("all", "ambient", "swag", "body"), args[3])); + } else if ((args.length == 4 && args[1].equalsIgnoreCase("sound")) || (args.length == 5 && args[1].equalsIgnoreCase("particle"))) { + String typed = args[args.length - 1].toLowerCase(Locale.ENGLISH); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase(Locale.ENGLISH).startsWith(typed)) { + suggestions.add(player.getName()); + } + } + } + } else if (args[0].equalsIgnoreCase("data")) { + if (args.length == 2) { + suggestions.addAll(filterStartingWith(Arrays.asList("backup", "validate", "migrate-world"), args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("migrate-world")) { + suggestions.addAll(filterStartingWith(getWorldNames(), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("migrate-world")) { + suggestions.addAll(filterStartingWith(getWorldNames(), args[3])); + } else if (args.length == 5 && args[1].equalsIgnoreCase("migrate-world")) { + suggestions.addAll(filterStartingWith(Arrays.asList("dry-run", "apply"), args[4])); + } + } else if (args[0].equalsIgnoreCase("gifts")) { + if (args.length == 2) { + suggestions.addAll(filterStartingWith(Arrays.asList("list", "roll", "remove", "spawn"), args[1])); + } } else if (args[0].equalsIgnoreCase("debug")) { if (args.length == 2) { List debugSuggestions = new ArrayList<>(DEBUG_SECTIONS.keySet()); @@ -204,6 +289,464 @@ public List onTabComplete(CommandSender sender, Command command, String return suggestions; } + private void handleGifts(CommandSender sender, String[] args) { + if (args.length < 2 || args[1].equalsIgnoreCase("spawn")) { + spawnPresentsUnderTrees(); + return; + } + if (args[1].equalsIgnoreCase("list")) { + int page = parsePositiveInt(args.length >= 3 ? args[2] : null, 1); + sendGiftList(sender, page); + return; + } + if (args[1].equalsIgnoreCase("roll")) { + sendGiftRoll(sender); + return; + } + if (args[1].equalsIgnoreCase("remove")) { + handleGiftRemove(sender, args); + return; + } + + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.unknown-action", "Unknown Gift Action"), TextUtils.error(args[1]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.usage", "Usage"), TextUtils.command(commandPath("gifts list")))); + } + + private void spawnPresentsUnderTrees() { + Random random = new Random(); + for (MagicTree magicTree : XMas.getAllTrees()) { + for (int i = 0; i < 3 + random.nextInt(4); i++) { + magicTree.spawnPresent(); + } + } + Bukkit.broadcast(TextUtils.parse(LocaleManager.COMMAND_GIVEAWAY)); + } + + private void sendGiftList(CommandSender sender, int page) { + List giftItems = plugin.getGiftItems(); + int pageSize = 8; + int pageCount = Math.max(1, (int) Math.ceil(giftItems.size() / (double) pageSize)); + page = Math.max(1, Math.min(page, pageCount)); + int start = (page - 1) * pageSize; + int end = Math.min(giftItems.size(), start + pageSize); + + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.title", TextUtils.title("Gift Pool")) + " " + TextUtils.muted("(" + page + "/" + pageCount + ")")); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.total", "Total"), TextUtils.text(Integer.toString(giftItems.size())))); + if (giftItems.isEmpty()) { + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.values.empty", TextUtils.warning("No gifts are configured."))); + return; + } + for (int index = start; index < end; index++) { + TextUtils.sendRawMessage(sender, formatStyledListEntry(Integer.toString(index + 1), formatGiftItem(giftItems.get(index)))); + } + if (page < pageCount) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.next", "Next"), TextUtils.command(commandPath("gifts list " + (page + 1))))); + } + } + + private void sendGiftRoll(CommandSender sender) { + ItemStack gift = plugin.rollGiftItem(); + if (gift == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.values.empty", TextUtils.warning("No gifts are configured."))); + return; + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.rolled", "Rolled"), formatGiftItem(gift))); + } + + private void handleGiftRemove(CommandSender sender, String[] args) { + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.usage", "Usage"), TextUtils.command(commandPath("gifts remove ")))); + return; + } + int index = parsePositiveInt(args[2], -1); + if (index < 1) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.index", "Index"), TextUtils.error(args[2]))); + return; + } + ItemStack removed = plugin.removeGiftItem(index); + if (removed == null) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.index", "Index"), TextUtils.error(Integer.toString(index)))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.try", "Try"), TextUtils.command(commandPath("gifts list")))); + return; + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.removed", "Removed"), formatGiftItem(removed))); + } + + private void handleTest(CommandSender sender, String[] args) { + if (args.length < 2) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test sound first|repeat [player]")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test particle [all|ambient|swag|body] [player]")))); + return; + } + + if (args[1].equalsIgnoreCase("sound")) { + handleTestSound(sender, args); + return; + } + if (args[1].equalsIgnoreCase("particle")) { + handleTestParticle(sender, args); + return; + } + + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.unknown-test", "Unknown Test"), TextUtils.error(args[1]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.try", "Try"), TextUtils.command(commandPath("test sound first")))); + } + + private void handleTestSound(CommandSender sender, String[] args) { + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test sound first|repeat [player]")))); + return; + } + + boolean repeat; + if (args[2].equalsIgnoreCase("first")) { + repeat = false; + } else if (args[2].equalsIgnoreCase("repeat")) { + repeat = true; + } else { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.sound", "Sound"), TextUtils.error(args[2]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.try", "Try"), TextUtils.command(commandPath("test sound first")))); + return; + } + + Player target = resolveCommandTarget(sender, args, 3, commandPath("test sound first ")); + if (target == null) { + return; + } + + float volume = repeat ? Main.growRepeatSoundVolume : Main.growFirstSoundVolume; + if (volume > 0) { + target.playSound(target.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, volume, 0.8f); + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.sound", "Sound"), TextUtils.text(repeat ? "repeat" : "first"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.target", "Target"), TextUtils.text(target.getName()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.volume", "Volume"), TextUtils.text(Float.toString(volume)))); + } + + private void handleTestParticle(CommandSender sender, String[] args) { + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test particle [all|ambient|swag|body] [player]")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.levels", "Levels"), TextUtils.text(String.join(", ", treeLevelNames())))); + return; + } + + TreeLevel level = treeLevelByName(args[2]); + if (level == null) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.level", "Level"), TextUtils.error(args[2]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.levels", "Levels"), TextUtils.text(String.join(", ", treeLevelNames())))); + return; + } + + String effectName = "all"; + int targetArgIndex = 3; + if (args.length >= 4 && isParticleEffectName(args[3])) { + effectName = args[3].toLowerCase(Locale.ENGLISH); + targetArgIndex = 4; + } + + Player target = resolveCommandTarget(sender, args, targetArgIndex, commandPath("test particle [all|ambient|swag|body] ")); + if (target == null) { + return; + } + + int played = playParticlePreview(level, effectName, target.getLocation()); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.particle", "Particle"), TextUtils.text(level.getLevelName() + " " + effectName))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.target", "Target"), TextUtils.text(target.getName()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.effects-played", "Effects Played"), TextUtils.text(Integer.toString(played)))); + if (played == 0) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.test.no-particles", TextUtils.warning("No enabled particle effects matched that preview."))); + } + } + + private void handleData(CommandSender sender, String[] args) { + if (args.length < 2) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data backup")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data validate")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data migrate-world [dry-run|apply]")))); + return; + } + + if (args[1].equalsIgnoreCase("backup")) { + handleDataBackup(sender); + return; + } + if (args[1].equalsIgnoreCase("validate")) { + handleDataValidate(sender); + return; + } + if (args[1].equalsIgnoreCase("migrate-world")) { + handleDataMigrateWorld(sender, args); + return; + } + + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.unknown-action", "Unknown Data Action"), TextUtils.error(args[1]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.try", "Try"), TextUtils.command(commandPath("data validate")))); + } + + private void handleDataBackup(CommandSender sender) { + try { + File backupFile = TreeSerializer.backupTreesFile(); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-created", TextUtils.success("Tree data backup created."))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.file", "File"), TextUtils.text(backupFile.getPath()))); + } catch (IOException exception) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-failed", TextUtils.error("Unable to back up trees.yml."))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.error", "Error"), TextUtils.error(exception.getMessage()))); + } + } + + private void handleDataValidate(CommandSender sender) { + TreeSerializer.TreeDataValidationReport report = TreeSerializer.validateTreesFile(); + TextUtils.sendRawMessage(sender, formatSectionTitle(LocaleManager.text("ui.data.title", "Tree Data Validation"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.file", "File"), TextUtils.text(TreeSerializer.getTreesFile().getPath()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.stored-trees", "Stored Trees"), TextUtils.text(Integer.toString(report.storedTreeCount())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.loaded-trees", "Loaded Trees"), TextUtils.text(Integer.toString(report.loadedTreeCount())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.status", "Status"), report.hasWarnings() + ? LocaleManager.text("ui.data.values.warnings", TextUtils.warning("warnings")) + : LocaleManager.text("ui.data.values.clean", TextUtils.success("clean")))); + sendValidationSet(sender, "invalid-tree-ids", report.invalidTreeIds()); + sendValidationSet(sender, "invalid-owners", report.invalidOwners()); + sendValidationSet(sender, "invalid-levels", report.invalidLevels()); + sendValidationSet(sender, "invalid-locations", report.invalidLocations()); + sendValidationSet(sender, "missing-worlds", report.missingWorlds()); + sendValidationSet(sender, "invalid-requirements", report.invalidRequirements()); + sendValidationSet(sender, "duplicate-locations", report.duplicateLocations()); + } + + private void handleDataMigrateWorld(CommandSender sender, String[] args) { + if (args.length < 4) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data migrate-world [dry-run|apply]")))); + return; + } + + String sourceWorld = args[2]; + String targetWorld = args[3]; + boolean apply = args.length >= 5 && args[4].equalsIgnoreCase("apply"); + if (args.length >= 5 && !args[4].equalsIgnoreCase("apply") && !args[4].equalsIgnoreCase("dry-run")) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.mode", "Mode"), TextUtils.error(args[4]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data migrate-world [dry-run|apply]")))); + return; + } + + try { + TreeSerializer.WorldMigrationReport report = TreeSerializer.migrateWorldName(sourceWorld, targetWorld, apply); + TextUtils.sendRawMessage(sender, formatSectionTitle(LocaleManager.text("ui.data.world-migration-title", "Tree World Migration"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.source-world", "Source World"), TextUtils.text(report.sourceWorld()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.target-world", "Target World"), TextUtils.text(report.targetWorld()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.matched-trees", "Matched Trees"), TextUtils.text(Integer.toString(report.matchedTrees())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.mode", "Mode"), TextUtils.text(report.applied() ? "apply" : "dry-run"))); + if (report.backupFile() != null) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.backup", "Backup"), TextUtils.text(report.backupFile().getPath()))); + } + if (!report.applied()) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.apply-command", "Apply Command"), TextUtils.command(commandPath("data migrate-world " + sourceWorld + " " + targetWorld + " apply")))); + } else { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.migration-applied", TextUtils.warning("World migration saved. Restart the server before testing migrated trees."))); + } + } catch (IOException exception) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.migration-failed", TextUtils.error("Unable to migrate tree world names."))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.error", "Error"), TextUtils.error(exception.getMessage()))); + } + } + + private void sendValidationSet(CommandSender sender, String labelKey, Set values) { + if (values == null || values.isEmpty()) { + return; + } + String label = LocaleManager.text("ui.data.labels." + labelKey, labelKey); + TextUtils.sendRawMessage(sender, formatKeyValue(label, TextUtils.warning(joinLimited(values, 8)))); + } + + private String joinLimited(Set values, int limit) { + List limited = new ArrayList<>(); + int index = 0; + for (String value : values) { + if (index >= limit) { + break; + } + limited.add(value); + index++; + } + if (values.size() > limit) { + limited.add("+" + (values.size() - limit) + " more"); + } + return String.join(", ", limited); + } + + private int parsePositiveInt(String rawValue, int fallback) { + if (rawValue == null || rawValue.isBlank()) { + return fallback; + } + try { + return Integer.parseInt(rawValue); + } catch (NumberFormatException exception) { + return fallback; + } + } + + private Player resolveCommandTarget(CommandSender sender, String[] args, int targetArgIndex, String consoleUsage) { + if (args.length > targetArgIndex) { + Player target = Bukkit.getPlayer(args[targetArgIndex]); + if (target == null) { + TextUtils.sendMessage(sender, LocaleManager.COMMAND_PLAYER_OFFLINE); + } + return target; + } + if (sender instanceof Player player) { + return player; + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(consoleUsage))); + return null; + } + + private int playParticlePreview(TreeLevel level, String effectName, Location location) { + int played = 0; + if ((effectName.equals("all") || effectName.equals("ambient")) && level.getAmbientEffect() != null) { + level.getAmbientEffect().playEffect(location); + played++; + } + if ((effectName.equals("all") || effectName.equals("swag")) && level.getSwagEffect() != null) { + level.getSwagEffect().playEffect(location); + played++; + } + if ((effectName.equals("all") || effectName.equals("body")) && level.getBodyEffect() != null) { + level.getBodyEffect().playEffect(location); + played++; + } + return played; + } + + private boolean isParticleEffectName(String value) { + return value != null + && (value.equalsIgnoreCase("all") + || value.equalsIgnoreCase("ambient") + || value.equalsIgnoreCase("swag") + || value.equalsIgnoreCase("body")); + } + + private TreeLevel treeLevelByName(String levelName) { + try { + return TreeLevel.fromString(levelName); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private List treeLevelNames() { + return Arrays.asList("sapling", "small_tree", "tree", "magic_tree"); + } + + private void handleInspect(CommandSender sender, String[] args) { + MagicTree tree = null; + if (args.length >= 2 && !args[1].equalsIgnoreCase("nearest")) { + tree = findTreeByUuid(args[1]); + if (tree == null) { + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.requested", "Requested"), + TextUtils.error(args[1]) + )); + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.try", "Try"), + TextUtils.command(commandPath("inspect nearest")) + )); + return; + } + } else if (args.length >= 3 && args[1].equalsIgnoreCase("nearest")) { + Player target = Bukkit.getPlayer(args[2]); + if (target == null) { + TextUtils.sendMessage(sender, LocaleManager.COMMAND_PLAYER_OFFLINE); + return; + } + tree = findNearestTree(target.getLocation(), 16); + } else if (sender instanceof Player player) { + tree = findTargetTree(player); + if (tree == null) { + tree = findNearestTree(player.getLocation(), 8); + } + } else { + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.usage", "Usage"), + TextUtils.command(commandPath("inspect ")) + )); + return; + } + + if (tree == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.inspect.not-found", TextUtils.warning("No XMas Tree found nearby or in your line of sight."))); + return; + } + for (String line : getInspectLines(tree)) { + TextUtils.sendRawMessage(sender, line); + } + } + + private MagicTree findTreeByUuid(String rawUuid) { + try { + return XMas.getTree(UUID.fromString(rawUuid)); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private MagicTree findTargetTree(Player player) { + Block targetBlock = player.getTargetBlockExact(8); + return MagicTree.getTreeByBlock(targetBlock); + } + + private MagicTree findNearestTree(Location location, double range) { + if (location == null || location.getWorld() == null) { + return null; + } + double maxDistanceSquared = range * range; + MagicTree nearest = null; + double nearestDistanceSquared = Double.MAX_VALUE; + for (MagicTree tree : XMas.getAllTrees()) { + Location treeLocation = tree.getLocation(); + if (treeLocation.getWorld() == null || !treeLocation.getWorld().equals(location.getWorld())) { + continue; + } + double distanceSquared = treeLocation.distanceSquared(location); + if (distanceSquared <= maxDistanceSquared && distanceSquared < nearestDistanceSquared) { + nearest = tree; + nearestDistanceSquared = distanceSquared; + } + } + return nearest; + } + + private List getInspectLines(MagicTree tree) { + List lines = new ArrayList<>(); + OfflinePlayer owner = Bukkit.getOfflinePlayer(tree.getOwner()); + Location location = tree.getLocation(); + String ownerName = owner.getName() != null ? owner.getName() : LocaleManager.text("ui.inspect.values.unknown-owner", "unknown"); + + lines.add(LocaleManager.text("ui.inspect.title", TextUtils.title(TextUtils.displayName() + " Tree Inspect"))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.owner", "Owner"), TextUtils.text(ownerName) + TextUtils.muted(" (" + tree.getOwner() + ")"))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.tree-id", "Tree ID"), TextUtils.text(tree.getTreeUID().toString()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.level", "Level"), TextUtils.text(tree.getLevel().getLevelName()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.location", "Location"), TextUtils.text(formatLocation(location)))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.can-level-up", "Can Level Up"), TextUtils.booleanValue(tree.canLevelUp()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.present-timer", "Present Timer"), TextUtils.text(Long.toString(tree.getPresentCounter())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.scheduled-presents", "Scheduled Presents"), TextUtils.text(Integer.toString(tree.getScheduledPresents())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.remaining", "Remaining Requirements"), TextUtils.text(formatRequirements(tree.getLevelupRequirements())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.refund-preview", "Refund Preview"), TextUtils.text(formatItems(tree.getRefundPreviewItems())))); + return lines; + } + + private List getReloadSummaryLines(Main.ReloadSummary summary) { + List lines = new ArrayList<>(); + lines.add(formatSectionTitle(LocaleManager.text("ui.reload.title", "Reload Summary"))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.locale", "Locale"), TextUtils.text(summary.locale()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.gifts", "Gifts"), TextUtils.text(Integer.toString(summary.giftCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.present-heads", "Present Heads"), TextUtils.text(Integer.toString(summary.presentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.trees", "Trees"), TextUtils.text(Integer.toString(summary.treeCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.owners", "Owners"), TextUtils.text(Integer.toString(summary.treeOwnerCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.particles", "Particles"), TextUtils.booleanValue(summary.particlesEnabled()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.resource-back", "Resource Back"), TextUtils.booleanValue(summary.resourceBack()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.legacy-alias", "Legacy Alias"), TextUtils.booleanValue(summary.legacyAliasEnabled()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.sounds", "Sounds"), TextUtils.text("first=" + summary.growFirstSoundVolume() + ", repeat=" + summary.growRepeatSoundVolume()))); + return lines; + } + private void sendStatus(CommandSender sender) { for (String line : getStatusLines()) { TextUtils.sendRawMessage(sender, line); @@ -220,20 +763,31 @@ private List getStatusLines() { SimpleDateFormat sdf = new SimpleDateFormat("dd-MM-yyyy kk-mm-ss"); List lines = new ArrayList<>(); - lines.add("" + TextUtils.DISPLAY_NAME + " " + plugin.getPluginMeta().getVersion() + " Plugin Status"); + lines.add( + replaceToken( + LocaleManager.text("ui.status.title", TextUtils.title(TextUtils.displayName()) + " " + TextUtils.text("{version}") + " " + TextUtils.title("Plugin Status")), + "version", + plugin.getPluginMeta().getVersion() + ) + ); lines.add(""); - lines.add(formatKeyValue("Event Status", Main.inProgress ? "In Progress" : "Holidays End")); + lines.add(formatKeyValue( + LocaleManager.text("ui.status.labels.event-status", "Event Status"), + Main.inProgress + ? LocaleManager.text("ui.status.values.in-progress", TextUtils.success("In Progress")) + : LocaleManager.text("ui.status.values.holidays-end", TextUtils.error("Holidays End")) + )); if (Main.inProgress) { - lines.add(formatKeyValue("Current Time", "" + sdf.format(System.currentTimeMillis()) + "")); - lines.add(formatKeyValue("Holidays End", "" + sdf.format(Main.endTime) + "")); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.current-time", "Current Time"), TextUtils.text(sdf.format(System.currentTimeMillis())))); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.holidays-end", "Holidays End"), TextUtils.text(sdf.format(Main.endTime)))); } - lines.add(formatKeyValue("Auto-End", booleanValue(Main.autoEnd))); - lines.add(formatKeyValue("Resource Back", booleanValue(Main.resourceBack))); - lines.add(formatKeyValue("Particles", booleanValue(Main.particlesEnabled))); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.auto-end", "Auto-End"), TextUtils.booleanValue(Main.autoEnd))); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.resource-back", "Resource Back"), TextUtils.booleanValue(Main.resourceBack))); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.particles", "Particles"), TextUtils.booleanValue(Main.particlesEnabled))); lines.add(""); - lines.add(formatKeyValue("Loaded Trees", "" + treeCount + "")); - lines.add(formatKeyValue("Tree Owners", "" + owners.size() + "")); - lines.add(formatKeyValue("Help", "" + commandPath("help") + "")); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.loaded-trees", "Loaded Trees"), TextUtils.text(Integer.toString(treeCount)))); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.tree-owners", "Tree Owners"), TextUtils.text(Integer.toString(owners.size())))); + lines.add(formatKeyValue(LocaleManager.text("ui.status.labels.help", "Help"), TextUtils.command(commandPath("help")))); return lines; } @@ -275,19 +829,19 @@ private void handleDebug(CommandSender sender, String[] args) { private void handleDebugToggle(CommandSender sender, String[] args) { if (args.length < 4) { - TextUtils.sendRawMessage(sender, formatKeyValue("Usage", "" + commandPath("debug toggle true|false") + "")); - TextUtils.sendRawMessage(sender, formatKeyValue("Keys", "" + String.join(", ", DEBUG_TOGGLE_KEYS) + "")); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.usage", "Usage"), TextUtils.command(commandPath("debug toggle true|false")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.keys", "Keys"), TextUtils.text(String.join(", ", DEBUG_TOGGLE_KEYS)))); return; } String key = args[2].toLowerCase(Locale.ENGLISH); if (!DEBUG_TOGGLE_KEYS.contains(key)) { - TextUtils.sendRawMessage(sender, formatKeyValue("Unknown Toggle Key", "" + args[2] + "")); - TextUtils.sendRawMessage(sender, formatKeyValue("Keys", "" + String.join(", ", DEBUG_TOGGLE_KEYS) + "")); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.unknown-toggle-key", "Unknown Toggle Key"), TextUtils.error(args[2]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.keys", "Keys"), TextUtils.text(String.join(", ", DEBUG_TOGGLE_KEYS)))); return; } if (!args[3].equalsIgnoreCase("true") && !args[3].equalsIgnoreCase("false")) { - TextUtils.sendRawMessage(sender, formatKeyValue("Value", "must be true or false")); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.value", "Value"), LocaleManager.text("ui.debug.invalid-boolean", TextUtils.error("must be true or false")))); return; } @@ -295,7 +849,7 @@ private void handleDebugToggle(CommandSender sender, String[] args) { plugin.getConfig().set(key, value); plugin.saveConfig(); plugin.reloadPluginConfig(); - TextUtils.sendRawMessage(sender, formatKeyValue("Updated", "" + key + " -> " + booleanValue(value))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.updated", "Updated"), TextUtils.command(key) + TextUtils.muted(" -> ") + TextUtils.booleanValue(value))); } private LinkedHashMap> buildDebugSections() { @@ -304,44 +858,57 @@ private LinkedHashMap> buildDebugSections() { List commandsPage = new ArrayList<>(); commandsPage.add(""); - commandsPage.add(formatSectionTitle("Commands")); - commandsPage.add(formatListEntry(commandPath(""), "status")); - commandsPage.add(formatListEntry(commandPath("help"), "command list")); - commandsPage.add(formatListEntry(commandPath("give "), "give a Christmas Crystal")); - commandsPage.add(formatListEntry(commandPath("gifts"), "spawn presents under all trees")); - commandsPage.add(formatListEntry(commandPath("addhand"), "add held item to gifts")); - commandsPage.add(formatListEntry(commandPath("reload"), "reload config and locale")); - commandsPage.add(formatListEntry(commandPath("end"), "end the event")); - commandsPage.add(formatListEntry(commandPath("debug"), "open the status debug section")); - commandsPage.add(formatListEntry(commandPath("debug [section|page]"), "extended debug output by category")); - commandsPage.add(formatListEntry(commandPath("debug toggle true|false"), "toggle global booleans")); + commandsPage.add(formatSectionTitle(debugSectionDisplayName("commands"))); + commandsPage.add(formatListEntry(commandPath(""), LocaleManager.text("ui.command-descriptions.status", "status"))); + commandsPage.add(formatListEntry(commandPath("help"), LocaleManager.text("ui.command-descriptions.help", "command list"))); + commandsPage.add(formatListEntry(commandPath("give "), LocaleManager.text("ui.command-descriptions.give", "give a Christmas Crystal"))); + commandsPage.add(formatListEntry(commandPath("gifts"), LocaleManager.text("ui.command-descriptions.gifts", "spawn presents under all trees"))); + commandsPage.add(formatListEntry(commandPath("gifts list [page]"), LocaleManager.text("ui.command-descriptions.gifts-list", "list configured gifts"))); + commandsPage.add(formatListEntry(commandPath("gifts roll"), LocaleManager.text("ui.command-descriptions.gifts-roll", "preview a random gift roll"))); + commandsPage.add(formatListEntry(commandPath("gifts remove "), LocaleManager.text("ui.command-descriptions.gifts-remove", "remove a configured gift"))); + commandsPage.add(formatListEntry(commandPath("addhand"), LocaleManager.text("ui.command-descriptions.addhand", "add held item to gifts"))); + commandsPage.add(formatListEntry(commandPath("reload"), LocaleManager.text("ui.command-descriptions.reload", "reload config and locale"))); + commandsPage.add(formatListEntry(commandPath("inspect"), LocaleManager.text("ui.command-descriptions.inspect", "inspect the tree you are looking at"))); + commandsPage.add(formatListEntry(commandPath("test sound first|repeat [player]"), LocaleManager.text("ui.command-descriptions.test-sound", "preview grow sounds"))); + commandsPage.add(formatListEntry(commandPath("test particle [effect] [player]"), LocaleManager.text("ui.command-descriptions.test-particle", "preview configured particles"))); + commandsPage.add(formatListEntry(commandPath("data backup"), LocaleManager.text("ui.command-descriptions.data-backup", "back up trees.yml"))); + commandsPage.add(formatListEntry(commandPath("data validate"), LocaleManager.text("ui.command-descriptions.data-validate", "validate trees.yml"))); + commandsPage.add(formatListEntry(commandPath("data migrate-world [dry-run|apply]"), LocaleManager.text("ui.command-descriptions.data-migrate-world", "migrate saved tree world names"))); + commandsPage.add(formatListEntry(commandPath("end"), LocaleManager.text("ui.command-descriptions.end", "end the event"))); + commandsPage.add(formatListEntry(commandPath("debug"), LocaleManager.text("ui.command-descriptions.debug", "open the status debug section"))); + commandsPage.add(formatListEntry(commandPath("debug [section|page]"), LocaleManager.text("ui.command-descriptions.debug-section", "extended debug output by category"))); + commandsPage.add(formatListEntry(commandPath("debug toggle true|false"), LocaleManager.text("ui.command-descriptions.debug-toggle", "toggle global booleans"))); if (isLegacyAliasEnabled()) { - commandsPage.add(formatKeyValue("Legacy Alias", "/" + LEGACY_COMMAND + "")); + commandsPage.add(formatKeyValue(LocaleManager.text("ui.status.labels.legacy-alias", "Legacy Alias"), TextUtils.command("/" + LEGACY_COMMAND))); } sections.put("commands", commandsPage); List permissionsPage = new ArrayList<>(); permissionsPage.add(""); - permissionsPage.add(formatSectionTitle("Permissions")); - for (Map.Entry permission : PERMISSIONS.entrySet()) { + permissionsPage.add(formatSectionTitle(debugSectionDisplayName("permissions"))); + for (Map.Entry permission : createPermissionDescriptions().entrySet()) { permissionsPage.add(formatListEntry(permission.getKey(), permission.getValue())); } sections.put("permissions", permissionsPage); List placeholdersPage = new ArrayList<>(); placeholdersPage.add(""); - placeholdersPage.add(formatSectionTitle("Placeholders")); - placeholdersPage.add(formatKeyValue("Notes", "Requires PlaceholderAPI. Use '_' after prefix, then dotted keys.")); + placeholdersPage.add(formatSectionTitle(debugSectionDisplayName("placeholders"))); + placeholdersPage.add(formatKeyValue( + LocaleManager.text("ui.debug.labels.notes", "Notes"), + LocaleManager.text("ui.debug.notes.placeholders", TextUtils.text("Requires PlaceholderAPI. Use '_' after prefix, then dotted keys.")) + )); + Map placeholderDescriptions = XMasPlaceholders.descriptions(); for (String placeholder : XMasPlaceholders.EXAMPLES) { - placeholdersPage.add(formatListEntry(placeholder, XMasPlaceholders.DESCRIPTIONS.getOrDefault(placeholder, "registered placeholder"))); + placeholdersPage.add(formatListEntry(placeholder, placeholderDescriptions.getOrDefault(placeholder, LocaleManager.text("ui.debug.placeholder-default-description", "registered placeholder")))); } sections.put("placeholders", placeholdersPage); List togglesPage = new ArrayList<>(); togglesPage.add(""); - togglesPage.add(formatSectionTitle("Toggleable Config Keys")); + togglesPage.add(formatSectionTitle(debugSectionDisplayName("config"))); for (String key : DEBUG_TOGGLE_KEYS) { - togglesPage.add(formatKeyValue(key, booleanValue(plugin.getConfig().getBoolean(key)))); + togglesPage.add(formatKeyValue(key, TextUtils.booleanValue(plugin.getConfig().getBoolean(key)))); } sections.put("config", togglesPage); @@ -387,21 +954,25 @@ private void renderDebugSection(CommandSender sender, String sectionKey, LinkedH int page = sectionIndex + 1; int pageCount = sectionKeys.size(); - TextUtils.sendRawMessage(sender, "" + TextUtils.DISPLAY_NAME + " Debug " + DEBUG_SECTIONS.getOrDefault(sectionKey, sectionKey) + " (" + page + "/" + pageCount + ")"); + TextUtils.sendRawMessage(sender, + LocaleManager.text("ui.debug.section-title", TextUtils.title(TextUtils.displayName() + " Debug")) + + " " + + TextUtils.text(debugSectionDisplayName(sectionKey)) + + TextUtils.muted(" (" + page + "/" + pageCount + ")")); for (String line : sections.get(sectionKey)) { TextUtils.sendRawMessage(sender, line); } if (page < pageCount) { String nextSection = sectionKeys.get(sectionIndex + 1); - TextUtils.sendRawMessage(sender, formatKeyValue("Next", "" + commandPath("debug " + nextSection) + "")); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.next", "Next"), TextUtils.command(commandPath("debug " + nextSection)))); } } private void sendInvalidDebugSelection(CommandSender sender, String requested, int pageCount) { - TextUtils.sendRawMessage(sender, formatKeyValue("Debug Sections", "" + String.join(", ", DEBUG_SECTIONS.keySet()) + "")); - TextUtils.sendRawMessage(sender, formatKeyValue("Debug Pages", "1-" + pageCount + "")); - TextUtils.sendRawMessage(sender, formatKeyValue("Requested", "" + requested + "")); - TextUtils.sendRawMessage(sender, formatKeyValue("Try", "" + commandPath("debug status") + "")); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.sections", "Debug Sections"), TextUtils.text(String.join(", ", DEBUG_SECTIONS.keySet())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.pages", "Debug Pages"), TextUtils.text("1-" + pageCount))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.requested", "Requested"), TextUtils.error(requested))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.try", "Try"), TextUtils.command(commandPath("debug status")))); } private List getHelpLines() { @@ -409,8 +980,12 @@ private List getHelpLines() { for (String line : LocaleManager.COMMAND_HELP) { lines.add(line.replaceAll("/" + LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND)); } + addMissingBundledHelpLines(lines); if (isLegacyAliasEnabled()) { - lines.add("Legacy alias: /" + LEGACY_COMMAND + " still works."); + lines.add(LocaleManager.text( + "command.legacy-alias-enabled", + TextUtils.muted("Legacy alias: ") + TextUtils.command("/" + LEGACY_COMMAND) + TextUtils.muted(" still works.") + )); } return lines; } @@ -422,6 +997,47 @@ private String commandPath(String suffix) { return "/" + PRIMARY_COMMAND + " " + suffix; } + private void addMissingBundledHelpLines(List lines) { + List bundledLines = LocaleManager.bundledTextList("command.help"); + for (String bundledLine : bundledLines) { + String normalizedLine = bundledLine.replaceAll("/" + LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND); + String commandNeedle = findCommandNeedle(normalizedLine); + if (commandNeedle != null && lines.stream().noneMatch(line -> line.contains(commandNeedle))) { + lines.add(normalizedLine); + } + } + } + + private String findCommandNeedle(String line) { + List commandNeedles = List.of( + commandPath("debug toggle"), + commandPath("debug status"), + commandPath("debug"), + commandPath("test particle"), + commandPath("test sound"), + commandPath("data validate"), + commandPath("data migrate-world"), + commandPath("data backup"), + commandPath("gifts remove"), + commandPath("gifts roll"), + commandPath("gifts list"), + commandPath("inspect"), + commandPath("reload"), + commandPath("addhand"), + commandPath("gifts"), + commandPath("give"), + commandPath("help"), + commandPath("end"), + commandPath("") + ); + for (String commandNeedle : commandNeedles) { + if (line.contains(commandNeedle)) { + return commandNeedle; + } + } + return null; + } + private boolean isLegacyAliasEnabled() { return plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true); } @@ -433,7 +1049,7 @@ public static boolean canOverrideTree(CommandSender sender) { private static void syncLegacyAlias(Main plugin, XMasCommand executor) { CommandMap commandMap = getCommandMap(); if (commandMap == null) { - plugin.getLogger().warning("Unable to access the Bukkit command map. Skipping legacy /xmas alias registration."); + plugin.getLogger().warning(LocaleManager.text("console.alias.command-map-unavailable", "Unable to access the Bukkit command map. Skipping legacy /xmas alias registration.")); return; } if (!plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true)) { @@ -449,27 +1065,32 @@ private static void syncLegacyAlias(Main plugin, XMasCommand executor) { legacyAliasCommand = existingPluginCommand; return; } - plugin.getLogger().warning("Legacy alias '/" + LEGACY_COMMAND + "' is already owned by plugin '" + existingPluginCommand.getPlugin().getName() + "'. Skipping alias registration."); + plugin.getLogger().warning(LocaleManager.text("console.alias.owned-by-plugin", "Legacy alias '/{alias}' is already owned by plugin '{plugin}'. Skipping alias registration.", + "{alias}", LEGACY_COMMAND, + "{plugin}", existingPluginCommand.getPlugin().getName())); return; } if (existing != null) { - plugin.getLogger().warning("Legacy alias '/" + LEGACY_COMMAND + "' is already registered by another command source. Skipping alias registration."); + plugin.getLogger().warning(LocaleManager.text("console.alias.registered-by-other", "Legacy alias '/{alias}' is already registered by another command source. Skipping alias registration.", + "{alias}", LEGACY_COMMAND)); return; } PluginCommand aliasCommand = createPluginCommand(plugin, LEGACY_COMMAND); if (aliasCommand == null) { - plugin.getLogger().warning("Unable to create the legacy /xmas alias command."); + plugin.getLogger().warning(LocaleManager.text("console.alias.create-failed", "Unable to create the legacy /xmas alias command.")); return; } aliasCommand.setDescription("Legacy alias for /" + PRIMARY_COMMAND); - aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|debug [section|page]|end]"); + aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|inspect|test|data|debug [section|page]|end]"); aliasCommand.setPermission(null); aliasCommand.setExecutor(executor); aliasCommand.setTabCompleter(executor); commandMap.register(plugin.getPluginMeta().getName().toLowerCase(Locale.ENGLISH), aliasCommand); legacyAliasCommand = aliasCommand; - plugin.getLogger().info("Registered legacy alias '/" + LEGACY_COMMAND + "' for '/" + PRIMARY_COMMAND + "'."); + plugin.getLogger().info(LocaleManager.text("console.alias.registered", "Registered legacy alias '/{alias}' for '/{primary}'.", + "{alias}", LEGACY_COMMAND, + "{primary}", PRIMARY_COMMAND)); } private static void unregisterLegacyAlias(CommandMap commandMap) { @@ -529,7 +1150,9 @@ private static PluginCommand createPluginCommand(Main plugin, String name) { constructor.setAccessible(true); return constructor.newInstance(name, plugin); } catch (ReflectiveOperationException e) { - plugin.getLogger().warning("Unable to construct dynamic command '/" + name + "': " + e.getMessage()); + plugin.getLogger().warning(LocaleManager.text("console.alias.construct-failed", "Unable to construct dynamic command '/{command}': {error}", + "{command}", name, + "{error}", e.getMessage())); return null; } } @@ -553,6 +1176,9 @@ private boolean canUseSubCommand(CommandSender sender, String subCommand) { case "gifts" -> hasPermission(sender, PERMISSION_GIFTS); case "reload" -> hasPermission(sender, PERMISSION_RELOAD); case "addhand" -> hasPermission(sender, PERMISSION_ADDHAND); + case "inspect" -> hasPermission(sender, PERMISSION_INSPECT); + case "test" -> hasPermission(sender, PERMISSION_TEST); + case "data" -> hasPermission(sender, PERMISSION_DATA); case "debug" -> hasPermission(sender, PERMISSION_DEBUG) || hasPermission(sender, PERMISSION_DEBUG_TOGGLE); default -> false; }; @@ -563,41 +1189,115 @@ private static boolean hasPermission(CommandSender sender, String permission) { } private void sendNoPermission(CommandSender sender) { - TextUtils.sendRawMessage(sender, "You do not have permission to use this command."); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.no-permission", TextUtils.error("You do not have permission to use this command."))); } private String formatSectionTitle(String title) { - return "" + title + ""; + return TextUtils.title(title); } private String formatListEntry(String key, String value) { - return "" + key + " : " + value + ""; + return TextUtils.command(key) + TextUtils.muted(" : ") + TextUtils.text(value); } - private String formatKeyValue(String key, String value) { - return "" + key + ": " + value; + private String formatStyledListEntry(String key, String value) { + return TextUtils.command(key) + TextUtils.muted(" : ") + value; } - private String booleanValue(boolean value) { - return value ? "true" : "false"; + private String formatKeyValue(String key, String value) { + return TextUtils.label(key) + TextUtils.muted(": ") + value; } private static Map createPermissionDescriptions() { Map permissions = new LinkedHashMap<>(); - permissions.put(PERMISSION_ADMIN, "allows all " + TextUtils.DISPLAY_NAME + " commands and overrides"); - permissions.put(PERMISSION_STATUS, "shows /" + PRIMARY_COMMAND + " status output"); - permissions.put(PERMISSION_HELP, "shows /" + PRIMARY_COMMAND + " help output"); - permissions.put(PERMISSION_GIVE, "allows /" + PRIMARY_COMMAND + " give"); - permissions.put(PERMISSION_GIFTS, "allows /" + PRIMARY_COMMAND + " gifts"); - permissions.put(PERMISSION_ADDHAND, "allows /" + PRIMARY_COMMAND + " addhand"); - permissions.put(PERMISSION_RELOAD, "allows /" + PRIMARY_COMMAND + " reload"); - permissions.put(PERMISSION_DEBUG, "allows /" + PRIMARY_COMMAND + " debug"); - permissions.put(PERMISSION_DEBUG_TOGGLE, "allows /" + PRIMARY_COMMAND + " debug toggle"); - permissions.put(PERMISSION_END, "allows /" + PRIMARY_COMMAND + " end"); - permissions.put(PERMISSION_TREE_OVERRIDE, "allows managing other players' trees"); + permissions.put(PERMISSION_ADMIN, LocaleManager.text("ui.permission-descriptions.admin", "allows all {plugin_name} commands and overrides")); + permissions.put(PERMISSION_STATUS, LocaleManager.text("ui.permission-descriptions.status", "shows /xmastree status output")); + permissions.put(PERMISSION_HELP, LocaleManager.text("ui.permission-descriptions.help", "shows /xmastree help output")); + permissions.put(PERMISSION_GIVE, LocaleManager.text("ui.permission-descriptions.give", "allows /xmastree give")); + permissions.put(PERMISSION_GIFTS, LocaleManager.text("ui.permission-descriptions.gifts", "allows /xmastree gifts")); + permissions.put(PERMISSION_ADDHAND, LocaleManager.text("ui.permission-descriptions.addhand", "allows /xmastree addhand")); + permissions.put(PERMISSION_RELOAD, LocaleManager.text("ui.permission-descriptions.reload", "allows /xmastree reload")); + permissions.put(PERMISSION_INSPECT, LocaleManager.text("ui.permission-descriptions.inspect", "allows /xmastree inspect")); + permissions.put(PERMISSION_TEST, LocaleManager.text("ui.permission-descriptions.test", "allows /xmastree test")); + permissions.put(PERMISSION_DATA, LocaleManager.text("ui.permission-descriptions.data", "allows /xmastree data")); + permissions.put(PERMISSION_DEBUG, LocaleManager.text("ui.permission-descriptions.debug", "allows /xmastree debug")); + permissions.put(PERMISSION_DEBUG_TOGGLE, LocaleManager.text("ui.permission-descriptions.debug-toggle", "allows /xmastree debug toggle")); + permissions.put(PERMISSION_END, LocaleManager.text("ui.permission-descriptions.end", "allows /xmastree end")); + permissions.put(PERMISSION_TREE_OVERRIDE, LocaleManager.text("ui.permission-descriptions.tree-override", "allows managing other players' trees")); return permissions; } + private String formatLocation(Location location) { + if (location == null || location.getWorld() == null) { + return LocaleManager.text("ui.inspect.values.unknown-location", "unknown"); + } + return location.getWorld().getName() + + " " + + location.getBlockX() + + ", " + + location.getBlockY() + + ", " + + location.getBlockZ(); + } + + private String formatRequirements(Map requirements) { + if (requirements == null || requirements.isEmpty()) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + List parts = new ArrayList<>(); + for (Map.Entry entry : requirements.entrySet()) { + if (entry.getValue() > 0) { + parts.add(formatMaterial(entry.getKey()) + " x" + entry.getValue()); + } + } + return parts.isEmpty() ? LocaleManager.text("ui.inspect.values.none", "none") : String.join(", ", parts); + } + + private String formatItems(List items) { + if (items == null || items.isEmpty()) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + Map totals = new LinkedHashMap<>(); + for (ItemStack item : items) { + if (item != null && !item.getType().isAir()) { + totals.merge(item.getType(), item.getAmount(), Integer::sum); + } + } + return formatRequirements(totals); + } + + private String formatGiftItem(ItemStack item) { + if (item == null || item.getType().isAir()) { + return TextUtils.muted(LocaleManager.text("ui.inspect.values.none", "none")); + } + String name = formatMaterial(item.getType()); + ItemMeta meta = item.getItemMeta(); + if (meta != null && meta.hasDisplayName() && meta.displayName() != null) { + name = PLAIN_TEXT.serialize(meta.displayName()); + } + String amount = " x" + item.getAmount(); + if (name.equals(formatMaterial(item.getType()))) { + return TextUtils.text(name + amount); + } + return TextUtils.text(name + amount) + TextUtils.muted(" (" + item.getType().name() + ")"); + } + + private List getWorldNames() { + List worlds = new ArrayList<>(); + for (World world : Bukkit.getWorlds()) { + worlds.add(world.getName()); + } + return worlds; + } + + private String formatMaterial(Material material) { + return Arrays.stream(material.name().toLowerCase(Locale.ENGLISH).split("_")) + .filter(part -> !part.isBlank()) + .map(part -> Character.toUpperCase(part.charAt(0)) + part.substring(1)) + .reduce((left, right) -> left + " " + right) + .orElse(material.name()); + } + private static Map createDebugSections() { Map sections = new LinkedHashMap<>(); sections.put("status", "Status"); @@ -608,4 +1308,15 @@ private static Map createDebugSections() { return sections; } + private String debugSectionDisplayName(String sectionKey) { + return LocaleManager.text("ui.debug.sections." + sectionKey, DEBUG_SECTIONS.getOrDefault(sectionKey, sectionKey)); + } + + private String replaceToken(String template, String key, String value) { + if (template == null) { + return null; + } + return template.replace("{" + key + "}", TextUtils.escape(value)); + } + } diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java index c46c4b8..86feedf 100644 --- a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java @@ -35,7 +35,6 @@ final class XMasPlaceholders { "%onembxmastree_player.trees%", "%onembxmastree_version%" ); - public static final Map DESCRIPTIONS = createDescriptions(); private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd-MM-yyyy HH-mm-ss"); private XMasPlaceholders() { @@ -48,15 +47,21 @@ public static String resolve(Main plugin, OfflinePlayer player, String params) { String key = normalize(params); return switch (key) { case "event_active" -> Boolean.toString(Main.inProgress); - case "event_active_text" -> Main.inProgress ? "Active" : "Inactive"; - case "event_status" -> Main.inProgress ? "In Progress" : "Holidays End"; - case "event_starts_at" -> "manual"; + case "event_active_text" -> Main.inProgress + ? LocaleManager.text("placeholders.values.active", "Active") + : LocaleManager.text("placeholders.values.inactive", "Inactive"); + case "event_status" -> Main.inProgress + ? LocaleManager.text("placeholders.values.in-progress", "In Progress") + : LocaleManager.text("placeholders.values.holidays-end", "Holidays End"); + case "event_starts_at" -> LocaleManager.text("placeholders.values.manual", "manual"); case "event_ends_at" -> formatEndDate(); case "event_ends_in" -> formatDurationUntilEnd(); case "event_ends_timestamp" -> Long.toString(Main.endTime); case "event_auto_end" -> Boolean.toString(Main.autoEnd); case "resource_back" -> Boolean.toString(Main.resourceBack); - case "resource_back_text" -> Main.resourceBack ? "Yes" : "No"; + case "resource_back_text" -> Main.resourceBack + ? LocaleManager.text("placeholders.values.yes", "Yes") + : LocaleManager.text("placeholders.values.no", "No"); case "particles_enabled" -> Boolean.toString(Main.particlesEnabled); case "luck_enabled" -> Boolean.toString(Main.LUCK_CHANCE_ENABLED); case "luck_chance" -> Integer.toString(Math.round(Main.LUCK_CHANCE * 100)); @@ -77,21 +82,21 @@ private static String normalize(String params) { private static String formatEndDate() { if (Main.endTime <= 0) { - return "unknown"; + return LocaleManager.text("placeholders.values.unknown", "unknown"); } return DATE_FORMAT.format(new Date(Main.endTime)); } private static String formatDurationUntilEnd() { if (!Main.autoEnd) { - return "disabled"; + return LocaleManager.text("placeholders.values.disabled", "disabled"); } if (Main.endTime <= 0) { - return "unknown"; + return LocaleManager.text("placeholders.values.unknown", "unknown"); } long remainingMillis = Main.endTime - System.currentTimeMillis(); if (remainingMillis <= 0) { - return "ended"; + return LocaleManager.text("placeholders.values.ended", "ended"); } long totalSeconds = remainingMillis / 1000; @@ -99,12 +104,17 @@ private static String formatDurationUntilEnd() { long hours = (totalSeconds % 86400) / 3600; long minutes = (totalSeconds % 3600) / 60; if (days > 0) { - return days + "d " + hours + "h"; + return LocaleManager.text("placeholders.values.duration-days-hours", "{days}d {hours}h", + "{days}", Long.toString(days), + "{hours}", Long.toString(hours)); } if (hours > 0) { - return hours + "h " + minutes + "m"; + return LocaleManager.text("placeholders.values.duration-hours-minutes", "{hours}h {minutes}m", + "{hours}", Long.toString(hours), + "{minutes}", Long.toString(minutes)); } - return minutes + "m"; + return LocaleManager.text("placeholders.values.duration-minutes", "{minutes}m", + "{minutes}", Long.toString(minutes)); } private static int countOwners(Collection trees) { @@ -128,25 +138,25 @@ private static int countPlayerTrees(OfflinePlayer player) { return count; } - private static Map createDescriptions() { + public static Map descriptions() { Map descriptions = new LinkedHashMap<>(); - descriptions.put("%onembxmastree_event.active%", "whether the event is currently active"); - descriptions.put("%onembxmastree_event.active_text%", "human-readable active state"); - descriptions.put("%onembxmastree_event.status%", "current event status text"); - descriptions.put("%onembxmastree_event.starts_at%", "event start mode"); - descriptions.put("%onembxmastree_event.ends_at%", "configured event end date"); - descriptions.put("%onembxmastree_event.ends_in%", "time remaining until the event ends"); - descriptions.put("%onembxmastree_event.ends_timestamp%", "event end timestamp in milliseconds"); - descriptions.put("%onembxmastree_event.auto_end%", "whether automatic ending is enabled"); - descriptions.put("%onembxmastree_resource.back%", "whether resource refunds are enabled"); - descriptions.put("%onembxmastree_resource.back_text%", "human-readable refund state"); - descriptions.put("%onembxmastree_particles.enabled%", "whether XMas Tree particles are enabled"); - descriptions.put("%onembxmastree_luck.enabled%", "whether gift luck chance is enabled"); - descriptions.put("%onembxmastree_luck.chance%", "gift luck chance as a percent"); - descriptions.put("%onembxmastree_trees.total%", "total loaded tree count"); - descriptions.put("%onembxmastree_trees.owners%", "number of unique tree owners"); - descriptions.put("%onembxmastree_player.trees%", "loaded trees owned by the placeholder player"); - descriptions.put("%onembxmastree_version%", "loaded plugin version"); + descriptions.put("%onembxmastree_event.active%", LocaleManager.text("placeholders.descriptions.event-active", "whether the event is currently active")); + descriptions.put("%onembxmastree_event.active_text%", LocaleManager.text("placeholders.descriptions.event-active-text", "human-readable active state")); + descriptions.put("%onembxmastree_event.status%", LocaleManager.text("placeholders.descriptions.event-status", "current event status text")); + descriptions.put("%onembxmastree_event.starts_at%", LocaleManager.text("placeholders.descriptions.event-starts-at", "event start mode")); + descriptions.put("%onembxmastree_event.ends_at%", LocaleManager.text("placeholders.descriptions.event-ends-at", "configured event end date")); + descriptions.put("%onembxmastree_event.ends_in%", LocaleManager.text("placeholders.descriptions.event-ends-in", "time remaining until the event ends")); + descriptions.put("%onembxmastree_event.ends_timestamp%", LocaleManager.text("placeholders.descriptions.event-ends-timestamp", "event end timestamp in milliseconds")); + descriptions.put("%onembxmastree_event.auto_end%", LocaleManager.text("placeholders.descriptions.event-auto-end", "whether automatic ending is enabled")); + descriptions.put("%onembxmastree_resource.back%", LocaleManager.text("placeholders.descriptions.resource-back", "whether resource refunds are enabled")); + descriptions.put("%onembxmastree_resource.back_text%", LocaleManager.text("placeholders.descriptions.resource-back-text", "human-readable refund state")); + descriptions.put("%onembxmastree_particles.enabled%", LocaleManager.text("placeholders.descriptions.particles-enabled", "whether XMas Tree particles are enabled")); + descriptions.put("%onembxmastree_luck.enabled%", LocaleManager.text("placeholders.descriptions.luck-enabled", "whether gift luck chance is enabled")); + descriptions.put("%onembxmastree_luck.chance%", LocaleManager.text("placeholders.descriptions.luck-chance", "gift luck chance as a percent")); + descriptions.put("%onembxmastree_trees.total%", LocaleManager.text("placeholders.descriptions.trees-total", "total loaded tree count")); + descriptions.put("%onembxmastree_trees.owners%", LocaleManager.text("placeholders.descriptions.trees-owners", "number of unique tree owners")); + descriptions.put("%onembxmastree_player.trees%", LocaleManager.text("placeholders.descriptions.player-trees", "loaded trees owned by the placeholder player")); + descriptions.put("%onembxmastree_version%", LocaleManager.text("placeholders.descriptions.version", "loaded plugin version")); return descriptions; } } diff --git a/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java index 9e59551..fb04e03 100644 --- a/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java @@ -5,6 +5,7 @@ import org.bukkit.configuration.file.FileConfiguration; import org.bukkit.configuration.file.YamlConfiguration; import org.bukkit.plugin.java.JavaPlugin; +import ru.meloncode.xmas.LocaleManager; import java.io.File; import java.io.IOException; @@ -25,7 +26,8 @@ public static YamlConfiguration loadConfig(File file) { try { configuration.loadFromString(Files.readString(file.toPath(), StandardCharsets.UTF_8)); } catch (IOException | InvalidConfigurationException exception) { - Bukkit.getLogger().log(Level.WARNING, "Failed to load YAML configuration from " + file.getPath(), exception); + Bukkit.getLogger().log(Level.WARNING, LocaleManager.text("console.config.load-yaml-failed", "Failed to load YAML configuration from {file}", + "{file}", file.getPath()), exception); } return configuration; } @@ -44,33 +46,44 @@ public static boolean synchronizeWithResource(JavaPlugin plugin, String resource return false; } YamlConfiguration defaults = loadResourceConfig(plugin, resourcePath); - return mergeDefaultsAndComments(yamlConfiguration, defaults); + return synchronizeWithDefaults(yamlConfiguration, defaults); + } + + public static boolean synchronizeWithDefaults(FileConfiguration configuration, FileConfiguration defaults) { + if (!(configuration instanceof YamlConfiguration yamlConfiguration) || !(defaults instanceof YamlConfiguration defaultYaml)) { + return false; + } + return mergeDefaultsAndComments(yamlConfiguration, defaultYaml); } public static void saveConfig(File file, FileConfiguration configuration) { File parent = file.getParentFile(); if (parent != null && !parent.exists() && !parent.mkdirs()) { - Bukkit.getLogger().warning("Unable to create configuration directory " + parent.getPath()); + Bukkit.getLogger().warning(LocaleManager.text("console.config.create-directory-failed", "Unable to create configuration directory {directory}", + "{directory}", parent.getPath())); return; } try { configuration.save(file); } catch (IOException exception) { - Bukkit.getLogger().log(Level.WARNING, "Failed to save YAML configuration to " + file.getPath(), exception); + Bukkit.getLogger().log(Level.WARNING, LocaleManager.text("console.config.save-yaml-failed", "Failed to save YAML configuration to {file}", + "{file}", file.getPath()), exception); } } - private static YamlConfiguration loadResourceConfig(JavaPlugin plugin, String resourcePath) { + public static YamlConfiguration loadResourceConfig(JavaPlugin plugin, String resourcePath) { YamlConfiguration configuration = newConfiguration(); try (InputStream inputStream = plugin.getResource(resourcePath)) { if (inputStream == null) { - plugin.getLogger().warning("Missing bundled configuration resource: " + resourcePath); + plugin.getLogger().warning(LocaleManager.text("console.config.missing-bundled-resource", "Missing bundled configuration resource: {resource}", + "{resource}", resourcePath)); return configuration; } configuration.loadFromString(new String(inputStream.readAllBytes(), StandardCharsets.UTF_8)); } catch (IOException | InvalidConfigurationException exception) { - plugin.getLogger().log(Level.WARNING, "Failed to load bundled configuration resource " + resourcePath, exception); + plugin.getLogger().log(Level.WARNING, LocaleManager.text("console.config.load-bundled-resource-failed", "Failed to load bundled configuration resource {resource}", + "{resource}", resourcePath), exception); } return configuration; } diff --git a/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java b/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java new file mode 100644 index 0000000..a3c0063 --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java @@ -0,0 +1,98 @@ +package ru.meloncode.xmas.utils; + +import org.bukkit.Bukkit; +import org.bukkit.block.Skull; +import org.bukkit.profile.PlayerTextures; +import ru.meloncode.xmas.LocaleManager; + +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.UUID; +import java.util.logging.Logger; + +public final class HeadProfileUtils { + + private HeadProfileUtils() { + } + + public static void applyConfiguredHead(Skull skull, String configuredHead, Logger logger) { + Object legacyProfile = createLegacyProfile(configuredHead, logger); + if (legacyProfile == null) { + return; + } + + if (trySetResolvableProfile(skull, legacyProfile)) { + return; + } + if (trySetLegacyProfile(skull, legacyProfile)) { + return; + } + + logger.warning(LocaleManager.text("console.heads.apply-failed", "Unable to apply configured present head profile.")); + } + + private static Object createLegacyProfile(String configuredHead, Logger logger) { + if (configuredHead == null || configuredHead.trim().isEmpty()) { + return null; + } + + String trimmedHead = configuredHead.trim(); + try { + if (!trimmedHead.contains("://")) { + Method createProfile = Bukkit.class.getMethod("createProfile", String.class); + return createProfile.invoke(null, trimmedHead); + } + + URL skinUrl = URI.create(trimmedHead).toURL(); + if (!"textures.minecraft.net".equalsIgnoreCase(skinUrl.getHost())) { + logger.warning(LocaleManager.text("console.heads.non-mojang-url", "Ignoring non-Mojang present skin URL: {url}", + "{url}", trimmedHead)); + return null; + } + + Method createProfile = Bukkit.class.getMethod("createProfile", UUID.class); + Object legacyProfile = createProfile.invoke(null, UUID.randomUUID()); + Method getTextures = legacyProfile.getClass().getMethod("getTextures"); + PlayerTextures textures = (PlayerTextures) getTextures.invoke(legacyProfile); + textures.setSkin(skinUrl); + Method setTextures = legacyProfile.getClass().getMethod("setTextures", PlayerTextures.class); + setTextures.invoke(legacyProfile, textures); + return legacyProfile; + } catch (IllegalArgumentException | MalformedURLException exception) { + logger.warning(LocaleManager.text("console.heads.invalid-url", "Invalid present skin URL: {url}", + "{url}", trimmedHead)); + return null; + } catch (ReflectiveOperationException exception) { + logger.warning(LocaleManager.text("console.heads.build-failed", "Failed to build present head profile: {error}", + "{error}", exception.getMessage())); + return null; + } + } + + private static boolean trySetResolvableProfile(Skull skull, Object legacyProfile) { + try { + Class legacyProfileClass = Class.forName("com.destroystokyo.paper.profile.PlayerProfile"); + Class resolvableProfileClass = Class.forName("io.papermc.paper.datacomponent.item.ResolvableProfile"); + Method factoryMethod = resolvableProfileClass.getMethod("resolvableProfile", legacyProfileClass); + Object resolvableProfile = factoryMethod.invoke(null, legacyProfile); + Method setProfile = skull.getClass().getMethod("setProfile", resolvableProfileClass); + setProfile.invoke(skull, resolvableProfile); + return true; + } catch (ReflectiveOperationException exception) { + return false; + } + } + + private static boolean trySetLegacyProfile(Skull skull, Object legacyProfile) { + try { + Class legacyProfileClass = Class.forName("com.destroystokyo.paper.profile.PlayerProfile"); + Method setPlayerProfile = skull.getClass().getMethod("setPlayerProfile", legacyProfileClass); + setPlayerProfile.invoke(skull, legacyProfile); + return true; + } catch (ReflectiveOperationException exception) { + return false; + } + } +} diff --git a/src/main/java/ru/meloncode/xmas/utils/TextUtils.java b/src/main/java/ru/meloncode/xmas/utils/TextUtils.java index 28925af..7114ec2 100644 --- a/src/main/java/ru/meloncode/xmas/utils/TextUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/TextUtils.java @@ -6,7 +6,7 @@ import org.bukkit.entity.Player; import net.kyori.adventure.text.Component; import net.kyori.adventure.text.event.HoverEvent; -import net.kyori.adventure.text.format.NamedTextColor; +import net.kyori.adventure.text.format.TextColor; import net.kyori.adventure.text.format.TextDecoration; import net.kyori.adventure.text.minimessage.MiniMessage; import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; @@ -15,12 +15,13 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; import java.util.Objects; public class TextUtils { public static final String DISPLAY_NAME = "XMas Tree"; - private static final String PREFIX = "[" + DISPLAY_NAME + "] "; - private static final String CONSOLE_PREFIX = "[" + DISPLAY_NAME + "] "; + private static final String FALLBACK_SUCCESS_HEX = "#b9e8b5"; + private static final String FALLBACK_ERROR_HEX = "#f3a7a7"; private static final MiniMessage MINI_MESSAGE = MiniMessage.miniMessage(); private static final LegacyComponentSerializer LEGACY_AMPERSAND = LegacyComponentSerializer.legacyAmpersand(); @@ -29,8 +30,7 @@ public class TextUtils { public static List generateChatReqList(MagicTree tree) { Objects.requireNonNull(tree, "tree"); List list = new ArrayList<>(); - Component title = Component.text(LocaleManager.GROW_REQ_LIST_TITLE, NamedTextColor.GOLD) - .decorate(TextDecoration.BOLD); + Component title = parse("" + escape(LocaleManager.GROW_REQ_LIST_TITLE) + ""); if (LocaleManager.GROW_REQ_LIST_HINT != null && !LocaleManager.GROW_REQ_LIST_HINT.isBlank()) { title = title.hoverEvent(HoverEvent.showText(parse(LocaleManager.GROW_REQ_LIST_HINT))); } @@ -42,7 +42,9 @@ public static List generateChatReqList(MagicTree tree) { if (tree.getLevelupRequirements().containsKey(cMaterial)) treeReq = tree.getLevelupRequirements().get(cMaterial); - NamedTextColor color = treeReq == 0 ? NamedTextColor.GREEN : NamedTextColor.RED; + TextColor color = treeReq == 0 + ? themeColor("xm-success", FALLBACK_SUCCESS_HEX) + : themeColor("xm-error", FALLBACK_ERROR_HEX); Component line = Component.translatable(cMaterial.getItemTranslationKey()) .color(color) .decorate(TextDecoration.BOLD) @@ -65,13 +67,13 @@ public static void sendMessage(Player player, Component message) { public static void sendMessage(CommandSender sender, String message) { if (sender != null && message != null) { - sender.sendMessage(parse(PREFIX + message)); + sender.sendMessage(parse(prefix() + message)); } } public static void sendMessage(CommandSender sender, Component message) { if (sender != null && message != null) { - sender.sendMessage(parse(PREFIX).append(message)); + sender.sendMessage(parse(prefix()).append(message)); } } @@ -83,7 +85,7 @@ public static void sendRawMessage(CommandSender sender, String message) { public static void sendConsoleMessage(String message) { if (message != null) { - Bukkit.getConsoleSender().sendMessage(parse(CONSOLE_PREFIX + message)); + Bukkit.getConsoleSender().sendMessage(parse(consolePrefix() + message)); } } @@ -91,13 +93,14 @@ public static Component parse(String message) { if (message == null) { return Component.empty(); } - if (message.indexOf('ยง') >= 0) { - return LEGACY_SECTION.deserialize(message); + String themed = applyThemeAliases(LocaleManager.replaceCommonTokens(message)); + if (themed.indexOf('ยง') >= 0) { + return LEGACY_SECTION.deserialize(themed); } - if (message.indexOf('&') >= 0 && message.indexOf('<') < 0) { - return LEGACY_AMPERSAND.deserialize(message); + if (themed.indexOf('&') >= 0 && themed.indexOf('<') < 0) { + return LEGACY_AMPERSAND.deserialize(themed); } - return MINI_MESSAGE.deserialize(message); + return MINI_MESSAGE.deserialize(themed); } public static List parseList(List messages) { @@ -109,4 +112,93 @@ public static List parseList(List messages) { } return components; } + + public static String displayName() { + if (LocaleManager.PLUGIN_NAME != null && !LocaleManager.PLUGIN_NAME.isBlank()) { + return LocaleManager.PLUGIN_NAME; + } + return DISPLAY_NAME; + } + + public static String escape(String text) { + if (text == null) { + return ""; + } + return MINI_MESSAGE.escapeTags(text); + } + + public static String title(String text) { + return "" + escape(text) + ""; + } + + public static String label(String text) { + return "" + escape(text) + ""; + } + + public static String text(String text) { + return "" + escape(text) + ""; + } + + public static String muted(String text) { + return "" + escape(text) + ""; + } + + public static String accent(String text) { + return "" + escape(text) + ""; + } + + public static String accentSecondary(String text) { + return "" + escape(text) + ""; + } + + public static String command(String text) { + return "" + escape(text) + ""; + } + + public static String success(String text) { + return "" + escape(text) + ""; + } + + public static String warning(String text) { + return "" + escape(text) + ""; + } + + public static String error(String text) { + return "" + escape(text) + ""; + } + + public static String info(String text) { + return "" + escape(text) + ""; + } + + public static String booleanValue(boolean value) { + return value ? success("true") : error("false"); + } + + private static String prefix() { + return LocaleManager.getChatPrefix(); + } + + private static String consolePrefix() { + return LocaleManager.getConsolePrefix(); + } + + private static String applyThemeAliases(String message) { + String themed = message; + for (Map.Entry entry : LocaleManager.getThemeAliases().entrySet()) { + themed = themed.replace(entry.getKey(), entry.getValue()); + } + return themed; + } + + private static TextColor themeColor(String themeAlias, String fallbackHex) { + String alias = LocaleManager.getThemeAliases().get("<" + themeAlias + ">"); + if (alias != null && alias.startsWith("<#") && alias.endsWith(">")) { + TextColor fromAlias = TextColor.fromHexString(alias.substring(1, alias.length() - 1)); + if (fromAlias != null) { + return fromAlias; + } + } + return TextColor.fromHexString(fallbackHex); + } } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 1825a4e..ae409db 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -18,9 +18,12 @@ core: # - Turning it back on after the plugin has already ended the event is safest with a full server restart. plugin-enabled: true - # Locale file name loaded from plugins/X-Mas/locales/.yml. + # Translation file loaded from plugins/X-Mas/translations/locale_.yml. # Default: en - # Safe values: a locale file name without .yml, for example en, ru, or ru_santa + # Safe values: + # - en -> plugins/X-Mas/translations/locale_en.yml + # - fr -> plugins/X-Mas/translations/locale_fr.yml + # - locale_en.yml also works, but short locale codes are preferred # Reload behavior: /xmastree reload applies immediately. locale: en diff --git a/src/main/resources/locales/default.yml b/src/main/resources/locales/default.yml deleted file mode 100644 index dcd933d..0000000 --- a/src/main/resources/locales/default.yml +++ /dev/null @@ -1,64 +0,0 @@ -## ## ### ######## ## ## #### ## ## ###### -## ## ## ## ## ## ## ### ## ## ### ## ## ## -## ## ## ## ## ## ## #### ## ## #### ## ## -## ## ## ## ## ######## ## ## ## ## ## ## ## ## #### -## ## ## ######### ## ## ## #### ## ## #### ## ## -## ## ## ## ## ## ## ## ### ## ## ### ## ## - ### ### ## ## ## ## ## ## #### ## ## ###### - -# EN: DO NOT TRY TO EDIT THIS FILE. IT WILL BE PURGED. -# EN: If you want to edit messages - use en.yml or define your one. -# EN: Don't forget to set your lang in config.yml - -# RU: Hะต ะฟั‹ั‚ะฐะนั‚ะตััŒ ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ัั‚ะพั‚ ั„ะฐะนะป. Oะฝ ะฝะต ะฑัƒะดะตั‚ ัะพั…ั€ะฐะฝะตะฝ!. -# RU: Eัะปะธ ะฒั‹ ั…ะพั‚ะธั‚ะต ะพั‚ั€ะตะดะฐะบั‚ะธั€ะพะฒะฐั‚ัŒ ะฟะตั€ะตะฒะพะด - ะธัะฟะพะปัŒะทัƒะนั‚ะต ru.yml ะธะปะธ ัะพะทะดะฐะนั‚ะต ะฝะพะฒั‹ะน. -# RU: Hะต ะทะฐะฑัƒะดัŒั‚ะต ัƒะบะฐะทะฐั‚ัŒ ะฒะฐัˆ ั„ะฐะนะป ะฒ config.yml - -#_UNUSED is a keyword to disable message. - -#For chat -plugin-name: XMas Tree - -messages: - plugin-enabled: Merry Christmas And Happy New Year! - timeout: The Holidays Are Over! See you next year! - final-wish: Happy New Year to all of you, my friends! - tree: - grow-lvl-ready: Woosh! Press Right Mouse Button to let tree grow! - grow-lvl-progress: _UNUSED - grow-req-list-title: Still needed - grow-req-list-hint: Right-click the tree while holding the listed ingredient items to feed them into the tree. - grow-not-enough-place: Not enough place to make it grow - grow-lvl-max: This tree has reached it's maximum level! - tree-limit: Hey, do not be greedy! - destroy-sapling: You can cut down this tree. - destroy-leaves-santa: Santa Claus sees everything. - destroy-leaves-tut: To destroy a tree - cut the log. - destroy-warning: You can cut down this tree. - destroy-resource-back: Your used upgrade items will be returned for collection. - destroy-tut: Hit it again to confirm. - destroy-fail-owner: It's not your tree! - destroy-complete: Your tree has been packed away for the season. - gift: - luck-message: _UNUSED - unluck-message: Probably Santa has put you in his blacklist. I hope you're lucky next time! -crystal: - name: Christmas Crystal - lore: - - Concentrated Christmas Spirit - - Use it on a spruce sapling to fill it with magic! -command: - help: - - 'Use /xmastree to show plugin version and status' - - 'Use /xmastree help to show this command list' - - 'Use /xmastree give <player> to give a player a Christmas Crystal' - - 'Use /xmastree gifts to spawn presents under all trees' - - 'Use /xmastree addhand to add your held item as a gift' - - 'Use /xmastree reload to reload the plugin config' - - 'Use /xmastree debug to open the status debug section' - - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, and toggles' - - 'Use /xmastree debug toggle <key> true|false to change supported global toggles' - - 'Use /xmastree end to end the event' - player-offline: 'Player not found' - no-player-name: 'Missing player name' - giveaway: 'Ho! Ho! Ho! Looks like its time to check presents!' diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml deleted file mode 100644 index 0368c92..0000000 --- a/src/main/resources/locales/en.yml +++ /dev/null @@ -1,55 +0,0 @@ -#_UNUSED is a keyword to disable message. - -#For chat -plugin-name: XMas Tree - -messages: - plugin-enabled: Merry Christmas And Happy New Year! - timeout: The Holidays Are Over! See you next year! - final-wish: Happy New Year to all of you, my friends! - - tree: - grow-lvl-ready: Woosh! Press Right Mouse Button to let tree grow! - grow-lvl-progress: _UNUSED - grow-req-list-title: Still needed - grow-req-list-hint: Right-click the tree while holding the listed ingredient items to feed them into the tree. - grow-not-enough-place: Not enough place to make it grow - grow-lvl-max: This tree has reached it's maximum level! - tree-limit: Hey, do not be greedy! (Tree limit) - destroy-sapling: You can cut down this tree. - destroy-leaves-santa: Santa Claus sees everything. - destroy-leaves-tut: To destroy a tree - cut the log. - destroy-warning: You can cut down this tree. - destroy-resource-back: Your used upgrade items will be returned for collection. - destroy-tut: Hit it again to confirm. - destroy-fail-owner: It's not your tree! - destroy-complete: Your tree has been packed away for the season. - gift: - luck-message: _UNUSED - unluck-message: Probably Santa has put you in blacklist. I hope you're lucky next time! -crystal: - name: Christmas Crystal - lore: - - Concentrated Christmas Spirit - - Use it on a spruce sapling to fill it with magic! -help: - - 'Use /xmastree to show plugin version and status' - - 'Use /xmastree give player to give crystal somebody' - - 'Use /xmastree gifts to spawn some presents under every Christmas Tree!' - - 'Use /xmastree end to set plugin into ending mode' - -command: - help: - - 'Use /xmastree to show plugin version and status' - - 'Use /xmastree help to show this command list' - - 'Use /xmastree give <player> to give a player a Christmas Crystal' - - 'Use /xmastree gifts to spawn presents under all trees' - - 'Use /xmastree addhand to add your held item as a gift' - - 'Use /xmastree reload to reload the plugin config' - - 'Use /xmastree debug to open the status debug section' - - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, and toggles' - - 'Use /xmastree debug toggle <key> true|false to change supported global toggles' - - 'Use /xmastree end to end the event' - player-offline: 'Player not found' - no-player-name: 'Missing player name' - giveaway: 'Ho! Ho! Ho! Looks like its time to check presents!' diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml deleted file mode 100644 index 876eec6..0000000 --- a/src/main/resources/locales/hu.yml +++ /dev/null @@ -1,50 +0,0 @@ -#_UNUSED is a keyword to disable message. - -#For chat -plugin-name: X-Mas - -messages: - plugin-enabled: Boldog karรกcsonyt รฉs boldog รบj รฉvet! - timeout: Az รผnnepek vรฉget รฉrtek! Viszlรกt, jรถvล‘re! - final-wish: Boldog รบj รฉvet mindenkinek, barรกtaim! - - tree: - grow-lvl-ready: Woosh! Nyomd meg a jobb egรฉrgombot, hogy a fรกk nรถvekedjenek! - grow-lvl-progress: _UNUSED - grow-req-list-title: Kรถtelezล‘ - grow-not-enough-place: Nincs elรฉg hely ahhoz, hogy nรถvekedjen - grow-lvl-max: Ez a fa elรฉrte a maximรกlis szintjรฉt! - tree-limit: Hรฉ, ne lรฉgy mohรณ! (Fa korlรกt) - destroy-sapling: Ez a csoda nem csรถkken. Biztos vagy ebben? - destroy-leaves-santa: Mikulรกs lรกt mindent. - destroy-leaves-tut: Egy fa megsemmisรญtรฉsรฉhez - vรกgd ki a fรกt. - destroy-warning: Biztos vagy ebben? A fejlล‘dรฉsed elvรฉsz. - destroy-tut: A fรกt megsemmisรญteni, vรกgd ki รบjra. - destroy-fail-owner: Ez nem a te fรกd! - destroy-complete: SZร–RNY vagy! - gift: - luck-message: _UNUSED - unluck-message: Valรณszรญnลฑleg a Mikulรกs a feketelistรกba tette. Remรฉlem, szerencsรฉje lesz legkรถzelebb! -crystal: - name: Karรกcsonyi kristรกly - lore: - - Koncentrรกlt karรกcsonyi szellem - - Hasznรกld a fa fa-csemetรฉkhez, hogy kitรถltse mรกgiรกval! -help: - - 'Hasznรกld /xmas a plugin verziรณjรกnak รฉs รกllapotรกnak megjelenรญtรฉse' - - 'Hasznรกld /xmas give nรฉv, hogy valakinek kristรกlyt adj' - - 'Hasznรกld /xmas ajรกndรฉkok, hogy minden karรกcsonyi fa alรก ajรกndรฉkozzon nรฉhรกny ajรกndรฉkot!' - - 'Hasznรกld /xmas vรฉget a bล‘vรญtmรฉny beillesztรฉsรฉnek mรณdjรกba' - -command: - help: - - 'Hasznรกld /xmas a plugin verziรณjรกnak รฉs รกllapotรกnak megjelenรญtรฉsรฉhez' - - 'Hasznรกld /xmas give nรฉv, hogy kristรกlyt adj egy jรกtรฉkosnak' - - 'Hasznรกld /xmas gifts, hogy ajรกndรฉkok jelenjenek meg a fรกk alatt' - - 'Hasznรกld /xmas addhand, hogy a kรฉzben tartott tรกrgy ajรกndรฉk legyen' - - 'Hasznรกld /xmas reload a konfigurรกciรณ รบjratรถltรฉsรฉhez' - - 'Hasznรกld /xmas debug a lapozhatรณ stรกtusz รฉs kapcsolรณk megjelenรญtรฉsรฉhez' - - 'Hasznรกld /xmas end az esemรฉny lezรกrรกsรกhoz' - player-offline: 'A jรกtรฉkos nem talรกlhatรณ' - no-player-name: 'Hiรกnyzรณ jรกtรฉkos nรฉv' - giveaway: 'Ho! Ho! Ho! Ideje ellenล‘rizni az ajรกndรฉkokat!' diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml deleted file mode 100644 index 5b744f6..0000000 --- a/src/main/resources/locales/ru.yml +++ /dev/null @@ -1,43 +0,0 @@ -#_UNUSED is a keyword to disable message. - -#For chat -plugin-name: ะะพะฒั‹ะน ะ“ะพะด! - -messages: - plugin-enabled: ะก ะะพะฒั‹ะผ ะ“ะพะดะพะผ! - timeout: ะŸั€ะฐะทะดะฝะธะบะธ ะฟะพะดะพัˆะปะธ ะบ ะบะพะฝั†ัƒ. ะฃะฒะธะดะธะผัั ะฒ ัะปะตะดัƒัŽั‰ะตะผ ะณะพะดัƒ ;) - final-wish: ะกั‡ะฐัั‚ะปะธะฒะพะณะพ ะะพะฒะพะณะพ ะณะพะดะฐ, ะดั€ัƒะทัŒั! - tree: - grow-lvl-ready: ะžั‚ะปะธั‡ะฝะพ! ะะฐะถะผะธั‚ะต ะฟั€ะฐะฒะพะน ะบะฝะพะฟะบะพะน ั‡ั‚ะพะฑั‹ ะฟะพะดะฝัั‚ัŒ ัƒั€ะพะฒะตะฝัŒ ะดะตั€ะตะฒะฐ! - grow-lvl-progress: _UNUSED - grow-req-list-title: ะขั€ะตะฑัƒะตั‚ัั - grow-not-enough-place: ะะตะดะพัั‚ะฐั‚ะพั‡ะฝะพ ะผะตัั‚ะฐ! - grow-lvl-max: ะ”ะตั€ะตะฒะพ ัƒะถะต ะธะผะตะตั‚ ะผะฐะบัะธะผะฐะปัŒะฝั‹ะน ัƒั€ะพะฒะตะฝัŒ - tree-limit: ะะต ะถะฐะดะฝะธั‡ะฐะนั‚ะต! (ะกะปะธัˆะบะพะผ ะผะฝะพะณะพ ะดะตั€ะตะฒัŒะตะฒ) - destroy-sapling: ะญั‚ะพ ั‡ัƒะดะพ ะฝะต ะฒั‹ะฟะฐะดะตั‚. ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? :< - destroy-leaves-santa: ะ”ะตะด ะœะพั€ะพะท ะฒัะต ะฒะธะดะธั‚! - destroy-leaves-tut: ะ”ะปั ั‚ะพะณะพ ั‡ั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะะปะบัƒ - ั€ัƒะฑะธ ัั‚ะฒะพะป! - destroy-warning: ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? ะ’ะตััŒ ะฟั€ะพะณั€ะตัั ะฑัƒะดะตั‚ ะฟะพั‚ะตั€ัะฝ. - destroy-tut: ะงั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะะปะบัƒ ั€ะฐะทั€ัƒัˆัŒั‚ะต ะฑะปะพะบ ะตั‰ะต ั€ะฐะท. - destroy-fail-owner: ะญั‚ะพ ะฝะต ะฒะฐัˆะต ะดะตั€ะตะฒะพ. - destroy-complete: ะ˜ ะฝะต ะถะฐะปะบะพ?! - gift: - luck-message: _UNUSED - unluck-message: ะšะฐะถะตั‚ัั ะบั‚ะพ-ั‚ะพ ะฟะปะพั…ะพ ะฒะตะป ัะตะฑั ะฒ ัั‚ะพะผ ะณะพะดัƒ... ะŸะพะฟั€ะพะฑัƒะน ะตั‰ะต ั€ะฐะท. -crystal: - name: ะ’ะพะปัˆะตะฑะฝั‹ะน ะบั€ะธัั‚ะฐะปะป - lore: - - ะกะพะดะตั€ะถะธั‚ ะฒ ัะตะฑะต ะบะพะฝั†ะตะฝั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ะน ะะพะฒะพะณะพะดะฝะธะน ะดัƒั…! - - ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต ะตะณะพ ะฝะฐ ัะฐะถะตะฝั†ะต ั‡ั‚ะพะฑั‹ ัะดะตะปะฐั‚ัŒ ะตะณะพ ะฒะพะปัˆะตะฑะฝั‹ะผ. -command: - help: - - 'ะšะพะผะฐะฝะดะฐ /xmas - ะพั‚ะพะฑั€ะฐะทะธั‚ัŒ ะฒะตั€ัะธัŽ ะฟะปะฐะณะธะฝะฐ ะธ ัั‚ะฐั‚ัƒั' - - 'ะšะพะผะฐะฝะดะฐ /xmas give ะะธะบ - ะฒั‹ะดะฐั‚ัŒ ะธะณั€ะพะบัƒ ะบั€ะธัั‚ะฐะปะป' - - 'ะšะพะผะฐะฝะดะฐ /xmas gifts - ัะพะทะดะฐั‚ัŒ ะฟะพะดะฐั€ะบะธ ะฟะพะด ะตะปะบะฐะผะธ' - - 'ะšะพะผะฐะฝะดะฐ /xmas addhand - ะดะพะฑะฐะฒะธั‚ัŒ ะฟั€ะตะดะผะตั‚ ะฒ ั€ัƒะบะต ะฒ ะฟะพะดะฐั€ะบะธ' - - 'ะšะพะผะฐะฝะดะฐ /xmas reload - ะฟะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ ะบะพะฝั„ะธะณ' - - 'ะšะพะผะฐะฝะดะฐ /xmas debug - ะพั‚ะปะฐะดะพั‡ะฝั‹ะน ัั‚ะฐั‚ัƒั, ะบะพะผะฐะฝะดั‹ ะธ ะฟะตั€ะตะบะปัŽั‡ะฐั‚ะตะปะธ' - - 'ะšะพะผะฐะฝะดะฐ /xmas end - ะทะฐะฒะตั€ัˆะธั‚ัŒ ะฟั€ะฐะทะดะฝะธะบ' - player-offline: 'ะ˜ะณั€ะพะบ ะฝะต ะฝะฐะนะดะตะฝ' - no-player-name: 'ะ’ั‹ ะฝะต ะฒะฒะตะปะธ ะธะผั ะธะณั€ะพะบะฐ!' - giveaway: 'ะšะฐะถะตั‚ัั ะฒั€ะตะผั ะฟั€ะพะฒะตั€ัั‚ัŒ ะฟะพะดะฐั€ะบะธ!' diff --git a/src/main/resources/locales/ru_santa.yml b/src/main/resources/locales/ru_santa.yml deleted file mode 100644 index 732d369..0000000 --- a/src/main/resources/locales/ru_santa.yml +++ /dev/null @@ -1,43 +0,0 @@ -#_UNUSED is a keyword to disable message. - -#For chat -plugin-name: ะะพะฒั‹ะน ะ“ะพะด! - -messages: - plugin-enabled: ะกั‡ะฐัั‚ะปะธะฒะพะณะพ ะ ะพะถะดะตัั‚ะฒะฐ! - timeout: ะŸั€ะฐะทะดะฝะธะบะธ ะฟะพะดะพัˆะปะธ ะบ ะบะพะฝั†ัƒ. ะฃะฒะธะดะธะผัั ะฒ ัะปะตะดัƒัŽั‰ะตะผ ะณะพะดัƒ ;) - final-wish: ะกั‡ะฐัั‚ะปะธะฒะพะณะพ ะะพะฒะพะณะพ ะณะพะดะฐ! - tree: - grow-lvl-ready: ะะฐะถะผะธั‚ะต ะฟั€ะฐะฒะพะน ะบะฝะพะฟะบะพะน ั‡ั‚ะพะฑั‹ ะฟะพะดะฝัั‚ัŒ ัƒั€ะพะฒะตะฝัŒ ะดะตั€ะตะฒะฐ! - grow-lvl-progress: _UNUSED - grow-req-list-title: ะขั€ะตะฑัƒะตั‚ัั - grow-not-enough-place: ะะตะดะพัั‚ะฐั‚ะพั‡ะฝะพ ะผะตัั‚ะฐ! - grow-lvl-max: ะ”ะตั€ะตะฒะพ ัƒะถะต ะธะผะตะตั‚ ะผะฐะบัะธะผะฐะปัŒะฝั‹ะน ัƒั€ะพะฒะตะฝัŒ - tree-limit: ะะต ะถะฐะดะฝะธั‡ะฐะนั‚ะต! (ะกะปะธัˆะบะพะผ ะผะฝะพะณะพ ะดะตั€ะตะฒัŒะตะฒ) - destroy-sapling: ะญั‚ะพ ั‡ัƒะดะพ ะฝะต ะฒั‹ะฟะฐะดะตั‚. ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? :< - destroy-leaves-santa: ะกะฐะฝั‚ะฐ ะšะปะฐัƒั ะฒัะต ะฒะธะดะธั‚! - destroy-leaves-tut: ะ”ะปั ั‚ะพะณะพ ั‡ั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะ ะพะถะดะตัั‚ะฒะตะฝัะบะพะต ะดะตั€ะตะฒะพ - ั€ัƒะฑะธ ัั‚ะฒะพะป! - destroy-warning: ะ’ั‹ ัƒะฒะตั€ะตะฝั‹? ะ’ะตััŒ ะฟั€ะพะณั€ะตัั ะฑัƒะดะตั‚ ะฟะพั‚ะตั€ัะฝ. - destroy-tut: ะงั‚ะพะฑั‹ ัั€ัƒะฑะธั‚ัŒ ะ ะพะถะดะตัั‚ะฒะตะฝัะบะพะต ะดะตั€ะตะฒะพ ั€ะฐะทั€ัƒัˆัŒั‚ะต ะฑะปะพะบ ะตั‰ะต ั€ะฐะท. - destroy-fail-owner: ะญั‚ะพ ะฝะต ะฒะฐัˆะต ะดะตั€ะตะฒะพ! - destroy-complete: ะ˜ ะฝะต ะถะฐะปะบะพ?! - gift: - luck-message: _UNUSED - unluck-message: ะšะฐะถะตั‚ัั ะบั‚ะพ-ั‚ะพ ะฟะปะพั…ะพ ะฒะตะป ัะตะฑั ะฒ ัั‚ะพะผ ะณะพะดัƒ... ะŸะพะฟั€ะพะฑัƒะน ะตั‰ะต ั€ะฐะท. -crystal: - name: ะ’ะพะปัˆะตะฑะฝั‹ะน ะบั€ะธัั‚ะฐะปะป - lore: - - ะกะพะดะตั€ะถะธั‚ ะฒ ัะตะฑะต ะบะพะฝั†ะตะฝั‚ั€ะธั€ะพะฒะฐะฝะฝั‹ะน ะ”ัƒั… ะ ะพะถะดะตัั‚ะฒะฐ! - - ะ˜ัะฟะพะปัŒะทัƒะนั‚ะต ะตะณะพ ะฝะฐ ัะฐะถะตะฝั†ะต ั‡ั‚ะพะฑั‹ ัะดะตะปะฐั‚ัŒ ะตะณะพ ะฒะพะปัˆะตะฑะฝั‹ะผ. -command: - help: - - 'ะšะพะผะฐะฝะดะฐ /xmas - ะพั‚ะพะฑั€ะฐะทะธั‚ัŒ ะฒะตั€ัะธัŽ ะฟะปะฐะณะธะฝะฐ ะธ ัั‚ะฐั‚ัƒั' - - 'ะšะพะผะฐะฝะดะฐ /xmas give ะะธะบ - ะฒั‹ะดะฐั‚ัŒ ะธะณั€ะพะบัƒ ะบั€ะธัั‚ะฐะปะป' - - 'ะšะพะผะฐะฝะดะฐ /xmas gifts - ัะพะทะดะฐั‚ัŒ ะฟะพะดะฐั€ะบะธ ะฟะพะด ะตะปะบะฐะผะธ' - - 'ะšะพะผะฐะฝะดะฐ /xmas addhand - ะดะพะฑะฐะฒะธั‚ัŒ ะฟั€ะตะดะผะตั‚ ะฒ ั€ัƒะบะต ะฒ ะฟะพะดะฐั€ะบะธ' - - 'ะšะพะผะฐะฝะดะฐ /xmas reload - ะฟะตั€ะตะทะฐะณั€ัƒะทะธั‚ัŒ ะบะพะฝั„ะธะณ' - - 'ะšะพะผะฐะฝะดะฐ /xmas debug - ะพั‚ะปะฐะดะพั‡ะฝั‹ะน ัั‚ะฐั‚ัƒั, ะบะพะผะฐะฝะดั‹ ะธ ะฟะตั€ะตะบะปัŽั‡ะฐั‚ะตะปะธ' - - 'ะšะพะผะฐะฝะดะฐ /xmas end - ะทะฐะฒะตั€ัˆะธั‚ัŒ ะฟั€ะฐะทะดะฝะธะบ' - player-offline: 'ะ˜ะณั€ะพะบ ะฝะต ะฝะฐะนะดะตะฝ' - no-player-name: 'ะ’ั‹ ะฝะต ะฒะฒะตะปะธ ะธะผั ะธะณั€ะพะบะฐ!' - giveaway: 'ะฅะพัƒ! ะฅะพัƒ! ะฅะพัƒ! ะšะฐะถะตั‚ัั ะฒั€ะตะผั ะฟั€ะพะฒะตั€ัั‚ัŒ ะฟะพะดะฐั€ะบะธ!' diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 7149a5f..9c47602 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -2,12 +2,12 @@ name: X-Mas version: ${version} main: ru.meloncode.xmas.Main load: POSTWORLD -api-version: '${apiVersionFloor}' +api-version: '${apiVersion}' softdepend: [Multiverse-Core, PlotMe, PlotSquared, Plotz, PlaceholderAPI] commands: xmastree: description: Manage the 1MB XMas Tree event - usage: / [help|give|gifts|addhand|reload|debug [section|page]|end] + usage: / [help|give|gifts [list|roll|remove|spawn]|addhand|reload|inspect|test|data|debug [section|page]|end] permissions: onembxmastree.admin: description: Allows all XMas Tree commands and overrides @@ -19,6 +19,9 @@ permissions: onembxmastree.command.gifts: true onembxmastree.command.addhand: true onembxmastree.command.reload: true + onembxmastree.command.inspect: true + onembxmastree.command.test: true + onembxmastree.command.data: true onembxmastree.command.debug: true onembxmastree.command.debug.toggle: true onembxmastree.command.end: true @@ -41,6 +44,15 @@ permissions: onembxmastree.command.reload: description: Allows /xmastree reload default: op + onembxmastree.command.inspect: + description: Allows /xmastree inspect + default: op + onembxmastree.command.test: + description: Allows /xmastree test + default: op + onembxmastree.command.data: + description: Allows /xmastree data + default: op onembxmastree.command.debug: description: Allows /xmastree debug default: op diff --git a/src/main/resources/translations/locale_en.yml b/src/main/resources/translations/locale_en.yml new file mode 100644 index 0000000..97c6f54 --- /dev/null +++ b/src/main/resources/translations/locale_en.yml @@ -0,0 +1,371 @@ +# XMas Tree translations +# File: plugins/X-Mas/translations/locale_en.yml +# +# This file is the primary source of truth for: +# - player-facing text +# - admin command/debug wording +# - the chat/console prefix +# - the MiniMessage pastel theme aliases +# +# The plugin loads this file by default when core.locale is set to en. +# Custom locales can be added as: +# - plugins/X-Mas/translations/locale_fr.yml +# - plugins/X-Mas/translations/locale_de.yml +# and so on. + +plugin-name: XMas Tree + +theme: + text: '#f7f1e8' + muted: '#c7c0bb' + accent: '#9fe3d6' + accent-secondary: '#f4c2d7' + label: '#f3d38f' + success: '#b9e8b5' + warning: '#f6d58b' + error: '#f3a7a7' + info: '#a9d4ff' + command: '#f4c2d7' + +format: + prefix: '[{plugin_name}] ' + console-prefix: '[{plugin_name}] ' + +messages: + plugin-enabled: Merry Christmas and Happy New Year! + timeout: The holidays are over. See you next year! + final-wish: Happy New Year to all of you, my friends! + + tree: + grow-lvl-ready: Woosh! Right-click the tree to let it grow. + grow-lvl-progress: _UNUSED + grow-req-list-title: Still needed + grow-req-list-hint: Right-click the tree while holding the listed ingredient items to feed them into the tree. + grow-not-enough-place: There is not enough room for the tree to grow. + grow-lvl-max: This tree has reached its maximum level. + tree-limit: Hey, do not be greedy. You have reached your tree limit. + destroy-sapling: You can cut down this tree. + destroy-leaves-santa: Santa Claus sees everything. + destroy-leaves-tut: To destroy a tree, cut the log. + destroy-warning: You can cut down the tree. Hit it again to confirm. + destroy-resource-back: Your used upgrade items will be returned for collection. + destroy-tut: Hit it again to confirm. + destroy-fail-owner: It is not your tree. + destroy-complete: Your tree has been packed away for the season. + refund: + chest: Your tree resources were returned in a chest. + barrel: Your tree resources were returned in a barrel. + inventory: Your tree resources were returned to your inventory. + inventory-overflow: Your tree resources were returned. Inventory overflow dropped at the tree. + + gift: + luck-message: _UNUSED + unluck-message: Probably Santa has put you on the blacklist. Better luck next time. + +crystal: + name: Christmas Crystal + lore: + - Concentrated Christmas Spirit + - Use it on a spruce sapling to fill it with magic. + +command: + help: + - 'Use /xmastree to show plugin version and status.' + - 'Use /xmastree help to show this command list.' + - 'Use /xmastree give \ to give a player a Christmas Crystal.' + - 'Use /xmastree gifts to spawn presents under all trees.' + - 'Use /xmastree gifts list [page] to list configured gift rewards.' + - 'Use /xmastree gifts roll to preview one random gift roll.' + - 'Use /xmastree gifts remove \ to remove a configured gift reward.' + - 'Use /xmastree addhand to add your held item as a gift.' + - 'Use /xmastree reload to reload config, translations, theme, and crystal item text.' + - 'Use /xmastree inspect to inspect the tree you are looking at.' + - 'Use /xmastree test sound first|repeat [player] to preview grow sound volume.' + - 'Use /xmastree test particle \ [effect] [player] to preview configured particles.' + - 'Use /xmastree data backup to back up trees.yml.' + - 'Use /xmastree data validate to check saved tree data.' + - 'Use /xmastree data migrate-world \ \ [dry-run|apply] to migrate saved tree world names.' + - 'Use /xmastree debug to open the status debug section.' + - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, and toggles.' + - 'Use /xmastree debug toggle \ true|false to change supported global toggles.' + - 'Use /xmastree end to end the event.' + player-offline: 'Player not found.' + no-player-name: 'Missing player name.' + giveaway: 'Ho! Ho! Ho! Looks like it is time to check presents.' + no-permission: 'You do not have permission to use this command.' + player-only: 'Only players can use this command.' + hold-item-first: 'Hold an item before running {command}.' + gift-added: 'Added the held item to the gift list.' + reload-success: '{plugin_name} configuration reloaded.' + inspect: + not-found: 'No XMas Tree found nearby or in your line of sight.' + test: + no-particles: 'No enabled particle effects matched that preview.' + data: + backup-created: 'Tree data backup created.' + backup-failed: 'Unable to back up trees.yml.' + migration-applied: 'World migration saved. Restart the server before testing migrated trees.' + migration-failed: 'Unable to migrate tree world names.' + legacy-alias-enabled: 'Legacy alias: /xmas still works.' + +ui: + status: + title: '{plugin_name} {version} Plugin Status' + labels: + event-status: Event Status + current-time: Current Time + holidays-end: Holidays End + auto-end: Auto-End + resource-back: Resource Back + particles: Particles + loaded-trees: Loaded Trees + tree-owners: Tree Owners + help: Help + legacy-alias: Legacy Alias + values: + in-progress: 'In Progress' + holidays-end: 'Holidays End' + + command-descriptions: + status: status + help: command list + give: give a Christmas Crystal + gifts: spawn presents under all trees + gifts-list: list configured gifts + gifts-roll: preview a random gift roll + gifts-remove: remove a configured gift + addhand: add held item to gifts + reload: reload config and translations + inspect: inspect the tree you are looking at + test-sound: preview grow sounds + test-particle: preview configured particles + data-backup: back up trees.yml + data-validate: validate trees.yml + data-migrate-world: migrate saved tree world names + end: end the event + debug: open the status debug section + debug-section: extended debug output by category + debug-toggle: toggle global booleans + + permission-descriptions: + admin: allows all {plugin_name} commands and overrides + status: shows /xmastree status output + help: shows /xmastree help output + give: allows /xmastree give + gifts: allows /xmastree gifts + addhand: allows /xmastree addhand + reload: allows /xmastree reload + inspect: allows /xmastree inspect + test: allows /xmastree test + data: allows /xmastree data + debug: allows /xmastree debug + debug-toggle: allows /xmastree debug toggle + end: allows /xmastree end + tree-override: allows managing other players' trees + + debug: + section-title: '{plugin_name} Debug' + sections: + status: Status + commands: Commands + permissions: Permissions + placeholders: Placeholders + config: Config + labels: + usage: Usage + keys: Keys + unknown-toggle-key: Unknown Toggle Key + value: Value + updated: Updated + next: Next + sections: Debug Sections + pages: Debug Pages + requested: Requested + try: Try + notes: Notes + invalid-boolean: 'must be true or false' + notes: + placeholders: 'Requires PlaceholderAPI. Use ''_'' after prefix, then dotted keys.' + placeholder-default-description: registered placeholder + + inspect: + title: '{plugin_name} Tree Inspect' + labels: + owner: Owner + tree-id: Tree ID + level: Level + location: Location + can-level-up: Can Level Up + present-timer: Present Timer + scheduled-presents: Scheduled Presents + remaining: Remaining Requirements + refund-preview: Refund Preview + requested: Requested + try: Try + usage: Usage + values: + none: none + unknown-owner: unknown + unknown-location: unknown + + reload: + title: Reload Summary + labels: + locale: Locale + gifts: Gifts + present-heads: Present Heads + trees: Trees + owners: Owners + particles: Particles + resource-back: Resource Back + legacy-alias: Legacy Alias + sounds: Sounds + + test: + labels: + usage: Usage + unknown-test: Unknown Test + try: Try + sound: Sound + particle: Particle + target: Target + volume: Volume + level: Level + levels: Levels + effects-played: Effects Played + + gifts: + title: Gift Pool + labels: + usage: Usage + unknown-action: Unknown Gift Action + total: Total + rolled: Rolled + removed: Removed + index: Index + try: Try + values: + empty: 'No gifts are configured.' + + data: + title: Tree Data Validation + world-migration-title: Tree World Migration + labels: + usage: Usage + unknown-action: Unknown Data Action + try: Try + file: File + error: Error + stored-trees: Stored Trees + loaded-trees: Loaded Trees + status: Status + invalid-tree-ids: Invalid Tree IDs + invalid-owners: Invalid Owners + invalid-levels: Invalid Levels + invalid-locations: Invalid Locations + missing-worlds: Missing Worlds + invalid-requirements: Invalid Requirements + duplicate-locations: Duplicate Locations + source-world: Source World + target-world: Target World + matched-trees: Matched Trees + mode: Mode + backup: Backup + apply-command: Apply Command + values: + clean: 'clean' + warnings: 'warnings' + +placeholders: + values: + active: Active + inactive: Inactive + in-progress: In Progress + holidays-end: Holidays End + manual: manual + yes: Yes + no: No + unknown: unknown + disabled: disabled + ended: ended + duration-days-hours: '{days}d {hours}h' + duration-hours-minutes: '{hours}h {minutes}m' + duration-minutes: '{minutes}m' + descriptions: + event-active: whether the event is currently active + event-active-text: human-readable active state + event-status: current event status text + event-starts-at: event start mode + event-ends-at: configured event end date + event-ends-in: time remaining until the event ends + event-ends-timestamp: event end timestamp in milliseconds + event-auto-end: whether automatic ending is enabled + resource-back: whether resource refunds are enabled + resource-back-text: human-readable refund state + particles-enabled: whether XMas Tree particles are enabled + luck-enabled: whether gift luck chance is enabled + luck-chance: gift luck chance as a percent + trees-total: total loaded tree count + trees-owners: number of unique tree owners + player-trees: loaded trees owned by the placeholder player + version: loaded plugin version + +console: + translation: + loaded: Loaded translation '{file}'. + missing: Translation '{file}' was not found. + fallback: Falling back to locale_en.yml. + create-directory-failed: Unable to create translations directory at {directory} + migrated-legacy: Migrated legacy locale '{source}' to '{target}'. + migrate-legacy-failed: 'Unable to migrate legacy locale ''{source}'' to ''{target}'': {error}' + config: + update-speed-invalid: Update speed must be > 0 + update-speed-reset: Setting value to default + unable-load-holiday-end-date: Unable to load holiday end date + invalid-holiday-end-date: Invalid holiday end date in config.yml + load-yaml-failed: Failed to load YAML configuration from {file} + create-directory-failed: Unable to create configuration directory {directory} + save-yaml-failed: Failed to save YAML configuration to {file} + missing-bundled-resource: Missing bundled configuration resource {resource} + load-bundled-resource-failed: Failed to load bundled configuration resource {resource} + gifts: + no-heads: Warning! No heads loaded. Presents cannot spawn without a box head. + load-item-failed: 'Failed to load gift item: {item}' + no-gifts: Warning! No gifts loaded. No X-Mas without gifts. + deserialize-item-failed: 'Failed to deserialize gift item: {item}' + serialize-list-item-failed: 'Failed to serialize item for saving to the gift list. Item: {item}' + serialize-item-failed: 'Failed to serialize item: {error}' + too-large: 'Gift item is too large to deserialize safely: {length} characters' + invalid-material-or-base64: 'Invalid material name or Base64 gift item: {item}' + deserialize-error: 'Failed to deserialize gift item: {error}' + placeholder: + registered: 'Registered PlaceholderAPI expansion: {identifier}' + registration-failed: PlaceholderAPI is present, but placeholder registration failed. + particles: + unknown: Unknown particle '{particle}' at {path}. Using fallback. + extra-data-unsupported: Particle '{particle}' needs extra data and is not supported in config yet. Using fallback. + spawn-failed: 'Failed to spawn particle {particle}: {error}' + trees: + load-tree-error: Error while loading tree {tree} + load-error: Error while loading trees + unable-load: Unable to load X-Mas trees + unable-save: Unable to save X-Mas tree data + unable-remove: Unable to remove X-Mas tree data + material-missing: Cannot find modern material '{material}' for tree level. + material-numeric-required: Tree level material '{material}' must use a numeric amount. + material-load-failed: Cannot load material '{material}' for tree level. + world-alias: Loading legacy X-Mas trees from saved world '{source}' into '{target}' via migration.world-aliases. + alias: + command-map-unavailable: Unable to access the Bukkit command map. Skipping legacy /xmas alias registration. + owned-by-plugin: Legacy alias '/{alias}' is already owned by plugin '{plugin}'. Skipping alias registration. + registered-by-other: Legacy alias '/{alias}' is already registered by another command source. Skipping alias registration. + create-failed: Unable to create the legacy /xmas alias command. + registered: Registered legacy alias '/{alias}' for '/{primary}'. + construct-failed: 'Unable to construct dynamic command ''/{command}'': {error}' + heads: + apply-failed: Unable to apply configured present head profile. + non-mojang-url: 'Ignoring non-Mojang present skin URL: {url}' + invalid-url: 'Invalid present skin URL: {url}' + build-failed: 'Failed to build present head profile: {error}' + refund: + place-container-failed: 'Failed to place refund {container}: {error}' diff --git a/todo.log b/todo.log index 96abf8d..7ebdcc5 100644 --- a/todo.log +++ b/todo.log @@ -1,8 +1,95 @@ Remaining warnings and deferred deprecation cleanup ================================================= -- `compileJava` still prints the generic note that some source uses deprecated API. -- The remaining known spots are the block-skull profile accessors and setters in: - - `src/main/java/ru/meloncode/xmas/MagicTree.java` - - `src/main/java/ru/meloncode/xmas/XMas.java` -- These were left in place on purpose because the newer block-skull `ResolvableProfile` route is not a clearly low-risk drop-in if we want to preserve the current texture URL and player-name behavior on both Paper versions. +- `compileJava` is clean after the Paper 26.x deprecation sweep. +- Present heads are now tagged with plugin PDC data, so gift detection no longer depends on deprecated skull-profile reads. +- The active custom-fork target is now Paper 26.2 with Java 25 bytecode. +- To run an explicit deprecation lint pass in future work, use: + - `gradle clean compileJava -PlintDeprecatedApi=true` +- Head texture application now uses a reflective bridge to Paper's current profile APIs so the plugin can preserve existing player-name and texture-URL configs without keeping deprecated profile methods in the compiled code. + + +Modernization and quality-of-life roadmap +========================================= + +Near-term implementation candidates +----------------------------------- + +- Finish MiniMessage theme polish: + - move any remaining visible colors, labels, and wording into `translations/locale_en.yml` + - tune the soft 1MB pastel theme for chat, debug pages, crystal text, requirement lists, and refund messages + +- Add admin preview and test commands: + - done: inspect tree state + - done: preview particles by tree stage with `/xmastree test particle` + - done: test first/repeat grow sounds with `/xmastree test sound` + - later: test refund delivery safely + +- Improve tree inspection: + - show owner, tree UUID, level, world/location, remaining requirements, spent/refundable resources, present timer, scheduled presents, and whether it can level up + - policy-must: existing trees from previous years must keep working; returning players waited for their legacy tree and should be able to keep it while receiving another crystal this year, letting them have two trees and earn more gifts + +- Add gift management UI: + - done: view configured gifts with `/xmastree gifts list` + - add held item + - done: remove gifts with `/xmastree gifts remove ` + - done: test a gift roll with `/xmastree gifts roll` + - eventually support weighted gift pools + +- Modernize PlaceholderAPI output: + - player tree count + - player highest tree level + - total active trees + - event status text + - future timer/status values useful for CMI holograms and ajLeaderboards + +- Add safer data tools: + - done: backup `trees.yml` with `/xmastree data backup` + - done: validate saved tree data with `/xmastree data validate` + - done: dry-run/apply saved world-name migration with `/xmastree data migrate-world [dry-run|apply]` + - report missing worlds, invalid owners, invalid levels, and duplicate/overlapping tree data + +- Continue present head and texture cleanup: + - verify Paper 26.2 profile behavior for player names and texture URLs + - improve diagnostics around invalid head entries + +- Add a reload report: + - show locale, gift count, present head count, tree count, tree owner count, particles setting, sound volumes, legacy alias state, and any warnings + +Future feature ideas to review +------------------------------ + +- Add event streaks: + - 6 gift claims in a day for a "hardcore elf" style daily milestone + - 7 days in a row + - every day of the event/month + - add a player command or GUI to view streak progress and claim milestone rewards + - possible milestone reward: trigger extra gifts for all online players + +- Add one extra progression level after the current max tree: + - "super tree" or similarly festive final stage + - modern Paper 26.2 particles/effects + - distinctive design for returning players who can upgrade their old max tree once more + +- Add seasonal admin presets: + - save/load named config presets for gift pools, particle styles, sound tuning, and event dates + +- Add player tree journal: + - command or GUI showing a player's tree history, current tree locations, old legacy tree, current-year tree, upgrade progress, and collected milestone rewards + +- Add community milestones: + - total server-wide gifts opened + - total trees grown + - unlock server-wide bonus gift waves when thresholds are reached + +- Add configurable tree protection helpers: + - staff-only teleport to a tree + - flag trees in unsafe/unloaded/missing worlds + - optional highlight particles around a selected tree for support checks + +- Add event wrap-up report: + - final tree count + - active owners + - gifts spawned/opened + - legacy trees preserved + - players eligible for returning-player rewards From ee09b8e633ab21d11260467e2d59bad3128d086a Mon Sep 17 00:00:00 2001 From: Floris Fiedeldij Dop Date: Sun, 12 Jul 2026 17:57:25 +0200 Subject: [PATCH 09/10] Modernize XMasTree for Paper 26.2 Add Paper 26.2/Java 25 build metadata, event progress tracking, journal and milestone systems, community progress, diagnostics, safer gift/head handling, data reports, and public documentation for the central 1MoreBlock docs importer. --- README.md | 159 ++- build.gradle | 2 +- docs/README.md | 22 + docs/commands.md | 58 + docs/configuration.md | 151 +++ docs/installation.md | 88 ++ docs/integrations.md | 57 + docs/permissions.md | 30 + docs/placeholders.md | 63 + docs/player-guide.md | 92 ++ docs/plugin-docs.yml | 12 + docs/troubleshooting.md | 150 +++ src/main/java/ru/meloncode/xmas/Effects.java | 2 + .../java/ru/meloncode/xmas/EventProgress.java | 524 ++++++++ src/main/java/ru/meloncode/xmas/Events.java | 36 +- .../xmas/JournalInventoryHolder.java | 23 + .../java/ru/meloncode/xmas/MagicTree.java | 86 +- src/main/java/ru/meloncode/xmas/Main.java | 434 ++++++- .../java/ru/meloncode/xmas/TreeLevel.java | 3 + .../ru/meloncode/xmas/TreeSerializer.java | 90 +- src/main/java/ru/meloncode/xmas/XMas.java | 21 +- .../java/ru/meloncode/xmas/XMasCommand.java | 1061 ++++++++++++++--- .../ru/meloncode/xmas/XMasPlaceholders.java | 90 ++ .../xmas/utils/HeadProfileUtils.java | 55 +- .../ru/meloncode/xmas/utils/TextUtils.java | 2 +- src/main/resources/config.yml | 352 +++++- src/main/resources/plugin.yml | 28 +- src/main/resources/translations/locale_en.yml | 250 +++- todo.log | 63 +- 29 files changed, 3636 insertions(+), 368 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/commands.md create mode 100644 docs/configuration.md create mode 100644 docs/installation.md create mode 100644 docs/integrations.md create mode 100644 docs/permissions.md create mode 100644 docs/placeholders.md create mode 100644 docs/player-guide.md create mode 100644 docs/plugin-docs.yml create mode 100644 docs/troubleshooting.md create mode 100644 src/main/java/ru/meloncode/xmas/EventProgress.java create mode 100644 src/main/java/ru/meloncode/xmas/JournalInventoryHolder.java diff --git a/README.md b/README.md index de7f2ee..8733d03 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ The Gradle build creates the current Paper 26.2 target jar in `build/libs`: | Jar | Purpose | | --- | --- | -| `1MB-XMas-2026-v2.1.0-032-v25-26.2.jar` | Modern Paper 26.2 build, Java 25 bytecode. | +| `1MB-XMas-2026-v2.1.0-051-v25-26.2.jar` | Modern Paper 26.2 build, Java 25 bytecode. | The checked-in source compiles against the Paper 26.2 API, targets Java 25, and declares `api-version: 26.2`. This fork now treats Paper 26.2 as the primary winter 2026 target, with future 26.x builds as the expected forward-compatibility path. @@ -27,13 +27,29 @@ The checked-in source compiles against the Paper 26.2 API, targets Java 25, and - Existing `plugins/X-Mas/trees.yml` data remains the event data source. - Optional resource refunds when a tree is destroyed or cleaned up after the event. - Configurable per-stage particles using Paper 26.2 particle names. -- `/xmastree debug` sections for `status`, `commands`, `permissions`, `placeholders`, and `config`, plus live global boolean toggles. -- Primary `/xmastree` command with an optional legacy `/xmas` alias. +- `/xmastree debug` sections for `status`, `commands`, `permissions`, `placeholders`, `config`, and `diagnostics`, plus live global boolean toggles. +- Single primary `/xmastree` command; `/xmas` is no longer registered by this plugin. - Optional PlaceholderAPI placeholders for CMI holograms, ajLeaderboards, scoreboards, and menus. - Legacy `trees.yml` world-name alias support for renamed destination worlds. - Comment-preserving `config.yml` syncing that keeps admin values while safely adding missing defaults and missing template comments. - Present heads are tagged with plugin PDC data, so gift handling no longer depends on deprecated skull profile reads. +## Documentation + +- [Player Guide](docs/player-guide.md) +- [Commands](docs/commands.md) +- [Permissions](docs/permissions.md) +- [Placeholders](docs/placeholders.md) +- [Configuration](docs/configuration.md) +- [Installation and Building](docs/installation.md) +- [Integrations](docs/integrations.md) +- [Troubleshooting](docs/troubleshooting.md) +- [Importer Manifest](docs/plugin-docs.yml) + +Canonical public docs URL: + +[docs.1moreblock.com/custom-server-plugins/xmas-tree/](https://docs.1moreblock.com/custom-server-plugins/xmas-tree/) + ## v2 changelog - modernize the plugin from the legacy deployed build to an actively maintained Paper 26.2 / Java 25 Gradle build @@ -45,29 +61,39 @@ The checked-in source compiles against the Paper 26.2 API, targets Java 25, and - preserve old event data while modernizing the runtime and admin tooling - make `/xmastree` the primary command -- keep `/xmas` as an optional legacy alias controlled by config -- fix legacy alias reload and unregister behavior so reload no longer crashes when alias settings change +- remove the old `/xmas` command alias so this plugin keeps one clear command surface - improve `/xmastree help` output and keep it aligned with the actual command surface +- add `/xmastree info` as a concise player-facing event guide +- add community gift-opening milestones at 250 and 500 opens, with larger global present-spawn rewards +- retune sapling, small-tree, and super-tree particles toward a quieter winter/XMas style +- expand milestone reward commands with console-run tokens for kits, economy, CMI messages, and announcements +- add milestone reward dry-runs, logged reward command results, community claim confirmation, progress backups, and debounced progress saves - add granular permissions under `onembxmastree.*` - replace the old `xmas.admin` permission with `onembxmastree.admin` - add separate permissions for `status`, `help`, `give`, `gifts`, `addhand`, `reload`, `debug`, `debug.toggle`, `end`, and `tree.override` -- add a modern debug system with named categories: `status`, `commands`, `permissions`, `placeholders`, and `config` +- add a modern debug system with named categories: `status`, `commands`, `permissions`, `placeholders`, `config`, and `diagnostics` - keep numeric debug pages working as a legacy shortcut - improve debug output formatting with clearer key/value coloring - make invalid debug page or section requests return a helpful response instead of silently falling back - add `/xmastree debug toggle true|false` for live boolean config changes - keep tab completion focused on named debug categories instead of numeric page suggestions - add `/xmastree inspect` for staff tree support checks, including owner, tree UUID, level, location, remaining requirements, refund preview, present timer, and scheduled presents -- make `/xmastree reload` print a short reload report with locale, gift/head/tree counts, key toggles, alias state, and sound volumes +- expand `/xmastree inspect` with owner online state, owner tree count, world/chunk state, built block count, next level, next-level space check, gift cooldown, nearby present count, requirement progress, spent resources, and refund state +- make `/xmastree reload` print a reload report with locale, gift/head/tree counts, key toggles, sound volumes, and config/data warnings - add `/xmastree test sound` and `/xmastree test particle` so admins can preview reloadable sound volume and particle settings without waiting for live tree interactions +- add `/xmastree test refund` for a safe dry run of refund delivery without editing blocks, inventories, tree data, or config - add `/xmastree data backup` and `/xmastree data validate` for safe `trees.yml` support checks before event work or migration testing - add `/xmastree data migrate-world [dry-run|apply]` so saved tree world names can be reviewed and migrated safely with an automatic backup - add `/xmastree gifts list`, `/xmastree gifts roll`, and `/xmastree gifts remove ` for command-line gift pool management before a GUI exists +- add weighted gift pools with `/xmastree gifts weight ` while keeping old simple gift entries compatible as weight `1` +- add `/xmastree gifts addhand` as the grouped gift-management command, while keeping `/xmastree addhand` as a compatibility shortcut +- add present head diagnostics for valid texture URLs, legacy player-name heads, invalid head entries, invalid gift entries, and tree-data validation warnings - add optional PlaceholderAPI support with the `onembxmastree` namespace - add placeholders for event state, end time, end countdown, auto-end, resource-back, particles, luck, tree totals, owner totals, player tree count, and plugin version +- add player highest-tree placeholders for CMI holograms, scoreboards, and ajLeaderboards - document placeholders in the README and show them in debug output - modernize message handling with MiniMessage support while keeping legacy color compatibility @@ -109,7 +135,7 @@ The checked-in source compiles against the Paper 26.2 API, targets Java 25, and For the 2026 target, use the modern Paper 26.2 jar: -- Paper 26.2: `1MB-XMas-2026-v2.1.0-032-v25-26.2.jar` +- Paper 26.2: `1MB-XMas-2026-v2.1.0-051-v25-26.2.jar` - Future 26.x: use the same jar for forward-compatibility testing ## Building @@ -166,33 +192,44 @@ End users do not need any `servers/` folder. The installable jars are written to ## Commands -The primary command is `/xmastree`. - -If `core.commands.legacy-command-enabled` is `true`, the legacy `/xmas` alias is also registered. +The only command registered by this plugin is `/xmastree`. The old `/xmas` alias is no longer registered, leaving `/xmas` free for a broader event command or another plugin. | Command | Description | | --- | --- | | `/xmastree` | Shows plugin version, event status, auto-end status, resource-back status, tree count, and owner count. | | `/xmastree help` | Shows the command list. | +| `/xmastree info` | Explains how players start the event, grow trees, claim gifts, reclaim ingredients, and progress streak milestones. | | `/xmastree give ` | Gives an online player a Christmas Crystal. | | `/xmastree gifts` | Spawns a small batch of presents under every loaded Christmas tree. | | `/xmastree gifts spawn` | Explicit form of `/xmastree gifts`; spawns presents under every loaded Christmas tree. | -| `/xmastree gifts list [page]` | Lists configured gift rewards by index. | -| `/xmastree gifts roll` | Previews one random gift roll without giving it to a player. | +| `/xmastree gifts list [page]` | Lists configured gift rewards by index, weight, and roll chance. | +| `/xmastree gifts roll` | Previews one weighted random gift roll without giving it to a player. | | `/xmastree gifts remove ` | Removes a configured gift reward by the index shown in `/xmastree gifts list`. | -| `/xmastree addhand` | Adds the item in your main hand to the gift list and saves it to `config.yml`. | -| `/xmastree reload` | Reloads config, translations, theme, prefix, crystal item text, present heads, gifts, luck settings, command alias settings, and tree level requirements, then prints a short reload report. | +| `/xmastree gifts weight ` | Sets a configured gift reward's roll weight. Weight must be a positive whole number. | +| `/xmastree gifts addhand` | Adds the item in your main hand to the gift list and saves it to `config.yml`. | +| `/xmastree addhand` | Compatibility shortcut for `/xmastree gifts addhand`. | +| `/xmastree reload` | Reloads config, translations, theme, prefix, crystal item text, present heads, gifts, luck settings, and tree level requirements, then prints a reload report with warning diagnostics. | | `/xmastree inspect` | Inspects the tree you are looking at, or the nearest tree if no tree block is targeted. | | `/xmastree inspect nearest ` | Inspects the nearest loaded tree to an online player. | | `/xmastree inspect ` | Inspects a specific loaded tree by UUID. | | `/xmastree test sound first [player]` | Plays the configured first ingredient sound volume for yourself or an online player. | | `/xmastree test sound repeat [player]` | Plays the configured repeat ingredient sound volume for yourself or an online player. | -| `/xmastree test particle [all\|ambient\|swag\|body] [player]` | Previews configured particle effects for `sapling`, `small_tree`, `tree`, or `magic_tree`. | -| `/xmastree data backup` | Creates a timestamped copy of `plugins/X-Mas/trees.yml` in `plugins/X-Mas/backups/`. | +| `/xmastree test particle [all\|ambient\|swag\|body] [player]` | Previews configured particle effects for `sapling`, `small_tree`, `tree`, `magic_tree`, or `super_tree`. | +| `/xmastree test refund [tree-uuid\|nearest [player]]` | Dry-runs refund delivery for a tree. This reports the target tree, refund items, and delivery order without editing blocks, inventories, tree data, or config. | +| `/xmastree data backup [trees\|progress\|all]` | Creates timestamped backups in `plugins/X-Mas/backups/`. Defaults to `trees`. | | `/xmastree data validate` | Reads `trees.yml` and reports missing worlds, invalid IDs, owners, levels, coordinates, requirements, and duplicate locations. | +| `/xmastree data report` | Shows an event wrap-up report with saved/loaded tree counts, owners, level counts, gifts opened, milestone claim counts, legacy trees preserved, and returning-player reward candidates. | | `/xmastree data migrate-world [dry-run\|apply]` | Counts or rewrites saved tree world names in `trees.yml`. `dry-run` is the default. `apply` creates a backup first, writes the change, and should be followed by a server restart before testing migrated trees. | +| `/xmastree journal [player]` | Shows tree count, highest tree level, gift-open totals, streaks, claimable milestone count, recent tree history, and claimed milestone history. Viewing another player requires `onembxmastree.tree.override`. | +| `/xmastree journal gui [player]` | Opens an informational journal GUI for yourself or, with override permission, another player. | +| `/xmastree milestones [player]` | Shows personal milestone progress and claim state. Viewing another player requires `onembxmastree.tree.override`. | +| `/xmastree milestones test [player]` | Previews personal milestone reward commands without claiming or running them. | +| `/xmastree milestones claim ` | Claims a ready personal milestone reward for yourself. | +| `/xmastree community` | Shows server-wide community milestone progress. | +| `/xmastree community test ` | Previews community milestone reward commands without claiming or running them. | +| `/xmastree community claim confirm` | Claims a ready community milestone reward. Defaults to staff/admin access and requires `confirm`. | | `/xmastree debug` | Opens the `status` debug section by default. | -| `/xmastree debug [section\|page]` | Shows debug output for `status`, `commands`, `permissions`, `placeholders`, or `config`. Numeric pages `1-5` still work as a legacy shortcut. | +| `/xmastree debug [section\|page]` | Shows debug output for `status`, `commands`, `permissions`, `placeholders`, `config`, or `diagnostics`. Numeric pages `1-6` still work as a legacy shortcut. | | `/xmastree debug toggle true\|false` | Toggles supported global boolean config keys and reloads the plugin config. | | `/xmastree end` | Ends the event and sets `core.plugin-enabled` to `false`. | @@ -203,10 +240,11 @@ The preferred debug syntax is category-based: | Section | Example | Purpose | | --- | --- | --- | | `status` | `/xmastree debug status` | Event state, end date, auto-end, refund state, particles, loaded tree count, and owner count. | -| `commands` | `/xmastree debug commands` | Command list, debug syntax, and legacy alias state. | +| `commands` | `/xmastree debug commands` | Command list and debug syntax. | | `permissions` | `/xmastree debug permissions` | All registered `onembxmastree.*` permissions and what they allow. | | `placeholders` | `/xmastree debug placeholders` | All built-in PlaceholderAPI placeholders plus their descriptions. | | `config` | `/xmastree debug config` | The current values of the toggleable global config keys. | +| `diagnostics` | `/xmastree debug diagnostics` | Present head, gift pool, and tree-data warning counts plus warning details. | Numeric compatibility remains available for existing habits and old screenshots: @@ -217,12 +255,12 @@ Numeric compatibility remains available for existing habits and old screenshots: | `3` | `permissions` | | `4` | `placeholders` | | `5` | `config` | +| `6` | `diagnostics` | ### Debug toggle keys `/xmastree debug toggle true|false` currently supports: -- `core.commands.legacy-command-enabled` - `core.plugin-enabled` - `core.holiday-ends.enabled` - `core.holiday-ends.resource-back` @@ -236,13 +274,19 @@ Numeric compatibility remains available for existing habits and old screenshots: | `onembxmastree.admin` | `op` | Umbrella permission for all XMas Tree commands and override actions. | | `onembxmastree.command.status` | `true` | Allows viewing `/xmastree` status output. | | `onembxmastree.command.help` | `true` | Allows viewing `/xmastree help`. | +| `onembxmastree.command.info` | `true` | Allows viewing `/xmastree info`. | | `onembxmastree.command.give` | `op` | Allows `/xmastree give `. | -| `onembxmastree.command.gifts` | `op` | Allows `/xmastree gifts`. | -| `onembxmastree.command.addhand` | `op` | Allows `/xmastree addhand`. | +| `onembxmastree.command.gifts` | `op` | Allows `/xmastree gifts`, including list, roll, remove, weight, and spawn actions. | +| `onembxmastree.command.addhand` | `op` | Allows `/xmastree gifts addhand` and `/xmastree addhand`. | | `onembxmastree.command.reload` | `op` | Allows `/xmastree reload`. | | `onembxmastree.command.inspect` | `op` | Allows `/xmastree inspect`. | -| `onembxmastree.command.test` | `op` | Allows `/xmastree test sound` and `/xmastree test particle`. | -| `onembxmastree.command.data` | `op` | Allows `/xmastree data backup` and `/xmastree data validate`. | +| `onembxmastree.command.test` | `op` | Allows `/xmastree test sound`, `/xmastree test particle`, and `/xmastree test refund`. | +| `onembxmastree.command.data` | `op` | Allows `/xmastree data backup`, `/xmastree data validate`, `/xmastree data report`, and `/xmastree data migrate-world`. | +| `onembxmastree.command.journal` | `true` | Allows `/xmastree journal` and `/xmastree journal gui`. | +| `onembxmastree.command.milestones` | `true` | Allows `/xmastree milestones`. | +| `onembxmastree.command.milestones.claim` | `true` | Allows `/xmastree milestones claim `. | +| `onembxmastree.command.community` | `true` | Allows `/xmastree community`. | +| `onembxmastree.command.community.claim` | `op` | Allows `/xmastree community claim confirm`. | | `onembxmastree.command.debug` | `op` | Allows `/xmastree debug [section\|page]`. | | `onembxmastree.command.debug.toggle` | `op` | Allows `/xmastree debug toggle true\|false`. | | `onembxmastree.command.end` | `op` | Allows `/xmastree end`. | @@ -252,13 +296,44 @@ Numeric compatibility remains available for existing habits and old screenshots: Players can receive a Christmas Crystal from an admin, or craft one with diamonds around an emerald in a cross shape. Right-click a spruce sapling with the crystal to create a magic tree. -After planting, players right-click the tree with the configured level-up materials. The requirement header is short in chat and includes a hover hint that explains the ingredients must be fed into the tree by right-clicking while holding them. When all requirements for a level are complete, right-click the tree again to grow it. Presents spawn around grown trees while the event is enabled. +After planting, players right-click the tree with the configured level-up materials. The requirement header is short in chat and includes a hover hint that explains the ingredients must be fed into the tree by right-clicking while holding them. When all requirements for a level are complete, right-click the tree again to grow it. The 2026 fork adds a fifth hardcoded `super_tree` stage after `magic_tree`, giving returning players a new upgrade target without invalidating their preserved legacy trees. Presents spawn around grown trees while the event is enabled. + +Opening presents records event progress in `progress.yml`. Players can use `/xmastree journal`, `/xmastree journal gui`, and `/xmastree milestones` to see daily gift claims, total gift claims, streaks, ready milestone rewards, recent tree history, and claimed milestone history. Community progress is visible with `/xmastree community`; staff can preview community milestone rewards with `/xmastree community test ` and claim ready community milestones with `/xmastree community claim confirm`. + +Personal and community milestone rewards are configured in `config.yml` under `xmas.milestones.*..reward-commands`. These commands always run from console when the milestone is claimed. Commands can omit a leading slash. + +Supported reward command tokens: + +- `{player}`: claiming player for personal milestones; staff/admin claimer for community milestones. +- `{claimer}`: same as the command sender who claimed the milestone. +- `{milestone}`: milestone config key, such as `streak_7` or `gifts_250`. +- `{display_name}`: milestone display name. +- `{target}`: configured milestone target. +- `{progress}`: progress value at claim time. + +Example reward commands: + +```yaml +reward-commands: + - cmi kit christmas {player} + - cmi money give {player} 250 + - cmi toast {player} -t:task -icon:EMERALD -text:"{display_name} complete!" + - cmi broadcast ! {display_name} reached: {progress}/{target} +``` + +The bundled community milestones are: + +- `gifts_100`: 100 community gift opens, runs `/xmastree gifts` once. +- `gifts_250`: 250 community gift opens, runs `/xmastree gifts` twice. +- `gifts_500`: 500 community gift opens, runs `/xmastree gifts` three times. + +Progress writes are batched briefly during active play and flushed on reload, disable, and progress backup. Use `/xmastree data backup progress` before manual progress edits, `/xmastree data backup all` before larger event maintenance, and `/xmastree data report` for an end-of-event wrap-up summary. If `core.holiday-ends.resource-back` is enabled, confirmed tree destruction returns the upgrade materials that were actually spent on the tree. The plugin tries to place a chest first, then a barrel, then the player's inventory, and finally drops any overflow at the tree location. Ingredient accept sounds can be tuned live in `config.yml` under `core.sounds.grow`. Use `0.0` for silent, `0.1` for quiet, `0.5` for half volume, and `1.0` for full volume. `/xmastree reload` applies the new values without a server restart. -Admins can preview those sound values with `/xmastree test sound first` and `/xmastree test sound repeat`. Particle configuration can be previewed with `/xmastree test particle sapling`, `/xmastree test particle small_tree`, `/xmastree test particle tree`, or `/xmastree test particle magic_tree`. +Admins can preview those sound values with `/xmastree test sound first` and `/xmastree test sound repeat`. Particle configuration can be previewed with `/xmastree test particle sapling`, `/xmastree test particle small_tree`, `/xmastree test particle tree`, `/xmastree test particle magic_tree`, or `/xmastree test particle super_tree`. Refund delivery can be safely reviewed with `/xmastree test refund`, `/xmastree test refund nearest `, or `/xmastree test refund ` without changing the tree or giving any items. Tree support checks should use `/xmastree inspect`, which reports owner, UUID, world/chunk state, progression, remaining requirements, refund preview, present timers, and nearby present state. ## Configuration @@ -266,6 +341,7 @@ The plugin writes its files to `plugins/X-Mas`: - `config.yml` controls event timing, locale, tree limits, gift cooldowns, present skins, gift items, and level-up requirements. - `trees.yml` stores placed tree data and should be kept when upgrading an existing event. +- `progress.yml` stores present-open totals, daily streak data, and claimed milestone state. - `translations/locale_en.yml` is the bundled English source of truth for player/admin text, theme colors, and prefixes. - optional custom translations can be added as `translations/locale_.yml`. @@ -278,13 +354,23 @@ Gift entries in `xmas.gifts` can be simple material names: - EMERALD:3 ``` -Admins can also hold an item and run `/xmastree addhand`. This saves the exact item as Base64 so custom names, lore, enchantments, and metadata can be used as gifts. +Gift entries can also be weighted. Existing simple entries automatically count as weight `1`. + +```yaml +- item: DIAMOND + weight: 10 +- item: EMERALD:3 + weight: 5 +``` + +Admins can also hold an item and run `/xmastree gifts addhand`. The older `/xmastree addhand` shortcut still works. This saves the exact item as Base64 so custom names, lore, enchantments, and metadata can be used as gifts. Gift pool maintenance commands: -- `/xmastree gifts list [page]` shows the currently loaded gift rewards. -- `/xmastree gifts roll` previews one random gift selection without giving it to anyone. +- `/xmastree gifts list [page]` shows the currently loaded gift rewards, weights, and roll chances. +- `/xmastree gifts roll` previews one weighted random gift selection without giving it to anyone. - `/xmastree gifts remove ` removes the indexed gift from `xmas.gifts`, saves `config.yml`, and reloads the gift pool. +- `/xmastree gifts weight ` converts or updates the indexed gift entry to the weighted format, saves `config.yml`, and reloads the gift pool. Legacy world-name remapping lives under `migration.world-aliases`. This is useful when an old `trees.yml` was saved in worlds like `general`, `wild`, or `santa`, but the new Paper 26.2 server uses different world names: @@ -307,7 +393,9 @@ Admins can preview or apply a world-name rewrite directly against `trees.yml`: The first command is a dry run. The `apply` command creates a timestamped backup in `plugins/X-Mas/backups/`, rewrites matching saved world names, and should be followed by a server restart before testing migrated legacy trees. -Present head entries in `xmas.presents` should use `textures.minecraft.net` URLs. Old player-name entries are still accepted for compatibility. +Present head entries in `xmas.presents` should use `textures.minecraft.net` URLs. Old player-name entries are still accepted for compatibility, but `/xmastree reload` and `/xmastree debug diagnostics` warn about them because texture URLs are more predictable on Paper 26.x. + +Invalid present head entries are skipped when config loads. This includes blank values, malformed URLs, and non-`textures.minecraft.net` URLs. Invalid gift entries are also skipped and reported in diagnostics. Per-stage particles live under `xmas.tree-lvl..particles`. Particle names should come from the Paper 26.2 `Particle` enum: @@ -377,7 +465,16 @@ The dotted key after `onembxmastree_` is supported to keep the placeholders read | `%onembxmastree_trees.total%` | `14` | Total loaded X-Mas trees. | | `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | | `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | -| `%onembxmastree_version%` | `2.1.0-032` | Loaded plugin version. | +| `%onembxmastree_player.highest_tree_level%` | `5` | Highest loaded tree level owned by the placeholder player, as `0-5`. | +| `%onembxmastree_player.highest_tree_level_name%` | `super_tree` | Highest loaded tree level owned by the placeholder player, as a config key. | +| `%onembxmastree_player.highest_tree_level_display%` | `Super Tree` | Highest loaded tree level owned by the placeholder player, as display text. | +| `%onembxmastree_player.gifts_today%` | `6` | Present gifts opened by the placeholder player today. | +| `%onembxmastree_player.gifts_total%` | `25` | Present gifts opened by the placeholder player during the event. | +| `%onembxmastree_player.streak_current%` | `7` | Current daily present-opening streak for the placeholder player. | +| `%onembxmastree_player.streak_best%` | `12` | Best daily present-opening streak for the placeholder player. | +| `%onembxmastree_player.milestones_claimable%` | `1` | Claimable personal milestone count for the placeholder player. | +| `%onembxmastree_community.gifts_opened%` | `100` | Community-wide present gifts opened. | +| `%onembxmastree_version%` | `2.1.0-051` | Loaded plugin version. | CMI hologram example: diff --git a/build.gradle b/build.gradle index 0ab0ad3..b4bb26e 100644 --- a/build.gradle +++ b/build.gradle @@ -3,7 +3,7 @@ plugins { } group = 'com.onemb.xmas' -version = '2.1.0-032' +version = '2.1.0-051' def projectVersion = version.toString() def enableDeprecationLint = providers.gradleProperty('lintDeprecatedApi').map { it.toBoolean() }.getOrElse(false) diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..0a9addc --- /dev/null +++ b/docs/README.md @@ -0,0 +1,22 @@ +# XMas Tree Documentation + +This directory contains source documentation for the central 1MoreBlock docs importer. + +Canonical public URL: + +[docs.1moreblock.com/custom-server-plugins/xmas-tree/](https://docs.1moreblock.com/custom-server-plugins/xmas-tree/) + +## Pages + +- [Player Guide](player-guide.md) +- [Commands](commands.md) +- [Permissions](permissions.md) +- [Placeholders](placeholders.md) +- [Configuration](configuration.md) +- [Installation and Building](installation.md) +- [Integrations](integrations.md) +- [Troubleshooting](troubleshooting.md) + +## Import Manifest + +The importer manifest is [plugin-docs.yml](plugin-docs.yml). diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 0000000..f601319 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,58 @@ +# XMas Tree Commands + +Primary command: `/xmastree` + +The old `/xmas` command is not registered by this plugin, leaving it free for a broader event command or another plugin. + +## Player Commands + +| Command | Permission | Description | +| --- | --- | --- | +| `/xmastree` | `onembxmastree.command.status` | Shows plugin version, event status, auto-end, refund status, tree count, and owner count. | +| `/xmastree help` | `onembxmastree.command.help` | Shows the command list. | +| `/xmastree info` | `onembxmastree.command.info` | Shows a short player guide and the canonical docs link. | +| `/xmastree journal [player]` | `onembxmastree.command.journal` | Shows tree count, highest tree level, gift totals, streaks, claimable milestones, tree history, and claimed milestone history. Viewing another player requires `onembxmastree.tree.override`. | +| `/xmastree journal gui [player]` | `onembxmastree.command.journal` | Opens the journal GUI for yourself or, with override permission, another player. | +| `/xmastree milestones [player]` | `onembxmastree.command.milestones` | Shows personal milestone progress and claim state. Viewing another player requires `onembxmastree.tree.override`. | +| `/xmastree milestones claim ` | `onembxmastree.command.milestones.claim` | Claims a ready personal milestone reward for yourself. | +| `/xmastree community` | `onembxmastree.command.community` | Shows server-wide community milestone progress. | + +## Gift Management + +| Command | Permission | Description | +| --- | --- | --- | +| `/xmastree gifts` | `onembxmastree.command.gifts` | Spawns presents under every loaded XMas tree. | +| `/xmastree gifts spawn` | `onembxmastree.command.gifts` | Explicit form of `/xmastree gifts`. | +| `/xmastree gifts list [page]` | `onembxmastree.command.gifts` | Lists configured gift rewards by index, weight, and roll chance. | +| `/xmastree gifts roll` | `onembxmastree.command.gifts` | Previews one weighted random gift roll without giving it to anyone. | +| `/xmastree gifts remove ` | `onembxmastree.command.gifts` | Removes a configured gift reward by list index. | +| `/xmastree gifts weight ` | `onembxmastree.command.gifts` | Sets a positive whole-number roll weight for a gift reward. | +| `/xmastree gifts addhand` | `onembxmastree.command.addhand` | Adds the held item to the gift pool and saves it to `config.yml`. | +| `/xmastree addhand` | `onembxmastree.command.addhand` | Compatibility shortcut for `/xmastree gifts addhand`. | + +## Staff and Admin Commands + +| Command | Permission | Description | +| --- | --- | --- | +| `/xmastree give ` | `onembxmastree.command.give` | Gives an online player a Christmas Crystal. | +| `/xmastree reload` | `onembxmastree.command.reload` | Reloads config, translations, theme, prefix, crystal text, present heads, gifts, luck settings, and tree level requirements. Prints a reload report. | +| `/xmastree inspect` | `onembxmastree.command.inspect` | Inspects the tree you are looking at, or the nearest tree if no tree block is targeted. | +| `/xmastree inspect nearest ` | `onembxmastree.command.inspect` | Inspects the nearest loaded tree to an online player. | +| `/xmastree inspect ` | `onembxmastree.command.inspect` | Inspects a specific loaded tree by UUID. | +| `/xmastree test sound first [player]` | `onembxmastree.command.test` | Previews the configured first accepted ingredient sound. | +| `/xmastree test sound repeat [player]` | `onembxmastree.command.test` | Previews the configured repeat ingredient sound. | +| `/xmastree test particle [all\|ambient\|swag\|body] [player]` | `onembxmastree.command.test` | Previews configured particles for a tree stage. | +| `/xmastree test refund [tree-uuid\|nearest [player]]` | `onembxmastree.command.test` | Dry-runs refund delivery without editing data or giving items. | +| `/xmastree data backup [trees\|progress\|all]` | `onembxmastree.command.data` | Creates timestamped data backups. Defaults to `trees`. | +| `/xmastree data validate` | `onembxmastree.command.data` | Validates `trees.yml` for invalid IDs, owners, levels, locations, worlds, requirements, and duplicate locations. | +| `/xmastree data report` | `onembxmastree.command.data` | Shows an event wrap-up report with tree, gift, milestone, and returning-player summary data. | +| `/xmastree data migrate-world [dry-run\|apply]` | `onembxmastree.command.data` | Reviews or rewrites saved tree world names. `apply` creates a backup first. | +| `/xmastree milestones test [player]` | `onembxmastree.command.test` | Previews personal milestone reward commands without claiming or running them. | +| `/xmastree community test ` | `onembxmastree.command.test` | Previews community milestone reward commands without claiming or running them. | +| `/xmastree community claim confirm` | `onembxmastree.command.community.claim` | Claims a ready community milestone reward. Requires explicit `confirm`. | +| `/xmastree debug` | `onembxmastree.command.debug` | Opens the status debug section. | +| `/xmastree debug [section\|page]` | `onembxmastree.command.debug` | Shows debug output for `status`, `commands`, `permissions`, `placeholders`, `config`, or `diagnostics`. Numeric pages `1-6` still work. | +| `/xmastree debug toggle true\|false` | `onembxmastree.command.debug.toggle` | Toggles supported global boolean config keys and reloads the plugin config. | +| `/xmastree end` | `onembxmastree.command.end` | Ends the event and sets `core.plugin-enabled` to `false`. | + +`onembxmastree.admin` includes all command permissions and tree override access. diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000..21cb3b5 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,151 @@ +# XMas Tree Configuration + +Main config file: + +```text +plugins/X-Mas/config.yml +``` + +The plugin preserves existing admin-edited values while adding missing defaults and missing bundled comments when it loads config. Most settings apply with `/xmastree reload`; the exceptions are noted below. + +## Core Settings + +| Key | Default | Reload Behavior | Notes | +| --- | --- | --- | --- | +| `core.plugin-enabled` | `true` | `/xmastree reload`; also live-toggleable with `/xmastree debug toggle core.plugin-enabled true\|false` | Master event switch. Turning the event back on after an end state is safest with a restart. | +| `core.locale` | `en` | `/xmastree reload` | Loads `plugins/X-Mas/translations/locale_.yml`. | +| `core.tree-limit` | `3` | `/xmastree reload` | Applies to future placement checks. | +| `core.holiday-ends.enabled` | `true` | `/xmastree reload`; live-toggleable | Enables time-based event shutdown. | +| `core.holiday-ends.date` | `10-01-2027 03-33-33` | `/xmastree reload` | Format is `dd-MM-yyyy HH-mm-ss`. | +| `core.holiday-ends.resource-back` | `true` | `/xmastree reload`; live-toggleable | Returns actually spent upgrade materials when a tree is destroyed after confirmation. | +| `core.update-speed` | `7` ticks | Restart recommended | Main tree update task cadence. Reload reads the value, but a restart is recommended to recreate the scheduled task cadence. | +| `core.particles-delay` | `35` ticks | Restart recommended | Particle task cadence. | +| `core.particles-enabled` | `true` | `/xmastree reload`; live-toggleable | Global particle switch. | +| `core.sounds.grow.first-volume` | `0.5` | `/xmastree reload` | First accepted item sound volume. Use `0.0` for silent and `1.0` for full volume. | +| `core.sounds.grow.repeat-volume` | `0.2` | `/xmastree reload` | Repeated accepted item sound volume for the same material requirement. | + +## Luck + +| Key | Default | Reload Behavior | Notes | +| --- | --- | --- | --- | +| `xmas.luck.enabled` | `false` | `/xmastree reload`; live-toggleable | When disabled, presents always use the main gift pool. | +| `xmas.luck.chance` | `75` | `/xmastree reload` | Percent chance for the normal gift outcome when luck is enabled. | + +## Presents and Gifts + +`xmas.presents` controls the player-head textures used for present blocks. Modern entries should be `textures.minecraft.net` URLs. Old player-name entries still load for compatibility, but diagnostics warn about them. + +`xmas.gifts` controls rewards from opened presents. Supported formats: + +```yaml +xmas: + gifts: + - DIAMOND + - EMERALD:3 + - item: GOLD_INGOT + weight: 10 +``` + +Supported gift entry forms: + +- `MATERIAL` +- `MATERIAL:AMOUNT` +- Base64 item strings saved by `/xmastree gifts addhand` +- weighted entries with `item` and positive whole-number `weight` + +Invalid modern material names, legacy material names, malformed texture URLs, and unsafe texture URL hosts are skipped and surfaced in reload/debug diagnostics. + +## Milestones + +Milestones live under: + +```yaml +xmas: + milestones: + player: + community: +``` + +Supported milestone fields: + +| Field | Notes | +| --- | --- | +| `enabled` | `true` or `false`. | +| `type` | Player milestones support `daily_gifts`, `total_gifts`, and `streak_days`. Community milestones support `community_gifts`. | +| `target` | Whole number `1` or higher. | +| `display-name` | Name shown in milestone commands and journals. | +| `reward-commands` | Console commands run when the milestone is claimed. | + +Reward command tokens: + +- `{player}` +- `{claimer}` +- `{milestone}` +- `{display_name}` +- `{target}` +- `{progress}` + +Example: + +```yaml +reward-commands: + - cmi kit christmas {player} + - cmi money give {player} 250 + - cmi toast {player} -t:task -icon:EMERALD -text:"{display_name} complete!" +``` + +Milestone definition changes apply with `/xmastree reload`. Existing progress and claim state are stored in `progress.yml` and are not reset by changing config. + +## Tree Levels + +Tree stages live under `xmas.tree-lvl`: + +- `sapling` +- `small_tree` +- `tree` +- `magic_tree` +- `super_tree` + +Each stage can define: + +- `gift-cooldown` +- `particles.ambient` +- `particles.swag` +- `particles.body` +- `lvlup` material requirements for the next stage + +Use modern Paper/Bukkit material names for `lvlup`. Particle names should come from the Paper 26.2 `Particle` enum: + +[jd.papermc.io/paper/26.2/org/bukkit/Particle.html](https://jd.papermc.io/paper/26.2/org/bukkit/Particle.html) + +`/xmastree reload` is enough for new trees and newly loaded trees. A restart is recommended when every existing loaded tree should use refreshed stage settings consistently. + +## World Aliases and Migration + +Legacy world-name remapping lives under: + +```yaml +migration: + world-aliases: + general: world + wild: world +``` + +Aliases let old `trees.yml` records load in renamed worlds without rewriting data. To rewrite saved world names, use: + +```text +/xmastree data migrate-world +/xmastree data migrate-world apply +``` + +The `apply` form creates a backup before writing and should be followed by a restart before testing migrated legacy trees. + +## Translation and Theme + +Player/admin messages, the chat prefix, and theme aliases live in: + +```text +plugins/X-Mas/translations/locale_en.yml +``` + +MiniMessage is supported. The bundled locale uses semantic tags such as ``, ``, ``, ``, ``, ``, ``, and ``. diff --git a/docs/installation.md b/docs/installation.md new file mode 100644 index 0000000..7bf1a1b --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,88 @@ +# XMas Tree Installation and Building + +## Compatibility + +| Requirement | Value | +| --- | --- | +| Server | Paper 26.2 | +| Java runtime | Java 25 or newer compatible runtime | +| Build target | Java 25 bytecode | +| Compile API | Paper API `26.2.build.29-alpha` | +| Plugin API version | `26.2` | +| Optional dependency | PlaceholderAPI `2.12.3-DEV-266` or compatible build | + +## Install + +1. Stop the Paper server. +2. Back up `plugins/X-Mas` and relevant world folders. +3. Remove old XMas Tree jars from the top-level `plugins` folder. +4. Copy the current jar into `plugins`. +5. Start the server. +6. Check console for startup warnings. +7. Run `/xmastree`, `/xmastree info`, and `/xmastree debug diagnostics`. + +Current jar name: + +```text +1MB-XMas-2026-v2.1.0-051-v25-26.2.jar +``` + +## Updating From an Older Event + +Preserve: + +- `plugins/X-Mas/trees.yml` +- `plugins/X-Mas/config.yml` +- `plugins/X-Mas/progress.yml`, if an active event already has progress +- `plugins/X-Mas/translations/locale_en.yml`, if custom text was edited + +Before testing live data: + +```text +/xmastree data backup all +/xmastree data validate +/xmastree debug diagnostics +``` + +If saved world names changed between old and new servers, configure `migration.world-aliases` or use `/xmastree data migrate-world`. + +## Building + +Requirements: + +- JDK 25 +- Gradle +- Centralized Paper 26.2 cache at `/Users/floris/Projects/Codex/servers/cache/Paper-26.2` +- PlaceholderAPI build `266` in the centralized Paper 26.2 cache + +Build and copy the release jar into `libs/`: + +```bash +gradle clean buildAllJars +``` + +Build only the jar: + +```bash +gradle jar +``` + +Print build metadata: + +```bash +gradle printBuildConfig +``` + +Optional deprecated API lint pass: + +```bash +gradle clean compileJava -PlintDeprecatedApi=true +``` + +End users do not need a local `servers/` folder. Local test server folders are ignored development data. + +## Support + +Report bugs and upgrade issues here: + +[github.com/mrfdev/XMasTree/issues](https://github.com/mrfdev/XMasTree/issues) diff --git a/docs/integrations.md b/docs/integrations.md new file mode 100644 index 0000000..e708da0 --- /dev/null +++ b/docs/integrations.md @@ -0,0 +1,57 @@ +# XMas Tree Integrations + +## PlaceholderAPI + +PlaceholderAPI is optional. When installed, XMas Tree registers the `onembxmastree` expansion for holograms, scoreboards, menus, and leaderboard displays. + +See [placeholders.md](placeholders.md) for the complete placeholder table. + +## CMI + +CMI is not required, but it is useful for milestone rewards, holograms, toasts, and economy commands. + +Milestone reward commands run from console and can include CMI commands: + +```yaml +reward-commands: + - cmi kit christmas {player} + - cmi money give {player} 250 + - cmi toast {player} -t:task -icon:EMERALD -text:"{display_name} complete!" +``` + +CMI holograms can display PlaceholderAPI values: + +```text +&aX-Mas Event: &f%onembxmastree_event.active_text% +&aEnds in: &f%onembxmastree_event.ends_in% +&aTrees planted: &f%onembxmastree_trees.total% +``` + +## LuckPerms + +Use LuckPerms or another permissions plugin to assign granular permissions: + +- player groups usually receive the default `true` permissions +- staff groups can receive command-specific admin permissions +- full admins can receive `onembxmastree.admin` + +The modern namespace is `onembxmastree.*`. + +## ajLeaderboards and Scoreboards + +The plugin exposes totals through PlaceholderAPI, which can be used by leaderboard or scoreboard plugins that support PlaceholderAPI: + +```text +%onembxmastree_trees.total% +%onembxmastree_trees.owners% +%onembxmastree_player.trees% +%onembxmastree_community.gifts_opened% +``` + +## Multiverse and World Managers + +XMas Tree can load legacy tree records across renamed worlds with `migration.world-aliases`. If a world manager changes world names between event years, use `/xmastree data validate` and `/xmastree data migrate-world` before opening the event. + +## Paper API + +This fork targets Paper 26.2 and Java 25. Particle names, material names, and plugin metadata are documented for that environment. diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..5977736 --- /dev/null +++ b/docs/permissions.md @@ -0,0 +1,30 @@ +# XMas Tree Permissions + +All current permissions use the `onembxmastree` namespace. + +| Permission | Default | Description | +| --- | --- | --- | +| `onembxmastree.admin` | `op` | Umbrella permission for all XMas Tree commands and override actions. | +| `onembxmastree.command.status` | `true` | Allows viewing `/xmastree` status output. | +| `onembxmastree.command.help` | `true` | Allows viewing `/xmastree help`. | +| `onembxmastree.command.info` | `true` | Allows viewing `/xmastree info`. | +| `onembxmastree.command.give` | `op` | Allows `/xmastree give `. | +| `onembxmastree.command.gifts` | `op` | Allows `/xmastree gifts`, including list, roll, remove, weight, and spawn actions. | +| `onembxmastree.command.addhand` | `op` | Allows `/xmastree gifts addhand` and `/xmastree addhand`. | +| `onembxmastree.command.reload` | `op` | Allows `/xmastree reload`. | +| `onembxmastree.command.inspect` | `op` | Allows `/xmastree inspect`. | +| `onembxmastree.command.test` | `op` | Allows `/xmastree test sound`, `/xmastree test particle`, `/xmastree test refund`, and reward preview tests. | +| `onembxmastree.command.data` | `op` | Allows `/xmastree data backup`, `/xmastree data validate`, `/xmastree data report`, and `/xmastree data migrate-world`. | +| `onembxmastree.command.journal` | `true` | Allows `/xmastree journal` and `/xmastree journal gui`. | +| `onembxmastree.command.milestones` | `true` | Allows `/xmastree milestones`. | +| `onembxmastree.command.milestones.claim` | `true` | Allows `/xmastree milestones claim `. | +| `onembxmastree.command.community` | `true` | Allows `/xmastree community`. | +| `onembxmastree.command.community.claim` | `op` | Allows `/xmastree community claim confirm`. | +| `onembxmastree.command.debug` | `op` | Allows `/xmastree debug [section\|page]`. | +| `onembxmastree.command.debug.toggle` | `op` | Allows `/xmastree debug toggle true\|false`. | +| `onembxmastree.command.end` | `op` | Allows `/xmastree end`. | +| `onembxmastree.tree.override` | `op` | Allows viewing and managing other players' trees where supported. | + +## Migration Note + +The old legacy `xmas.admin` permission is not the active admin permission for this fork. Use `onembxmastree.admin` and the granular `onembxmastree.command.*` permissions for the 2026 event. diff --git a/docs/placeholders.md b/docs/placeholders.md new file mode 100644 index 0000000..742faf7 --- /dev/null +++ b/docs/placeholders.md @@ -0,0 +1,63 @@ +# XMas Tree Placeholders + +PlaceholderAPI is optional. When PlaceholderAPI is installed, XMas Tree registers the `onembxmastree` expansion. + +Use an underscore after the expansion identifier: + +```text +%onembxmastree_event.active% +``` + +The key after `onembxmastree_` can use dotted or underscore form. For example, both of these resolve: + +```text +%onembxmastree_event.active% +%onembxmastree_event_active% +``` + +| Placeholder | Example Output | Meaning | +| --- | --- | --- | +| `%onembxmastree_event.active%` | `true` | Whether the event is currently active. | +| `%onembxmastree_event.active_text%` | `Active` | Human-readable active/inactive state. | +| `%onembxmastree_event.status%` | `In Progress` | Current event status text. | +| `%onembxmastree_event.starts_at%` | `manual` | Start mode. The plugin currently starts from `core.plugin-enabled`. | +| `%onembxmastree_event.ends_at%` | `10-01-2027 03-33-33` | Configured event end date. | +| `%onembxmastree_event.ends_in%` | `263d 7h` | Approximate time until the configured end date, or `disabled` when auto-end is off. | +| `%onembxmastree_event.ends_timestamp%` | `1799552013000` | Event end timestamp in milliseconds. | +| `%onembxmastree_event.auto_end%` | `true` | Whether automatic event ending is enabled. | +| `%onembxmastree_resource.back%` | `true` | Whether resource refunds are enabled. | +| `%onembxmastree_resource.back_text%` | `Yes` | Human-readable refund state. | +| `%onembxmastree_particles.enabled%` | `true` | Whether XMas Tree particles are globally enabled. | +| `%onembxmastree_luck.enabled%` | `false` | Whether gift luck chance is enabled. | +| `%onembxmastree_luck.chance%` | `75` | Gift luck chance as a percent. | +| `%onembxmastree_trees.total%` | `14` | Total loaded XMas trees. | +| `%onembxmastree_trees.owners%` | `6` | Number of unique loaded tree owners. | +| `%onembxmastree_player.trees%` | `2` | Number of loaded trees owned by the placeholder player. | +| `%onembxmastree_player.highest_tree_level%` | `5` | Highest loaded tree level owned by the placeholder player, as `0-5`. | +| `%onembxmastree_player.highest_tree_level_name%` | `super_tree` | Highest loaded tree level owned by the placeholder player, as a config key. | +| `%onembxmastree_player.highest_tree_level_display%` | `Super Tree` | Highest loaded tree level owned by the placeholder player, as display text. | +| `%onembxmastree_player.gifts_today%` | `6` | Present gifts opened by the placeholder player today. | +| `%onembxmastree_player.gifts_total%` | `25` | Present gifts opened by the placeholder player during the event. | +| `%onembxmastree_player.streak_current%` | `7` | Current daily present-opening streak for the placeholder player. | +| `%onembxmastree_player.streak_best%` | `12` | Best daily present-opening streak for the placeholder player. | +| `%onembxmastree_player.milestones_claimable%` | `1` | Claimable personal milestone count for the placeholder player. | +| `%onembxmastree_community.gifts_opened%` | `100` | Community-wide present gifts opened. | +| `%onembxmastree_version%` | `2.1.0-051` | Loaded plugin version. | + +## Example CMI Hologram + +```text +&aX-Mas Event: &f%onembxmastree_event.active_text% +&aEnds in: &f%onembxmastree_event.ends_in% +&aResource back: &f%onembxmastree_resource.back_text% +&aTrees planted: &f%onembxmastree_trees.total% +``` + +## Example Leaderboard Values + +```text +%onembxmastree_trees.total% +%onembxmastree_trees.owners% +%onembxmastree_player.trees% +%onembxmastree_community.gifts_opened% +``` diff --git a/docs/player-guide.md b/docs/player-guide.md new file mode 100644 index 0000000..9a79144 --- /dev/null +++ b/docs/player-guide.md @@ -0,0 +1,92 @@ +# XMas Tree Player Guide + +XMas Tree is the 1MoreBlock Christmas tree event plugin. You plant a magical spruce sapling, feed it ingredients, grow it through several stages, and collect presents that appear below the tree during the event. + +Returning players can keep preserved trees from previous years where the server data still has them. Winter 2026 also adds a `super_tree` level so old trees can become useful again instead of being left behind. + +## Quick Start + +1. Get a Christmas Crystal during the event. +2. Place a spruce sapling where you want your tree. +3. Right-click the sapling while holding the Christmas Crystal. +4. Right-click your tree with the ingredients shown in chat. +5. When all ingredients are complete, right-click the tree again to grow it. +6. Check below grown trees for presents and right-click presents to claim rewards. +7. Use `/xmastree journal` and `/xmastree milestones` to track your event progress. + +## Commands + +| Command | What It Does | +| --- | --- | +| `/xmastree` | Shows the current plugin/event status. | +| `/xmastree help` | Shows the available command list. | +| `/xmastree info` | Shows a short in-game event guide and the canonical docs link. | +| `/xmastree journal` | Shows your trees, gift totals, streaks, milestone count, and recent tree/milestone history. | +| `/xmastree journal gui` | Opens the journal GUI. | +| `/xmastree milestones` | Shows your personal milestone progress and claim state. | +| `/xmastree milestones claim ` | Claims a ready personal milestone reward. | +| `/xmastree community` | Shows community-wide gift milestone progress. | + +Most admin commands are hidden behind staff permissions and are documented in the technical command reference. + +## Permissions and Rank Requirements + +The player-facing commands are available by default unless the server changes permissions: + +| Permission | Default | Allows | +| --- | --- | --- | +| `onembxmastree.command.status` | Everyone | `/xmastree` | +| `onembxmastree.command.help` | Everyone | `/xmastree help` | +| `onembxmastree.command.info` | Everyone | `/xmastree info` | +| `onembxmastree.command.journal` | Everyone | `/xmastree journal` and `/xmastree journal gui` | +| `onembxmastree.command.milestones` | Everyone | `/xmastree milestones` | +| `onembxmastree.command.milestones.claim` | Everyone | `/xmastree milestones claim ` | +| `onembxmastree.command.community` | Everyone | `/xmastree community` | + +If a command does not work for your rank, ask staff whether the event is active and whether that command is enabled for your group. + +## Rewards, Costs, Limits, and Cooldowns + +Trees cost the ingredients shown in chat. Hold the listed ingredient and right-click the tree to feed it in. If more than one of an ingredient is needed, repeat until the counter is complete. + +Each account can own a limited number of XMas trees. The default limit is `3`, but server staff can change it. + +Presents can appear below grown trees while the event is active. Present rewards come from the server's configured gift pool, so exact rewards may change between years. + +Milestones are one-time claims. You can build progress by opening presents: + +- daily gift milestones reward activity on one server-local day +- streak milestones reward opening gifts on consecutive days +- total gift milestones reward event-wide gift openings +- community milestones reward the whole server when enough presents are opened by everyone + +After the event, staff may allow trees to be broken down. If resource refunds are enabled, the ingredients you actually spent on that tree are returned by chest, barrel, inventory, or floor drops if needed. + +## Important Notes + +- Use a spruce sapling for new trees. +- Tree growth needs enough empty space around the tree. +- Do not break random leaves to remove a tree; cut the log when the event allows cleanup. +- If you had a tree from a previous year, staff may preserve it and may give you another crystal for the new event. +- `/xmastree info` is the best in-game reminder if you forget the flow. + +## Placeholders + +Players normally do not type placeholders directly, but the server may use them in holograms, scoreboards, or menus. Examples include: + +- `%onembxmastree_event.active_text%` +- `%onembxmastree_event.ends_in%` +- `%onembxmastree_player.trees%` +- `%onembxmastree_player.gifts_today%` +- `%onembxmastree_player.streak_current%` +- `%onembxmastree_community.gifts_opened%` + +## Related Features + +XMas Tree can be shown in CMI holograms, scoreboards, PlaceholderAPI-aware menus, and community event displays. Staff may also connect milestone rewards to kits, money, toasts, broadcasts, or extra present spawns. + +## Technical Documentation + +Technical reference: [README](../README.md) + +Canonical public docs: [docs.1moreblock.com/custom-server-plugins/xmas-tree/](https://docs.1moreblock.com/custom-server-plugins/xmas-tree/) diff --git a/docs/plugin-docs.yml b/docs/plugin-docs.yml new file mode 100644 index 0000000..012604e --- /dev/null +++ b/docs/plugin-docs.yml @@ -0,0 +1,12 @@ +id: xmas-tree +name: XMas Tree +category: custom-server-plugin +summary: Plant, grow, and celebrate upgradeable Christmas trees with presents, streaks, milestones, and preserved legacy event trees. +main_command: /xmastree +docs_url: https://docs.1moreblock.com/custom-server-plugins/xmas-tree/ +player_guide: player-guide.md +technical_readme: ../README.md +java_target: 25 +paper_target: 26.2 +official_project: true +repository: mrfdev/XMasTree diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..4d37bb4 --- /dev/null +++ b/docs/troubleshooting.md @@ -0,0 +1,150 @@ +# XMas Tree Troubleshooting + +## First Checks + +Run these commands before editing data: + +```text +/xmastree +/xmastree info +/xmastree reload +/xmastree debug diagnostics +/xmastree data validate +``` + +For larger maintenance, back up first: + +```text +/xmastree data backup all +``` + +## The Tree Will Not Grow + +Check: + +- the event is active +- the player owns the tree +- the tree has enough empty space for the next stage +- the player is holding the required material +- the material name in config is a modern Paper/Bukkit material name + +Use: + +```text +/xmastree inspect +``` + +This reports next level, remaining requirements, spent resources, chunk/world state, and whether the next level has room. + +## A Legacy Tree Does Not Load + +Run: + +```text +/xmastree data validate +``` + +Common causes: + +- saved world name no longer exists +- coordinates now point to unloaded or changed terrain +- old material names are invalid on modern Paper +- duplicate saved tree locations + +If the world was renamed, configure `migration.world-aliases` or use `/xmastree data migrate-world apply` after creating a backup. + +## Presents Do Not Spawn + +Check: + +- `core.plugin-enabled` +- `xmas.tree-lvl..gift-cooldown` +- tree stage +- present head diagnostics from `/xmastree debug diagnostics` +- whether the server was recently reloaded and existing loaded trees need a restart to pick up stage changes + +## Present Rewards Look Wrong + +Use: + +```text +/xmastree gifts list +/xmastree gifts roll +/xmastree debug diagnostics +``` + +Invalid material names, unsafe present texture URLs, and malformed Base64 gift entries are skipped and reported in diagnostics. + +## Refunds Do Not Return Items + +Check: + +- `core.holiday-ends.resource-back` +- `/xmastree inspect` refund preview +- whether the tree has actually consumed upgrade ingredients + +Refund delivery order is: + +1. chest +2. barrel +3. player inventory +4. floor drops + +Preview without changing anything: + +```text +/xmastree test refund +``` + +## Milestone Rewards Did Not Run + +Use: + +```text +/xmastree milestones test [player] +/xmastree community test +``` + +Reward commands run from console when claimed. They should not include a leading slash. Check server logs for whether each command was accepted. + +Community milestone claims require: + +```text +/xmastree community claim confirm +``` + +## Placeholder Values Are Empty + +Check: + +- PlaceholderAPI is installed and enabled +- the placeholder starts with `%onembxmastree_` +- player-specific placeholders are being requested in a player context +- `/xmastree debug placeholders` lists the placeholder + +## Config Changes Did Not Apply + +Most file edits apply with: + +```text +/xmastree reload +``` + +Restart is recommended for: + +- `core.update-speed` +- `core.particles-delay` +- fully consistent tree-stage changes on already loaded trees + +## Getting Help + +Open an issue with: + +- plugin jar version +- Paper version +- Java version +- relevant console errors +- `/xmastree debug diagnostics` output +- `/xmastree data validate` output, if tree data is involved + +[github.com/mrfdev/XMasTree/issues](https://github.com/mrfdev/XMasTree/issues) diff --git a/src/main/java/ru/meloncode/xmas/Effects.java b/src/main/java/ru/meloncode/xmas/Effects.java index b7a7b31..cf51085 100644 --- a/src/main/java/ru/meloncode/xmas/Effects.java +++ b/src/main/java/ru/meloncode/xmas/Effects.java @@ -16,5 +16,7 @@ class Effects { public static final ParticleContainer GROW = new ParticleContainer(Particle.HAPPY_VILLAGER, 0.25f, 0.25f, 0.25f, 1f, 16); public static final ParticleContainer AMBIENT_SNOW = new ParticleContainer(Particle.ITEM_SNOWBALL, 1.5f, 3f, 1.5f, 0, 16); + public static final ParticleContainer CRYSTAL_SAPLING_GREEN = new ParticleContainer(Particle.SPORE_BLOSSOM_AIR, 0.45f, 0.55f, 0.45f, 0.02f, 12); + public static final ParticleContainer CRYSTAL_SAPLING_WHITE = new ParticleContainer(Particle.FIREWORK, 0.35f, 0.45f, 0.35f, 0.01f, 8); } diff --git a/src/main/java/ru/meloncode/xmas/EventProgress.java b/src/main/java/ru/meloncode/xmas/EventProgress.java new file mode 100644 index 0000000..5bee912 --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/EventProgress.java @@ -0,0 +1,524 @@ +package ru.meloncode.xmas; + +import org.bukkit.Bukkit; +import org.bukkit.OfflinePlayer; +import org.bukkit.command.CommandSender; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.entity.Player; +import org.bukkit.scheduler.BukkitTask; +import ru.meloncode.xmas.utils.ConfigUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; + +public class EventProgress { + private static final String PROGRESS_FILE = "progress.yml"; + private static final DateTimeFormatter BACKUP_TIMESTAMP = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss"); + private static final DateTimeFormatter HISTORY_TIMESTAMP = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final int HISTORY_LIMIT = 25; + + private final Main plugin; + private final File file; + private YamlConfiguration data; + private final Map playerMilestones = new LinkedHashMap<>(); + private final Map communityMilestones = new LinkedHashMap<>(); + private BukkitTask saveTask; + private boolean dirty; + + public EventProgress(Main plugin) { + this.plugin = plugin; + this.file = new File(plugin.getDataFolder(), PROGRESS_FILE); + reload(); + } + + public void reload() { + flushSave(); + data = ConfigUtils.loadConfig(file); + reloadMilestones(); + } + + public void save() { + flushSave(); + } + + public void flushSave() { + if (saveTask != null) { + saveTask.cancel(); + saveTask = null; + } + if (data == null) { + return; + } + if (!dirty && file.exists()) { + return; + } + ConfigUtils.saveConfig(file, data); + dirty = false; + } + + public void recordGiftClaim(Player player) { + if (player == null) { + return; + } + String playerPath = playerPath(player.getUniqueId()); + String today = today(); + data.set(playerPath + ".name", player.getName()); + data.set(playerPath + ".total-gifts", data.getInt(playerPath + ".total-gifts", 0) + 1); + data.set(playerPath + ".daily." + today, data.getInt(playerPath + ".daily." + today, 0) + 1); + data.set(playerPath + ".current-streak", calculateCurrentStreak(player.getUniqueId())); + data.set(playerPath + ".best-streak", Math.max(getBestStreak(player.getUniqueId()), getCurrentStreak(player.getUniqueId()))); + data.set("community.gifts-opened", getCommunityGiftClaims() + 1); + markDirty(); + } + + public void recordTreeCreated(Player player, MagicTree tree) { + if (player == null || tree == null) { + return; + } + data.set(playerPath(player.getUniqueId()) + ".name", player.getName()); + addPlayerHistory(player.getUniqueId(), "tree-history", LocaleManager.text("progress.history.tree-created", "{time} - Created {level} at {location} ({tree})", + "{time}", historyTimestamp(), + "{level}", tree.getLevel().getLevelName(), + "{location}", formatLocation(tree), + "{tree}", tree.getTreeUID().toString())); + } + + public void recordTreeRemoved(MagicTree tree) { + if (tree == null) { + return; + } + addPlayerHistory(tree.getOwner(), "tree-history", LocaleManager.text("progress.history.tree-removed", "{time} - Removed {level} at {location} ({tree})", + "{time}", historyTimestamp(), + "{level}", tree.getLevel().getLevelName(), + "{location}", formatLocation(tree), + "{tree}", tree.getTreeUID().toString())); + } + + public PlayerStats playerStats(OfflinePlayer player) { + UUID uuid = player.getUniqueId(); + return new PlayerStats( + uuid, + player.getName() != null ? player.getName() : data.getString(playerPath(uuid) + ".name", uuid.toString()), + getTotalGiftClaims(uuid), + getGiftClaimsToday(uuid), + getCurrentStreak(uuid), + getBestStreak(uuid), + countPlayerClaimableMilestones(uuid) + ); + } + + public int getTotalGiftClaims(UUID uuid) { + return data.getInt(playerPath(uuid) + ".total-gifts", 0); + } + + public int getGiftClaimsToday(UUID uuid) { + return data.getInt(playerPath(uuid) + ".daily." + today(), 0); + } + + public int getCurrentStreak(UUID uuid) { + return data.getInt(playerPath(uuid) + ".current-streak", calculateCurrentStreak(uuid)); + } + + public int getBestStreak(UUID uuid) { + return data.getInt(playerPath(uuid) + ".best-streak", 0); + } + + public int getCommunityGiftClaims() { + return data.getInt("community.gifts-opened", 0); + } + + public List playerMilestones() { + return List.copyOf(playerMilestones.values()); + } + + public List communityMilestones() { + return List.copyOf(communityMilestones.values()); + } + + public ClaimResult claimPlayerMilestone(Player player, String key) { + Milestone milestone = playerMilestones.get(normalizeKey(key)); + if (milestone == null) { + return ClaimResult.error(LocaleManager.text("progress.milestone.unknown", "Unknown milestone.")); + } + UUID uuid = player.getUniqueId(); + if (isPlayerMilestoneClaimed(uuid, milestone.key())) { + return ClaimResult.error(LocaleManager.text("progress.milestone.already-claimed", "That milestone is already claimed.")); + } + if (!isPlayerMilestoneEligible(uuid, milestone)) { + return ClaimResult.error(LocaleManager.text("progress.milestone.not-ready", "That milestone is not ready yet.")); + } + int progress = progressValue(uuid, milestone); + data.set(playerPath(uuid) + ".claimed." + milestone.key(), System.currentTimeMillis()); + runRewardCommands(milestone, player.getName(), player.getName(), progress); + addPlayerHistory(uuid, "milestone-history", LocaleManager.text("progress.history.player-milestone-claimed", "{time} - Claimed {display_name} ({key}) at {progress}/{target}", + "{time}", historyTimestamp(), + "{display_name}", milestone.displayName(), + "{key}", milestone.key(), + "{progress}", Integer.toString(progress), + "{target}", Integer.toString(milestone.target()))); + markDirty(); + return ClaimResult.success(LocaleManager.text("progress.milestone.claimed", "Milestone claimed!")); + } + + public ClaimResult claimCommunityMilestone(CommandSender sender, String key) { + Milestone milestone = communityMilestones.get(normalizeKey(key)); + if (milestone == null) { + return ClaimResult.error(LocaleManager.text("progress.milestone.unknown", "Unknown milestone.")); + } + if (isCommunityMilestoneClaimed(milestone.key())) { + return ClaimResult.error(LocaleManager.text("progress.milestone.already-claimed", "That milestone is already claimed.")); + } + if (!isCommunityMilestoneEligible(milestone)) { + return ClaimResult.error(LocaleManager.text("progress.milestone.not-ready", "That milestone is not ready yet.")); + } + int progress = getCommunityGiftClaims(); + data.set("community.claimed." + milestone.key(), System.currentTimeMillis()); + runRewardCommands(milestone, sender.getName(), sender.getName(), progress); + addHistory("community.milestone-history", LocaleManager.text("progress.history.community-milestone-claimed", "{time} - {claimer} claimed {display_name} ({key}) at {progress}/{target}", + "{time}", historyTimestamp(), + "{claimer}", sender.getName(), + "{display_name}", milestone.displayName(), + "{key}", milestone.key(), + "{progress}", Integer.toString(progress), + "{target}", Integer.toString(milestone.target()))); + markDirty(); + return ClaimResult.success(LocaleManager.text("progress.milestone.claimed", "Milestone claimed!")); + } + + public List playerTreeHistory(OfflinePlayer player, int limit) { + return recentHistory(playerPath(player.getUniqueId()) + ".tree-history", limit); + } + + public List playerMilestoneHistory(OfflinePlayer player, int limit) { + return recentHistory(playerPath(player.getUniqueId()) + ".milestone-history", limit); + } + + public List communityMilestoneHistory(int limit) { + return recentHistory("community.milestone-history", limit); + } + + public EventSummary eventSummary() { + int playersWithGifts = 0; + int claimedPlayerMilestones = 0; + ConfigurationSection players = data.getConfigurationSection("players"); + if (players != null) { + for (String playerKey : players.getKeys(false)) { + String path = "players." + playerKey; + if (data.getInt(path + ".total-gifts", 0) > 0) { + playersWithGifts++; + } + ConfigurationSection claimed = data.getConfigurationSection(path + ".claimed"); + if (claimed != null) { + claimedPlayerMilestones += claimed.getKeys(false).size(); + } + } + } + ConfigurationSection claimedCommunity = data.getConfigurationSection("community.claimed"); + return new EventSummary( + getCommunityGiftClaims(), + playersWithGifts, + claimedPlayerMilestones, + claimedCommunity != null ? claimedCommunity.getKeys(false).size() : 0 + ); + } + + public RewardPreview previewPlayerMilestone(OfflinePlayer player, String key, boolean requireReady) { + Milestone milestone = playerMilestones.get(normalizeKey(key)); + if (milestone == null) { + return RewardPreview.error(LocaleManager.text("progress.milestone.unknown", "Unknown milestone.")); + } + UUID uuid = player.getUniqueId(); + if (isPlayerMilestoneClaimed(uuid, milestone.key())) { + return RewardPreview.error(LocaleManager.text("progress.milestone.already-claimed", "That milestone is already claimed.")); + } + if (requireReady && !isPlayerMilestoneEligible(uuid, milestone)) { + return RewardPreview.error(LocaleManager.text("progress.milestone.not-ready", "That milestone is not ready yet.")); + } + int progress = progressValue(uuid, milestone); + String playerName = player.getName() != null ? player.getName() : data.getString(playerPath(uuid) + ".name", uuid.toString()); + return RewardPreview.success(milestone, progress, renderRewardCommands(milestone, playerName, playerName, progress)); + } + + public RewardPreview previewCommunityMilestone(CommandSender sender, String key, boolean requireReady) { + Milestone milestone = communityMilestones.get(normalizeKey(key)); + if (milestone == null) { + return RewardPreview.error(LocaleManager.text("progress.milestone.unknown", "Unknown milestone.")); + } + if (isCommunityMilestoneClaimed(milestone.key())) { + return RewardPreview.error(LocaleManager.text("progress.milestone.already-claimed", "That milestone is already claimed.")); + } + if (requireReady && !isCommunityMilestoneEligible(milestone)) { + return RewardPreview.error(LocaleManager.text("progress.milestone.not-ready", "That milestone is not ready yet.")); + } + int progress = getCommunityGiftClaims(); + return RewardPreview.success(milestone, progress, renderRewardCommands(milestone, sender.getName(), sender.getName(), progress)); + } + + public boolean isPlayerMilestoneEligible(UUID uuid, Milestone milestone) { + int value = switch (milestone.type()) { + case DAILY_GIFTS -> getGiftClaimsToday(uuid); + case TOTAL_GIFTS -> getTotalGiftClaims(uuid); + case STREAK_DAYS -> getCurrentStreak(uuid); + case COMMUNITY_GIFTS -> getCommunityGiftClaims(); + }; + return value >= milestone.target(); + } + + public boolean isCommunityMilestoneEligible(Milestone milestone) { + return switch (milestone.type()) { + case COMMUNITY_GIFTS -> getCommunityGiftClaims() >= milestone.target(); + default -> false; + }; + } + + public boolean isPlayerMilestoneClaimed(UUID uuid, String key) { + return data.contains(playerPath(uuid) + ".claimed." + normalizeKey(key), true); + } + + public boolean isCommunityMilestoneClaimed(String key) { + return data.contains("community.claimed." + normalizeKey(key), true); + } + + public int progressValue(UUID uuid, Milestone milestone) { + return switch (milestone.type()) { + case DAILY_GIFTS -> getGiftClaimsToday(uuid); + case TOTAL_GIFTS -> getTotalGiftClaims(uuid); + case STREAK_DAYS -> getCurrentStreak(uuid); + case COMMUNITY_GIFTS -> getCommunityGiftClaims(); + }; + } + + private int countPlayerClaimableMilestones(UUID uuid) { + int count = 0; + for (Milestone milestone : playerMilestones.values()) { + if (!isPlayerMilestoneClaimed(uuid, milestone.key()) && isPlayerMilestoneEligible(uuid, milestone)) { + count++; + } + } + return count; + } + + private int calculateCurrentStreak(UUID uuid) { + int streak = 0; + LocalDate day = LocalDate.now(ZoneId.systemDefault()); + String dailyPath = playerPath(uuid) + ".daily."; + while (data.getInt(dailyPath + day, 0) > 0) { + streak++; + day = day.minusDays(1); + } + return streak; + } + + private void reloadMilestones() { + playerMilestones.clear(); + communityMilestones.clear(); + loadMilestones("xmas.milestones.player", playerMilestones); + loadMilestones("xmas.milestones.community", communityMilestones); + } + + private void loadMilestones(String path, Map target) { + ConfigurationSection section = plugin.getConfig().getConfigurationSection(path); + if (section == null) { + return; + } + for (String key : section.getKeys(false)) { + ConfigurationSection milestoneSection = section.getConfigurationSection(key); + if (milestoneSection == null || !milestoneSection.getBoolean("enabled", true)) { + continue; + } + MilestoneType type = MilestoneType.fromConfig(milestoneSection.getString("type", "")); + int targetValue = milestoneSection.getInt("target", 1); + if (type == null || targetValue < 1) { + plugin.getLogger().warning(LocaleManager.text("progress.milestone.invalid", "Skipping invalid milestone {key}", + "{key}", path + "." + key)); + continue; + } + String normalizedKey = normalizeKey(key); + target.put(normalizedKey, new Milestone( + normalizedKey, + milestoneSection.getString("display-name", key), + type, + targetValue, + milestoneSection.getStringList("reward-commands") + )); + } + } + + public File backupProgressFile() throws IOException { + flushSave(); + File backupDirectory = new File(plugin.getDataFolder(), "backups"); + if (!backupDirectory.exists() && !backupDirectory.mkdirs()) { + throw new IOException("Unable to create backup directory " + backupDirectory.getPath()); + } + if (!file.exists()) { + ConfigUtils.saveConfig(file, data); + } + File backupFile = new File(backupDirectory, "progress-" + LocalDateTime.now().format(BACKUP_TIMESTAMP) + ".yml"); + Files.copy(file.toPath(), backupFile.toPath(), StandardCopyOption.COPY_ATTRIBUTES); + return backupFile; + } + + private void markDirty() { + dirty = true; + if (saveTask != null) { + return; + } + saveTask = Bukkit.getScheduler().runTaskLater(plugin, () -> { + saveTask = null; + flushSave(); + }, 100L); + } + + private void addPlayerHistory(UUID uuid, String historyKey, String line) { + addHistory(playerPath(uuid) + "." + historyKey, line); + } + + private void addHistory(String path, String line) { + List history = data.getStringList(path); + history.add(line); + while (history.size() > HISTORY_LIMIT) { + history.removeFirst(); + } + data.set(path, history); + markDirty(); + } + + private List recentHistory(String path, int limit) { + List history = new ArrayList<>(data.getStringList(path)); + Collections.reverse(history); + if (limit > 0 && history.size() > limit) { + return List.copyOf(history.subList(0, limit)); + } + return List.copyOf(history); + } + + private String historyTimestamp() { + return LocalDateTime.now(ZoneId.systemDefault()).format(HISTORY_TIMESTAMP); + } + + private String formatLocation(MagicTree tree) { + if (tree.getLocation() == null || tree.getLocation().getWorld() == null) { + return "unknown"; + } + return tree.getLocation().getWorld().getName() + + " " + + tree.getLocation().getBlockX() + + "," + + tree.getLocation().getBlockY() + + "," + + tree.getLocation().getBlockZ(); + } + + private List renderRewardCommands(Milestone milestone, String playerName, String claimerName, int progress) { + List commands = new ArrayList<>(); + for (String command : milestone.rewardCommands()) { + if (command == null || command.isBlank()) { + continue; + } + String normalized = command.trim(); + if (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + normalized = normalized + .replace("{player}", playerName) + .replace("{claimer}", claimerName) + .replace("{milestone}", milestone.key()) + .replace("{display_name}", milestone.displayName()) + .replace("{target}", Integer.toString(milestone.target())) + .replace("{progress}", Integer.toString(progress)); + commands.add(normalized); + } + return commands; + } + + private void runRewardCommands(Milestone milestone, String playerName, String claimerName, int progress) { + for (String command : renderRewardCommands(milestone, playerName, claimerName, progress)) { + boolean accepted = Bukkit.dispatchCommand(Bukkit.getConsoleSender(), command); + if (accepted) { + plugin.getLogger().info("Milestone reward command accepted for " + milestone.key() + ": " + command); + } else { + plugin.getLogger().warning("Milestone reward command was not accepted for " + milestone.key() + ": " + command); + } + } + } + + private String today() { + return LocalDate.now(ZoneId.systemDefault()).toString(); + } + + private String playerPath(UUID uuid) { + return "players." + uuid; + } + + private static String normalizeKey(String key) { + return key == null ? "" : key.toLowerCase(Locale.ENGLISH).replace(' ', '_'); + } + + public record Milestone(String key, String displayName, MilestoneType type, int target, List rewardCommands) { + public Milestone { + rewardCommands = rewardCommands == null ? Collections.emptyList() : List.copyOf(new ArrayList<>(rewardCommands)); + } + } + + public record RewardPreview(boolean success, String message, Milestone milestone, int progress, List commands) { + public RewardPreview { + commands = commands == null ? Collections.emptyList() : List.copyOf(new ArrayList<>(commands)); + } + + public static RewardPreview success(Milestone milestone, int progress, List commands) { + return new RewardPreview(true, "", milestone, progress, commands); + } + + public static RewardPreview error(String message) { + return new RewardPreview(false, message, null, 0, Collections.emptyList()); + } + } + + public record PlayerStats(UUID uuid, String name, int totalGifts, int giftsToday, int currentStreak, int bestStreak, int claimableMilestones) { + } + + public record EventSummary(int communityGiftClaims, int playersWithGifts, int claimedPlayerMilestones, int claimedCommunityMilestones) { + } + + public record ClaimResult(boolean success, String message) { + public static ClaimResult success(String message) { + return new ClaimResult(true, message); + } + + public static ClaimResult error(String message) { + return new ClaimResult(false, message); + } + } + + public enum MilestoneType { + DAILY_GIFTS, + TOTAL_GIFTS, + STREAK_DAYS, + COMMUNITY_GIFTS; + + static MilestoneType fromConfig(String value) { + if (value == null) { + return null; + } + try { + return MilestoneType.valueOf(value.trim().toUpperCase(Locale.ENGLISH).replace('-', '_')); + } catch (IllegalArgumentException ignored) { + return null; + } + } + } +} diff --git a/src/main/java/ru/meloncode/xmas/Events.java b/src/main/java/ru/meloncode/xmas/Events.java index a44ee63..c53014e 100644 --- a/src/main/java/ru/meloncode/xmas/Events.java +++ b/src/main/java/ru/meloncode/xmas/Events.java @@ -12,6 +12,8 @@ import org.bukkit.event.block.*; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityExplodeEvent; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.StructureGrowEvent; @@ -33,6 +35,20 @@ public void registerListener() { Bukkit.getPluginManager().registerEvents(this, Main.getInstance()); } + @EventHandler(ignoreCancelled = true) + public void onJournalInventoryClick(InventoryClickEvent event) { + if (event.getInventory().getHolder() instanceof JournalInventoryHolder) { + event.setCancelled(true); + } + } + + @EventHandler(ignoreCancelled = true) + public void onJournalInventoryDrag(InventoryDragEvent event) { + if (event.getInventory().getHolder() instanceof JournalInventoryHolder) { + event.setCancelled(true); + } + } + @EventHandler public void onPlayerOpenPresent(PlayerInteractEvent event) { @@ -214,14 +230,14 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { XMas.removeTree(tree); } if (Main.inProgress) - TextUtils.sendMessage(player, TextUtils.success(LocaleManager.DESTROY_COMPLETE)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_COMPLETE); } else { destroyers.put(player.getUniqueId(), System.currentTimeMillis()); if (Main.inProgress) - TextUtils.sendMessage(player, TextUtils.warning(LocaleManager.DESTROY_WARNING)); - TextUtils.sendMessage(player, TextUtils.muted(LocaleManager.DESTROY_TUT)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_WARNING); + TextUtils.sendMessage(player, LocaleManager.DESTROY_TUT); if (Main.resourceBack) { - TextUtils.sendMessage(player, TextUtils.info(LocaleManager.DESTROY_RESOURCE_BACK)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_RESOURCE_BACK); } } else { @@ -234,9 +250,9 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { case SPRUCE_LEAVES: case GLOWSTONE: if (Main.inProgress) - TextUtils.sendMessage(player, TextUtils.accent(LocaleManager.DESTROY_LEAVES_SANTA)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_LEAVES_SANTA); if (player.getUniqueId().equals(tree.getOwner()) || XMasCommand.canOverrideTree(player)) { - TextUtils.sendMessage(player, TextUtils.warning(LocaleManager.DESTROY_LEAVES_TUT)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_LEAVES_TUT); } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); } @@ -250,14 +266,14 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { } else { XMas.removeTree(tree); } - TextUtils.sendMessage(player, TextUtils.success(LocaleManager.DESTROY_COMPLETE)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_COMPLETE); } else { destroyers.put(player.getUniqueId(), System.currentTimeMillis()); if (Main.inProgress) - TextUtils.sendMessage(player, TextUtils.warning(LocaleManager.DESTROY_SAPLING)); - TextUtils.sendMessage(player, TextUtils.muted(LocaleManager.DESTROY_TUT)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_SAPLING); + TextUtils.sendMessage(player, LocaleManager.DESTROY_TUT); if (Main.resourceBack) { - TextUtils.sendMessage(player, TextUtils.info(LocaleManager.DESTROY_RESOURCE_BACK)); + TextUtils.sendMessage(player, LocaleManager.DESTROY_RESOURCE_BACK); } } } else { diff --git a/src/main/java/ru/meloncode/xmas/JournalInventoryHolder.java b/src/main/java/ru/meloncode/xmas/JournalInventoryHolder.java new file mode 100644 index 0000000..10f7481 --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/JournalInventoryHolder.java @@ -0,0 +1,23 @@ +package ru.meloncode.xmas; + +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; + +import java.util.UUID; + +class JournalInventoryHolder implements InventoryHolder { + private final UUID owner; + + JournalInventoryHolder(UUID owner) { + this.owner = owner; + } + + public UUID owner() { + return owner; + } + + @Override + public Inventory getInventory() { + return null; + } +} diff --git a/src/main/java/ru/meloncode/xmas/MagicTree.java b/src/main/java/ru/meloncode/xmas/MagicTree.java index cb4dc44..5089785 100644 --- a/src/main/java/ru/meloncode/xmas/MagicTree.java +++ b/src/main/java/ru/meloncode/xmas/MagicTree.java @@ -59,7 +59,8 @@ public static MagicTree getTreeByBlock(Block block) { if (block == null) { return null; } - return XMas.getTree(blockAssociation.get(block)); + UUID treeId = blockAssociation.get(block); + return treeId != null ? XMas.getTree(treeId) : null; } public static boolean isBlockBelongs(Block block) { @@ -84,6 +85,18 @@ public TreeLevel getLevel() { return level; } + public void refreshLevelDefinition() { + if (level == null || level.getLevelName() == null) { + return; + } + String levelName = level.getLevelName(); + this.level = TreeLevel.fromString(levelName); + if (shouldBackfillLegacyRequirements()) { + this.levelupRequirements = new HashMap<>(level.getLevelupRequirements()); + save(); + } + } + public Location getLocation() { return location; } @@ -92,7 +105,29 @@ public Map getLevelupRequirements() { return levelupRequirements; } + private boolean shouldBackfillLegacyRequirements() { + if (level == null + || !level.hasNext() + || !"magic_tree".equalsIgnoreCase(level.getLevelName()) + || level.getLevelupRequirements() == null + || level.getLevelupRequirements().isEmpty()) { + return false; + } + if (levelupRequirements == null || levelupRequirements.isEmpty()) { + return true; + } + for (Material material : levelupRequirements.keySet()) { + if (!level.getLevelupRequirements().containsKey(material)) { + return true; + } + } + return false; + } + public boolean grow(Material material) { + if (levelupRequirements == null) { + levelupRequirements = new HashMap<>(); + } if (levelupRequirements.containsKey(material)) { int levelRequirement = level.getLevelupRequirements().getOrDefault(material, levelupRequirements.get(material)); int remainingBefore = levelupRequirements.get(material); @@ -131,6 +166,9 @@ public void update() { public void playParticles() { if (blocks != null && blocks.size() > 0) { + if (level.getAmbientEffect() != null) { + level.getAmbientEffect().playEffect(location.clone().add(0, level.getTreeHeight(), 0)); + } for (Block block : blocks) { if (!block.getChunk().isLoaded()) continue; @@ -144,9 +182,6 @@ public void playParticles() level.getBodyEffect().playEffect(block.getLocation()); } } - if (level.getAmbientEffect() != null) { - level.getAmbientEffect().playEffect(location.clone().add(0, level.getTreeHeight(), 0)); - } } } } @@ -179,13 +214,19 @@ private void levelUp() { } public void unbuild() { + unbuild(true); + } + + public void unbuild(boolean playBlockEffects) { Block block; Location loc; for (Entry cBlock : blockAssociation.entrySet()) { if (cBlock.getValue().equals(treeuid)) { block = cBlock.getKey(); loc = block.getLocation(); - loc.getWorld().playEffect(loc, Effect.STEP_SOUND, block.getType()); + if (playBlockEffects && loc.getWorld() != null) { + loc.getWorld().playEffect(loc, Effect.STEP_SOUND, block.getType()); + } block.setType(Material.AIR); blockAssociation.remove(block); } @@ -291,14 +332,14 @@ private void refundResources(Player refundTarget) { List leftovers = putRefundsInContainer(Material.CHEST, refundItems); if (leftovers != null) { dropRefunds(leftovers); - notifyRefund(refundTarget, TextUtils.success(LocaleManager.text("messages.tree.refund.chest", "Your tree resources were returned in a chest."))); + notifyRefund(refundTarget, LocaleManager.text("messages.tree.refund.chest", "Your tree resources were returned in a chest.")); return; } leftovers = putRefundsInContainer(Material.BARREL, refundItems); if (leftovers != null) { dropRefunds(leftovers); - notifyRefund(refundTarget, TextUtils.success(LocaleManager.text("messages.tree.refund.barrel", "Your tree resources were returned in a barrel."))); + notifyRefund(refundTarget, LocaleManager.text("messages.tree.refund.barrel", "Your tree resources were returned in a barrel.")); return; } @@ -306,9 +347,9 @@ private void refundResources(Player refundTarget) { dropRefunds(leftovers); if (refundTarget != null) { if (leftovers.isEmpty()) { - notifyRefund(refundTarget, TextUtils.success(LocaleManager.text("messages.tree.refund.inventory", "Your tree resources were returned to your inventory."))); + notifyRefund(refundTarget, LocaleManager.text("messages.tree.refund.inventory", "Your tree resources were returned to your inventory.")); } else { - notifyRefund(refundTarget, TextUtils.info(LocaleManager.text("messages.tree.refund.inventory-overflow", "Your tree resources were returned. Inventory overflow dropped at the tree."))); + notifyRefund(refundTarget, LocaleManager.text("messages.tree.refund.inventory-overflow", "Your tree resources were returned. Inventory overflow dropped at the tree.")); } } } @@ -403,6 +444,33 @@ public int getScheduledPresents() { return scheduledPresents; } + public boolean isBuilt() { + return blocks != null && !blocks.isEmpty(); + } + + public int getBuiltBlockCount() { + return blocks != null ? blocks.size() : 0; + } + + public int getNearbyPresentCount() { + if (location == null || location.getWorld() == null) { + return 0; + } + int presentCount = 0; + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + if (x == 0 && z == 0) { + continue; + } + Block block = location.clone().add(x, 0, z).getBlock(); + if (XMas.isPresentHead(block)) { + presentCount++; + } + } + } + return presentCount; + } + public boolean hasScheduledPresents() { return scheduledPresents > 0; } diff --git a/src/main/java/ru/meloncode/xmas/Main.java b/src/main/java/ru/meloncode/xmas/Main.java index 0aa4789..a3196e5 100644 --- a/src/main/java/ru/meloncode/xmas/Main.java +++ b/src/main/java/ru/meloncode/xmas/Main.java @@ -17,6 +17,7 @@ import org.bukkit.plugin.java.JavaPlugin; import org.bukkit.util.Vector; import ru.meloncode.xmas.utils.ConfigUtils; +import ru.meloncode.xmas.utils.HeadProfileUtils; import ru.meloncode.xmas.utils.TextUtils; import java.io.File; @@ -31,7 +32,7 @@ public class Main extends JavaPlugin implements Listener { // Yeah. That's as it should be. static final Random RANDOM = new Random(Calendar.getInstance().get(Calendar.YEAR)); - static List gifts; + static List gifts; static float LUCK_CHANCE; static boolean LUCK_CHANCE_ENABLED; static boolean resourceBack; @@ -54,21 +55,62 @@ public class Main extends JavaPlugin implements Listener { private FileConfiguration config; private String locale; private XMasPlaceholderExpansion placeholderExpansion; + private ConfigDiagnostics configDiagnostics = ConfigDiagnostics.empty(); + private EventProgress eventProgress; public record ReloadSummary( String locale, int giftCount, int presentHeadCount, + int configuredPresentHeadCount, + int texturePresentHeadCount, + int legacyPlayerHeadCount, + int invalidPresentHeadCount, + int invalidGiftCount, + int configWarningCount, + int treeDataWarningCount, int treeCount, int treeOwnerCount, boolean particlesEnabled, boolean resourceBack, - boolean legacyAliasEnabled, float growFirstSoundVolume, - float growRepeatSoundVolume + float growRepeatSoundVolume, + List warnings ) { } + public record ConfigDiagnostics( + int configuredPresentHeadCount, + int validPresentHeadCount, + int texturePresentHeadCount, + int legacyPlayerHeadCount, + int invalidPresentHeadCount, + int configuredGiftCount, + int validGiftCount, + int invalidGiftCount, + int treeDataWarningCount, + List warnings + ) { + public static ConfigDiagnostics empty() { + return new ConfigDiagnostics(0, 0, 0, 0, 0, 0, 0, 0, 0, Collections.emptyList()); + } + + public int configWarningCount() { + return warnings.size(); + } + } + + public record GiftEntry(ItemStack item, int weight) { + public GiftEntry { + item = item.clone(); + weight = Math.max(1, weight); + } + + public ItemStack itemClone() { + return item.clone(); + } + } + public static Plugin getInstance() { return plugin; } @@ -89,6 +131,14 @@ public static NamespacedKey getPresentHeadKey() { return presentHeadKey; } + public static int getUpdateSpeed() { + return UPDATE_SPEED; + } + + public EventProgress getEventProgress() { + return eventProgress; + } + private File getPluginConfigFile() { return new File(getDataFolder(), CONFIG_RESOURCE_PATH); } @@ -159,25 +209,16 @@ public void onEnable() { TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.config.unable-load-holiday-end-date", "Unable to load holiday end date"))); } defineTreeLevels(); + eventProgress = new EventProgress(this); for (World world : getServer().getWorlds()) { TreeSerializer.loadTrees(this, world); } - heads = config.getStringList("xmas.presents"); + loadDiagnosticsAndConfiguredItems(true); if (heads.size() == 0) { getLogger().warning(LocaleManager.text("console.gifts.no-heads", "Warning! No heads loaded. Presents cannot spawn without a box head.")); return; } - gifts = new ArrayList<>(); - for (String serializedItem : config.getStringList("xmas.gifts")) { - ItemStack item = deserializeItem(serializedItem); - if (item != null) { - gifts.add(item); - } else { - getLogger().warning(LocaleManager.text("console.gifts.load-item-failed", "Failed to load gift item: {item}", - "{item}", serializedItem)); - } - } if (gifts.size() == 0) { getLogger().warning(LocaleManager.text("console.gifts.no-gifts", "Warning! No gifts loaded. No X-Mas without gifts.")); return; @@ -203,7 +244,7 @@ public void onWorldLoad(WorldLoadEvent event) { @EventHandler public void onWorldUnload(WorldUnloadEvent event) { for (MagicTree magicTree : XMas.getAllTrees()) { - if (magicTree.getLocation().getWorld() == event.getWorld()) magicTree.unbuild(); + if (magicTree.getLocation().getWorld() == event.getWorld()) magicTree.unbuild(false); } } @@ -236,18 +277,13 @@ public ReloadSummary reloadPluginConfig() { } defineTreeLevels(); - heads = config.getStringList("xmas.presents"); - - gifts = new ArrayList<>(); - for (String serializedItem : config.getStringList("xmas.gifts")) { - ItemStack item = deserializeItem(serializedItem); - if (item != null) { - gifts.add(item); - } else { - getLogger().warning(LocaleManager.text("console.gifts.deserialize-item-failed", "Failed to deserialize gift item: {item}", - "{item}", serializedItem)); - } + refreshLoadedTreeDefinitions(); + if (eventProgress == null) { + eventProgress = new EventProgress(this); + } else { + eventProgress.reload(); } + loadDiagnosticsAndConfiguredItems(true); LUCK_CHANCE_ENABLED = config.getBoolean("xmas.luck.enabled"); LUCK_CHANCE = (float) config.getInt("xmas.luck.chance") / 100; @@ -266,21 +302,38 @@ public ReloadSummary createReloadSummary() { LocaleManager.getActiveLocaleCode(), gifts != null ? gifts.size() : 0, heads != null ? heads.size() : 0, + configDiagnostics.configuredPresentHeadCount(), + configDiagnostics.texturePresentHeadCount(), + configDiagnostics.legacyPlayerHeadCount(), + configDiagnostics.invalidPresentHeadCount(), + configDiagnostics.invalidGiftCount(), + configDiagnostics.configWarningCount(), + configDiagnostics.treeDataWarningCount(), XMas.getAllTrees().size(), owners.size(), particlesEnabled, resourceBack, - getConfig().getBoolean("core.commands.legacy-command-enabled", true), growFirstSoundVolume, - growRepeatSoundVolume + growRepeatSoundVolume, + configDiagnostics.warnings() ); } + public ConfigDiagnostics getConfigDiagnostics() { + return configDiagnostics; + } + private void loadSoundConfig() { growFirstSoundVolume = clampVolume(config.getDouble("core.sounds.grow.first-volume", 0.5)); growRepeatSoundVolume = clampVolume(config.getDouble("core.sounds.grow.repeat-volume", 0.2)); } + private void refreshLoadedTreeDefinitions() { + for (MagicTree tree : XMas.getAllTrees()) { + tree.refreshLevelDefinition(); + } + } + private float clampVolume(double volume) { if (Double.isNaN(volume)) { return 0; @@ -297,11 +350,11 @@ public void addGiftItem(ItemStack item) { return; } - List giftList = config.getStringList("xmas.gifts"); + List giftList = getRawGiftList(); giftList.add(serializedItem); config.set("xmas.gifts", giftList); saveConfig(); - gifts.add(gift); + reloadGiftItemsFromConfig(); } public List getGiftItems() { @@ -309,19 +362,61 @@ public List getGiftItems() { if (gifts == null) { return snapshot; } - for (ItemStack gift : gifts) { - if (gift != null) { - snapshot.add(gift.clone()); + for (GiftEntry gift : gifts) { + if (gift != null && gift.item() != null) { + snapshot.add(gift.itemClone()); + } + } + return snapshot; + } + + public List getGiftEntries() { + List snapshot = new ArrayList<>(); + if (gifts == null) { + return snapshot; + } + for (GiftEntry gift : gifts) { + if (gift != null && gift.item() != null) { + snapshot.add(new GiftEntry(gift.item(), gift.weight())); } } return snapshot; } public ItemStack rollGiftItem() { + return rollConfiguredGift(); + } + + public static ItemStack rollConfiguredGift() { if (gifts == null || gifts.isEmpty()) { return null; } - return gifts.get(RANDOM.nextInt(gifts.size())).clone(); + int totalWeight = getGiftTotalWeight(); + if (totalWeight <= 0) { + return null; + } + int roll = RANDOM.nextInt(totalWeight) + 1; + int runningWeight = 0; + for (GiftEntry gift : gifts) { + runningWeight += gift.weight(); + if (roll <= runningWeight) { + return gift.itemClone(); + } + } + return gifts.get(gifts.size() - 1).itemClone(); + } + + public static int getGiftTotalWeight() { + if (gifts == null || gifts.isEmpty()) { + return 0; + } + int totalWeight = 0; + for (GiftEntry gift : gifts) { + if (gift != null) { + totalWeight += Math.max(1, gift.weight()); + } + } + return totalWeight; } public ItemStack removeGiftItem(int displayIndex) { @@ -329,11 +424,11 @@ public ItemStack removeGiftItem(int displayIndex) { return null; } - List giftList = config.getStringList("xmas.gifts"); + List giftList = getRawGiftList(); int validIndex = 0; for (int rawIndex = 0; rawIndex < giftList.size(); rawIndex++) { - ItemStack item = deserializeItem(giftList.get(rawIndex)); - if (item == null) { + GiftEntry gift = deserializeGiftEntry(giftList.get(rawIndex), true); + if (gift == null) { continue; } validIndex++; @@ -342,7 +437,35 @@ public ItemStack removeGiftItem(int displayIndex) { config.set("xmas.gifts", giftList); saveConfig(); reloadGiftItemsFromConfig(); - return item; + return gift.itemClone(); + } + } + return null; + } + + public GiftEntry updateGiftWeight(int displayIndex, int weight) { + if (displayIndex < 1 || weight < 1) { + return null; + } + + List giftList = getRawGiftList(); + int validIndex = 0; + for (int rawIndex = 0; rawIndex < giftList.size(); rawIndex++) { + Object rawGift = giftList.get(rawIndex); + GiftEntry gift = deserializeGiftEntry(rawGift, true); + if (gift == null) { + continue; + } + validIndex++; + if (validIndex == displayIndex) { + Map updatedGift = new LinkedHashMap<>(); + updatedGift.put("item", extractGiftItemValue(rawGift)); + updatedGift.put("weight", weight); + giftList.set(rawIndex, updatedGift); + config.set("xmas.gifts", giftList); + saveConfig(); + reloadGiftItemsFromConfig(); + return new GiftEntry(gift.item(), weight); } } return null; @@ -350,14 +473,150 @@ public ItemStack removeGiftItem(int displayIndex) { private void reloadGiftItemsFromConfig() { gifts = new ArrayList<>(); - for (String serializedItem : config.getStringList("xmas.gifts")) { - ItemStack item = deserializeItem(serializedItem); - if (item != null) { - gifts.add(item); + for (Object configuredGift : getRawGiftList()) { + GiftEntry gift = deserializeGiftEntry(configuredGift, false); + if (gift != null) { + gifts.add(gift); } } } + private void loadDiagnosticsAndConfiguredItems(boolean logWarnings) { + List warnings = new ArrayList<>(); + List configuredHeads = config.getStringList("xmas.presents"); + List validHeads = new ArrayList<>(); + int textureHeads = 0; + int legacyPlayerHeads = 0; + int invalidHeads = 0; + + for (String configuredHead : configuredHeads) { + HeadProfileUtils.HeadConfigValidation validation = HeadProfileUtils.validateConfiguredHead(configuredHead); + if (validation.valid()) { + validHeads.add(configuredHead.trim()); + if (validation.type() == HeadProfileUtils.HeadConfigType.TEXTURE_URL) { + textureHeads++; + } else if (validation.type() == HeadProfileUtils.HeadConfigType.PLAYER_NAME) { + legacyPlayerHeads++; + } + } else { + invalidHeads++; + if (validation.warning() != null) { + warnings.add(validation.warning()); + } + } + } + if (legacyPlayerHeads > 0) { + warnings.add(LocaleManager.text("console.heads.legacy-player-names", "Present heads include {count} legacy player-name entr{plural}. Texture URLs are preferred for predictable Paper 26.x behavior.", + "{count}", Integer.toString(legacyPlayerHeads), + "{plural}", legacyPlayerHeads == 1 ? "y" : "ies")); + } + + List configuredGifts = getRawGiftList(); + List validGifts = new ArrayList<>(); + int invalidGifts = 0; + for (Object configuredGift : configuredGifts) { + GiftEntry gift = deserializeGiftEntry(configuredGift, false); + if (gift != null) { + validGifts.add(gift); + } else { + invalidGifts++; + warnings.add(LocaleManager.text("console.gifts.invalid-entry", "Invalid gift entry skipped: {item}", + "{item}", abbreviate(String.valueOf(configuredGift), 80))); + } + } + + TreeSerializer.TreeDataValidationReport treeDataReport = TreeSerializer.validateTreesFile(); + int treeDataWarningCount = treeDataReport.warningCount(); + if (treeDataWarningCount > 0) { + warnings.add(LocaleManager.text("console.trees.validation-warnings", "trees.yml has {count} validation warning(s). Run /xmastree data validate for details.", + "{count}", Integer.toString(treeDataWarningCount))); + } + + if (validHeads.isEmpty()) { + warnings.add(LocaleManager.text("console.gifts.no-valid-heads", "No valid present head entries are configured.")); + } + if (validGifts.isEmpty()) { + warnings.add(LocaleManager.text("console.gifts.no-valid-gifts", "No valid gift entries are configured.")); + } + + heads = validHeads; + gifts = validGifts; + configDiagnostics = new ConfigDiagnostics( + configuredHeads.size(), + validHeads.size(), + textureHeads, + legacyPlayerHeads, + invalidHeads, + configuredGifts.size(), + validGifts.size(), + invalidGifts, + treeDataWarningCount, + Collections.unmodifiableList(new ArrayList<>(warnings)) + ); + + if (logWarnings) { + for (String warning : warnings) { + getLogger().warning(warning); + } + } + } + + private List getRawGiftList() { + List rawList = config.getList("xmas.gifts", Collections.emptyList()); + return new ArrayList<>(rawList); + } + + private static GiftEntry deserializeGiftEntry(Object configuredGift, boolean logWarnings) { + if (configuredGift instanceof Map map) { + Object itemValue = firstPresentValue(map, "item", "reward", "material", "value"); + if (itemValue == null) { + return null; + } + int weight = parseGiftWeight(map.get("weight")); + if (weight < 1) { + return null; + } + ItemStack item = deserializeItem(String.valueOf(itemValue), logWarnings); + return item != null ? new GiftEntry(item, weight) : null; + } + + ItemStack item = deserializeItem(String.valueOf(configuredGift), logWarnings); + return item != null ? new GiftEntry(item, 1) : null; + } + + private static Object firstPresentValue(Map map, String... keys) { + for (String key : keys) { + if (map.containsKey(key) && map.get(key) != null) { + return map.get(key); + } + } + return null; + } + + private static int parseGiftWeight(Object rawWeight) { + if (rawWeight == null) { + return 1; + } + if (rawWeight instanceof Number number) { + return number.intValue(); + } + try { + return Integer.parseInt(String.valueOf(rawWeight).trim()); + } catch (NumberFormatException ignored) { + return -1; + } + } + + private String extractGiftItemValue(Object rawGift) { + if (rawGift instanceof Map map) { + Object itemValue = firstPresentValue(map, "item", "reward", "material", "value"); + if (itemValue != null) { + return String.valueOf(itemValue); + } + } + return String.valueOf(rawGift); + } + private String serializeItem(ItemStack item) { try { byte[] serializedBytes = item.serializeAsBytes(); @@ -370,6 +629,10 @@ private String serializeItem(ItemStack item) { } public static ItemStack deserializeItem(String serializedItem) { + return deserializeItem(serializedItem, true); + } + + private static ItemStack deserializeItem(String serializedItem, boolean logWarnings) { if (serializedItem == null) { return null; } @@ -384,8 +647,10 @@ public static ItemStack deserializeItem(String serializedItem) { } if (trimmed.length() > MAX_SERIALIZED_GIFT_LENGTH) { - Bukkit.getLogger().severe(LocaleManager.text("console.gifts.too-large", "Gift item is too large to deserialize safely: {length} characters", - "{length}", Integer.toString(trimmed.length()))); + if (logWarnings) { + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.too-large", "Gift item is too large to deserialize safely: {length} characters", + "{length}", Integer.toString(trimmed.length()))); + } return null; } @@ -393,16 +658,30 @@ public static ItemStack deserializeItem(String serializedItem) { byte[] serializedBytes = Base64.getDecoder().decode(trimmed); return ItemStack.deserializeBytes(serializedBytes); } catch (IllegalArgumentException e) { - Bukkit.getLogger().severe(LocaleManager.text("console.gifts.invalid-material-or-base64", "Invalid material name or Base64 gift item: {item}", - "{item}", trimmed)); + if (logWarnings) { + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.invalid-material-or-base64", "Invalid material name or Base64 gift item: {item}", + "{item}", trimmed)); + } return null; } catch (Exception e) { - Bukkit.getLogger().severe(LocaleManager.text("console.gifts.deserialize-error", "Failed to deserialize gift item: {error}", - "{error}", e.getMessage())); + if (logWarnings) { + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.deserialize-error", "Failed to deserialize gift item: {error}", + "{error}", e.getMessage())); + } return null; } } + private static String abbreviate(String value, int maxLength) { + if (value == null) { + return "null"; + } + if (value.length() <= maxLength) { + return value; + } + return value.substring(0, Math.max(0, maxLength - 3)) + "..."; + } + private static ItemStack deserializeMaterialItem(String serializedItem) { String materialName = serializedItem; int amount = 1; @@ -431,9 +710,12 @@ public void onDisable() { if (placeholderExpansion != null && placeholderExpansion.isRegistered()) { placeholderExpansion.unregister(); } + if (eventProgress != null) { + eventProgress.save(); + } if (XMas.getAllTrees().size() > 0) for (MagicTree tree : XMas.getAllTrees()) { - tree.unbuild(); + tree.unbuild(false); } } @@ -492,17 +774,69 @@ private void defineTreeLevels() { long small_delay = config.getInt("xmas.tree-lvl.small_tree.gift-cooldown") * 20 / UPDATE_SPEED; long tree_delay = config.getInt("xmas.tree-lvl.tree.gift-cooldown") * 20 / UPDATE_SPEED; long magic_delay = config.getInt("xmas.tree-lvl.magic_tree.gift-cooldown") * 20 / UPDATE_SPEED; + long super_delay = config.getInt("xmas.tree-lvl.super_tree.gift-cooldown") * 20 / UPDATE_SPEED; ConfigurationSection lvlups = config.getConfigurationSection("xmas.tree-lvl"); Map saplingLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("sapling.lvlup").getValues(false)); Map smallLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("small_tree.lvlup").getValues(false)); Map treeLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("tree.lvlup").getValues(false)); + Map magicLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("magic_tree.lvlup").getValues(false)); + + TreeLevel.SUPER_TREE = new TreeLevel("super_tree", + getParticleEffect("super_tree", "ambient", Effects.TREE_WHITE_AMBIENT), + getParticleEffect("super_tree", "swag", Effects.TREE_GOLD_SWAG), + getParticleEffect("super_tree", "body", null), + null, super_delay, Collections.emptyMap(), new StructureTemplate(new HashMap() { + private static final long serialVersionUID = 1L; + + { + put(new Vector(0, -1, 0), Material.GRASS_BLOCK); + for (int i = 0; i <= 7; i++) { + put(new Vector(0, i, 0), Material.SPRUCE_LOG); + if (i >= 2) { + put(new Vector(1, i, 0), Material.SPRUCE_LEAVES); + put(new Vector(-1, i, 0), Material.SPRUCE_LEAVES); + put(new Vector(0, i, 1), Material.SPRUCE_LEAVES); + put(new Vector(0, i, -1), Material.SPRUCE_LEAVES); + } + } + put(new Vector(0, 8, 0), Material.SPRUCE_LEAVES); + put(new Vector(0, 9, 0), Material.GLOWSTONE); + + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + if (Math.abs(x) + Math.abs(z) <= 3 && !(x == 0 && z == 0)) { + put(new Vector(x, 2, z), Material.SPRUCE_LEAVES); + } + } + } + for (int x = -2; x <= 2; x++) { + for (int z = -2; z <= 2; z++) { + if (Math.abs(x) + Math.abs(z) <= 2 && !(x == 0 && z == 0)) { + put(new Vector(x, 4, z), Material.SPRUCE_LEAVES); + } + } + } + for (int x = -1; x <= 1; x++) { + for (int z = -1; z <= 1; z++) { + if (!(x == 0 && z == 0)) { + put(new Vector(x, 6, z), Material.SPRUCE_LEAVES); + } + } + } + + put(new Vector(2, 3, 0), Material.REDSTONE_BLOCK); + put(new Vector(-2, 3, 0), Material.GOLD_BLOCK); + put(new Vector(0, 5, 2), Material.EMERALD_BLOCK); + put(new Vector(0, 5, -2), Material.DIAMOND_BLOCK); + } + })); TreeLevel.MAGIC_TREE = new TreeLevel("magic_tree", getParticleEffect("magic_tree", "ambient", Effects.TREE_WHITE_AMBIENT), getParticleEffect("magic_tree", "swag", Effects.TREE_SWAG), getParticleEffect("magic_tree", "body", null), - null, magic_delay, Collections.emptyMap(), new StructureTemplate(new HashMap() { + TreeLevel.SUPER_TREE, magic_delay, magicLevelUp, new StructureTemplate(new HashMap() { private static final long serialVersionUID = 1L; { diff --git a/src/main/java/ru/meloncode/xmas/TreeLevel.java b/src/main/java/ru/meloncode/xmas/TreeLevel.java index a393825..3f2306f 100644 --- a/src/main/java/ru/meloncode/xmas/TreeLevel.java +++ b/src/main/java/ru/meloncode/xmas/TreeLevel.java @@ -6,6 +6,7 @@ import java.util.Map; public class TreeLevel { + public static TreeLevel SUPER_TREE; public static TreeLevel MAGIC_TREE; public static TreeLevel TREE; public static TreeLevel SMALL_TREE; @@ -48,6 +49,8 @@ public static TreeLevel fromString(String name) { return SMALL_TREE; case "magic_tree": return MAGIC_TREE; + case "super_tree": + return SUPER_TREE; default: throw new IllegalArgumentException("Wrong level '" + name + "'"); } diff --git a/src/main/java/ru/meloncode/xmas/TreeSerializer.java b/src/main/java/ru/meloncode/xmas/TreeSerializer.java index 02eb3cd..36e8558 100644 --- a/src/main/java/ru/meloncode/xmas/TreeSerializer.java +++ b/src/main/java/ru/meloncode/xmas/TreeSerializer.java @@ -18,6 +18,7 @@ import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.Map; import java.util.Set; @@ -51,6 +52,16 @@ public boolean hasWarnings() { || !invalidRequirements.isEmpty() || !duplicateLocations.isEmpty(); } + + public int warningCount() { + return invalidTreeIds.size() + + invalidOwners.size() + + invalidLevels.size() + + invalidLocations.size() + + missingWorlds.size() + + invalidRequirements.size() + + duplicateLocations.size(); + } } public record WorldMigrationReport( @@ -62,6 +73,14 @@ public record WorldMigrationReport( ) { } + public record TreeSummary( + int storedTreeCount, + int loadedTreeCount, + Set owners, + Map levelCounts + ) { + } + public static void loadTrees(JavaPlugin plugin, World world) { try { UUID owner; @@ -85,15 +104,31 @@ public static void loadTrees(JavaPlugin plugin, World world) { z = trees.getInt("trees." + cKey + ".loc.z"); loc = new Location(world, x, y, z); Map requirements; - if (trees.getConfigurationSection("trees." + cKey + ".levelup") != null) { - requirements = convertRequirementsMap(trees.getConfigurationSection("trees." + cKey + ".levelup").getValues(false)); + ConfigurationSection requirementsSection = trees.getConfigurationSection("trees." + cKey + ".levelup"); + boolean readyToLevelUp = trees.getBoolean("trees." + cKey + ".ready_to_levelup", false); + boolean backfilledRequirements = false; + if (readyToLevelUp) { + requirements = new HashMap<>(); + } else if (requirementsSection != null) { + requirements = convertRequirementsMap(requirementsSection.getValues(false)); + if (shouldBackfillLegacyRequirements(level, requirements)) { + requirements = new HashMap<>(level.getLevelupRequirements()); + backfilledRequirements = true; + } + } else if (level.hasNext()) { + requirements = new HashMap<>(level.getLevelupRequirements()); + backfilledRequirements = shouldBackfillLegacyRequirements(level, null); } else { requirements = new HashMap<>(); } presentCounter = trees.getLong("trees." + cKey + ".present_counter", 0); scheduledPresents = trees.getInt("trees." + cKey + ".scheduled_presents", 0); - XMas.addMagicTree(new MagicTree(owner, treeUID, level, loc, requirements, presentCounter, scheduledPresents)); + MagicTree tree = new MagicTree(owner, treeUID, level, loc, requirements, presentCounter, scheduledPresents); + XMas.addMagicTree(tree); + if (backfilledRequirements) { + tree.save(); + } } catch (Exception e) { plugin.getLogger().log(Level.SEVERE, LocaleManager.text("console.trees.load-tree-error", "Error while loading tree {tree}", "{tree}", cKey), e); @@ -117,6 +152,10 @@ public static void saveTree(MagicTree tree) { trees.set("trees." + cKey + ".loc.x", tree.getLocation().getX()); trees.set("trees." + cKey + ".loc.y", tree.getLocation().getY()); trees.set("trees." + cKey + ".loc.z", tree.getLocation().getZ()); + trees.set("trees." + cKey + ".levelup", null); + boolean readyToLevelUp = tree.getLevel().hasNext() + && (tree.getLevelupRequirements() == null || tree.getLevelupRequirements().isEmpty()); + trees.set("trees." + cKey + ".ready_to_levelup", readyToLevelUp ? true : null); if (tree.getLevelupRequirements() != null && tree.getLevelupRequirements().size() > 0) trees.createSection("trees." + cKey + ".levelup", tree.getLevelupRequirements()); trees.set("trees." + cKey + ".present_counter", tree.getPresentCounter()); @@ -199,6 +238,32 @@ public static TreeDataValidationReport validateTreesFile() { ); } + public static TreeSummary summarizeTreesFile() { + FileConfiguration data = ConfigUtils.loadConfig(treesFile); + ConfigurationSection treeSection = data.getConfigurationSection("trees"); + Set owners = new LinkedHashSet<>(); + Map levelCounts = new LinkedHashMap<>(); + + if (treeSection == null) { + return new TreeSummary(0, XMas.getAllTrees().size(), owners, levelCounts); + } + + for (String treeKey : treeSection.getKeys(false)) { + String rawOwner = data.getString("trees." + treeKey + ".owner"); + if (rawOwner != null) { + try { + owners.add(UUID.fromString(rawOwner)); + } catch (IllegalArgumentException ignored) { + // Validation reports invalid owners; summaries skip malformed UUIDs. + } + } + String level = data.getString("trees." + treeKey + ".level", "unknown"); + levelCounts.merge(level, 1, Integer::sum); + } + + return new TreeSummary(treeSection.getKeys(false).size(), XMas.getAllTrees().size(), owners, levelCounts); + } + public static WorldMigrationReport migrateWorldName(String sourceWorld, String targetWorld, boolean apply) throws IOException { FileConfiguration data = ConfigUtils.loadConfig(treesFile); ConfigurationSection treeSection = data.getConfigurationSection("trees"); @@ -254,6 +319,25 @@ public static Map convertRequirementsMap(Map return levelupRequirements; } + private static boolean shouldBackfillLegacyRequirements(TreeLevel level, Map savedRequirements) { + if (level == null + || !level.hasNext() + || !"magic_tree".equalsIgnoreCase(level.getLevelName()) + || level.getLevelupRequirements() == null + || level.getLevelupRequirements().isEmpty()) { + return false; + } + if (savedRequirements == null || savedRequirements.isEmpty()) { + return true; + } + for (Material material : savedRequirements.keySet()) { + if (!level.getLevelupRequirements().containsKey(material)) { + return true; + } + } + return false; + } + private static boolean matchesSavedWorld(World world, String savedWorldName) { if (world == null || savedWorldName == null || savedWorldName.isBlank()) { return false; diff --git a/src/main/java/ru/meloncode/xmas/XMas.java b/src/main/java/ru/meloncode/xmas/XMas.java index 20bafbb..38bb294 100644 --- a/src/main/java/ru/meloncode/xmas/XMas.java +++ b/src/main/java/ru/meloncode/xmas/XMas.java @@ -31,6 +31,11 @@ public static void createMagicTree(Player player, Location loc) { trees.put(tree.getTreeUID(), tree); trees_byChunk.computeIfAbsent(LocationUtils.getChunkKey(tree.getLocation()), aLong -> new ArrayList<>()).add(tree); tree.save(); + if (Main.getInstance() instanceof Main plugin && plugin.getEventProgress() != null) { + plugin.getEventProgress().recordTreeCreated(player, tree); + } + Effects.CRYSTAL_SAPLING_GREEN.playEffect(loc); + Effects.CRYSTAL_SAPLING_WHITE.playEffect(loc); } public static void addMagicTree(MagicTree tree) { @@ -58,6 +63,9 @@ public static void removeTree(MagicTree tree, boolean unbuild) { if (unbuild) { tree.unbuild(); } + if (Main.getInstance() instanceof Main plugin && plugin.getEventProgress() != null) { + plugin.getEventProgress().recordTreeRemoved(tree); + } TreeSerializer.removeTree(tree); trees.remove(tree.getTreeUID()); List chunkTrees = trees_byChunk.get(LocationUtils.getChunkKey(tree.getLocation())); @@ -78,7 +86,12 @@ public static void processPresent(Block block, Player player) { World world = loc.getWorld(); if (world != null) { if (RANDOM.nextFloat() < Main.LUCK_CHANCE || !Main.LUCK_CHANCE_ENABLED) { - world.dropItemNaturally(loc, new ItemStack(Main.gifts.get(RANDOM.nextInt(Main.gifts.size())))); + ItemStack gift = Main.rollConfiguredGift(); + if (gift == null) { + block.setType(Material.AIR); + return; + } + world.dropItemNaturally(loc, gift); Effects.TREE_SWAG.playEffect(loc); TextUtils.sendMessage(player, LocaleManager.GIFT_LUCK); } else { @@ -87,6 +100,9 @@ public static void processPresent(Block block, Player player) { TextUtils.sendMessage(player, LocaleManager.GIFT_FAIL); } block.setType(Material.AIR); + if (Main.getInstance() instanceof Main plugin && plugin.getEventProgress() != null) { + plugin.getEventProgress().recordGiftClaim(player); + } } } @@ -109,6 +125,9 @@ public static List getTreesPlayerOwn(Player player) { } public static MagicTree getTree(UUID treeUID) { + if (treeUID == null) { + return null; + } return trees.get(treeUID); } } diff --git a/src/main/java/ru/meloncode/xmas/XMasCommand.java b/src/main/java/ru/meloncode/xmas/XMasCommand.java index d21333e..745b1cc 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -7,14 +7,13 @@ import org.bukkit.Sound; import org.bukkit.World; import org.bukkit.block.Block; -import org.bukkit.command.CommandMap; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; import org.bukkit.command.PluginCommand; -import org.bukkit.command.SimpleCommandMap; import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; +import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; @@ -22,18 +21,17 @@ import java.io.File; import java.io.IOException; -import java.lang.reflect.Constructor; -import java.lang.reflect.Field; import java.text.SimpleDateFormat; import java.util.*; public class XMasCommand implements CommandExecutor, TabCompleter { private static final PlainTextComponentSerializer PLAIN_TEXT = PlainTextComponentSerializer.plainText(); public static final String PRIMARY_COMMAND = "xmastree"; - public static final String LEGACY_COMMAND = "xmas"; + private static final String RETIRED_LEGACY_COMMAND = "xmas"; public static final String PERMISSION_ADMIN = "onembxmastree.admin"; public static final String PERMISSION_STATUS = "onembxmastree.command.status"; public static final String PERMISSION_HELP = "onembxmastree.command.help"; + public static final String PERMISSION_INFO = "onembxmastree.command.info"; public static final String PERMISSION_GIVE = "onembxmastree.command.give"; public static final String PERMISSION_GIFTS = "onembxmastree.command.gifts"; public static final String PERMISSION_ADDHAND = "onembxmastree.command.addhand"; @@ -43,11 +41,15 @@ public class XMasCommand implements CommandExecutor, TabCompleter { public static final String PERMISSION_INSPECT = "onembxmastree.command.inspect"; public static final String PERMISSION_TEST = "onembxmastree.command.test"; public static final String PERMISSION_DATA = "onembxmastree.command.data"; + public static final String PERMISSION_JOURNAL = "onembxmastree.command.journal"; + public static final String PERMISSION_MILESTONES = "onembxmastree.command.milestones"; + public static final String PERMISSION_MILESTONES_CLAIM = "onembxmastree.command.milestones.claim"; + public static final String PERMISSION_COMMUNITY = "onembxmastree.command.community"; + public static final String PERMISSION_COMMUNITY_CLAIM = "onembxmastree.command.community.claim"; public static final String PERMISSION_END = "onembxmastree.command.end"; public static final String PERMISSION_TREE_OVERRIDE = "onembxmastree.tree.override"; - private static final List COMMANDS = Arrays.asList("help", "give", "end", "gifts", "reload", "addhand", "debug", "inspect", "test", "data"); + private static final List COMMANDS = Arrays.asList("help", "info", "give", "end", "gifts", "reload", "addhand", "debug", "inspect", "test", "data", "journal", "milestones", "community"); private static final Set DEBUG_TOGGLE_KEYS = new LinkedHashSet<>(Arrays.asList( - "core.commands.legacy-command-enabled", "core.plugin-enabled", "core.holiday-ends.enabled", "core.holiday-ends.resource-back", @@ -56,7 +58,6 @@ public class XMasCommand implements CommandExecutor, TabCompleter { )); private static final Map DEBUG_SECTIONS = createDebugSections(); private static XMasCommand registeredExecutor; - private static PluginCommand legacyAliasCommand; private final Main plugin; @@ -72,7 +73,6 @@ public static void register(Main plugin) { } primaryCommand.setExecutor(registeredExecutor); primaryCommand.setTabCompleter(registeredExecutor); - refreshCommandConfiguration(plugin); } public static void refreshCommandConfiguration(Main plugin) { @@ -80,7 +80,6 @@ public static void refreshCommandConfiguration(Main plugin) { register(plugin); return; } - syncLegacyAlias(plugin, registeredExecutor); } @Override @@ -99,6 +98,16 @@ public boolean onCommand(CommandSender sender, Command command, String label, St } break; } + case "info": { + if (!hasPermission(sender, PERMISSION_INFO)) { + sendNoPermission(sender); + break; + } + for (String line : getInfoLines()) { + TextUtils.sendRawMessage(sender, line); + } + break; + } case "give": { if (!hasPermission(sender, PERMISSION_GIVE)) { sendNoPermission(sender); @@ -126,10 +135,6 @@ public boolean onCommand(CommandSender sender, Command command, String label, St break; } case "gifts": { - if (!hasPermission(sender, PERMISSION_GIFTS)) { - sendNoPermission(sender); - break; - } handleGifts(sender, args); break; } @@ -169,26 +174,36 @@ public boolean onCommand(CommandSender sender, Command command, String label, St handleData(sender, args); break; } - case "addhand": { - if (!(sender instanceof Player player)) { - TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-only", TextUtils.error("Only players can use this command."))); + case "journal": { + if (!hasPermission(sender, PERMISSION_JOURNAL)) { + sendNoPermission(sender); break; } - if (!hasPermission(sender, PERMISSION_ADDHAND)) { + handleJournal(sender, args); + break; + } + case "milestones": { + if (!hasPermission(sender, PERMISSION_MILESTONES)) { sendNoPermission(sender); break; } - ItemStack item = player.getInventory().getItemInMainHand(); - if (item.getType().isAir()) { - TextUtils.sendRawMessage(player, replaceToken( - LocaleManager.text("command.hold-item-first", TextUtils.error("Hold an item before running {command}.")), - "command", - commandPath("addhand") - )); + handleMilestones(sender, args); + break; + } + case "community": { + if (!hasPermission(sender, PERMISSION_COMMUNITY)) { + sendNoPermission(sender); + break; + } + handleCommunity(sender, args); + break; + } + case "addhand": { + if (!hasPermission(sender, PERMISSION_ADDHAND)) { + sendNoPermission(sender); break; } - plugin.addGiftItem(item.clone()); - TextUtils.sendRawMessage(player, LocaleManager.text("command.gift-added", TextUtils.success("Added the held item to the gift list."))); + handleAddHand(sender, commandPath("addhand")); break; } case "debug": { @@ -244,13 +259,27 @@ public List onTabComplete(CommandSender sender, Command command, String } } else if (args[0].equalsIgnoreCase("test")) { if (args.length == 2) { - suggestions.addAll(filterStartingWith(Arrays.asList("sound", "particle"), args[1])); + suggestions.addAll(filterStartingWith(Arrays.asList("sound", "particle", "refund"), args[1])); } else if (args.length == 3 && args[1].equalsIgnoreCase("sound")) { suggestions.addAll(filterStartingWith(Arrays.asList("first", "repeat"), args[2])); } else if (args.length == 3 && args[1].equalsIgnoreCase("particle")) { suggestions.addAll(filterStartingWith(treeLevelNames(), args[2])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("refund")) { + List refundSuggestions = new ArrayList<>(); + refundSuggestions.add("nearest"); + for (MagicTree tree : XMas.getAllTrees()) { + refundSuggestions.add(tree.getTreeUID().toString()); + } + suggestions.addAll(filterStartingWith(refundSuggestions, args[2])); } else if (args.length == 4 && args[1].equalsIgnoreCase("particle")) { suggestions.addAll(filterStartingWith(Arrays.asList("all", "ambient", "swag", "body"), args[3])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("refund") && args[2].equalsIgnoreCase("nearest")) { + String typed = args[3].toLowerCase(Locale.ENGLISH); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase(Locale.ENGLISH).startsWith(typed)) { + suggestions.add(player.getName()); + } + } } else if ((args.length == 4 && args[1].equalsIgnoreCase("sound")) || (args.length == 5 && args[1].equalsIgnoreCase("particle"))) { String typed = args[args.length - 1].toLowerCase(Locale.ENGLISH); for (Player player : Bukkit.getOnlinePlayers()) { @@ -261,7 +290,9 @@ public List onTabComplete(CommandSender sender, Command command, String } } else if (args[0].equalsIgnoreCase("data")) { if (args.length == 2) { - suggestions.addAll(filterStartingWith(Arrays.asList("backup", "validate", "migrate-world"), args[1])); + suggestions.addAll(filterStartingWith(Arrays.asList("backup", "validate", "report", "migrate-world"), args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("backup")) { + suggestions.addAll(filterStartingWith(Arrays.asList("trees", "progress", "all"), args[2])); } else if (args.length == 3 && args[1].equalsIgnoreCase("migrate-world")) { suggestions.addAll(filterStartingWith(getWorldNames(), args[2])); } else if (args.length == 4 && args[1].equalsIgnoreCase("migrate-world")) { @@ -271,7 +302,40 @@ public List onTabComplete(CommandSender sender, Command command, String } } else if (args[0].equalsIgnoreCase("gifts")) { if (args.length == 2) { - suggestions.addAll(filterStartingWith(Arrays.asList("list", "roll", "remove", "spawn"), args[1])); + suggestions.addAll(filterStartingWith(Arrays.asList("list", "roll", "remove", "weight", "spawn", "addhand"), args[1])); + } else if (args.length == 3 && (args[1].equalsIgnoreCase("remove") || args[1].equalsIgnoreCase("weight"))) { + suggestions.addAll(filterStartingWith(giftIndexes(), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("weight")) { + suggestions.addAll(filterStartingWith(Arrays.asList("1", "2", "5", "10", "25", "50", "100"), args[3])); + } + } else if (args[0].equalsIgnoreCase("journal")) { + if (args.length == 2) { + List journalSuggestions = new ArrayList<>(); + journalSuggestions.add("gui"); + addOnlinePlayerNames(journalSuggestions); + suggestions.addAll(filterStartingWith(journalSuggestions, args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("gui")) { + suggestions.addAll(filterStartingWith(onlinePlayerNames(), args[2])); + } + } else if (args[0].equalsIgnoreCase("milestones")) { + if (args.length == 2) { + List milestoneSuggestions = new ArrayList<>(); + milestoneSuggestions.add("claim"); + milestoneSuggestions.add("test"); + addOnlinePlayerNames(milestoneSuggestions); + suggestions.addAll(filterStartingWith(milestoneSuggestions, args[1])); + } else if (args.length == 3 && (args[1].equalsIgnoreCase("claim") || args[1].equalsIgnoreCase("test"))) { + suggestions.addAll(filterStartingWith(playerMilestoneKeys(), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("test")) { + suggestions.addAll(filterStartingWith(onlinePlayerNames(), args[3])); + } + } else if (args[0].equalsIgnoreCase("community")) { + if (args.length == 2) { + suggestions.addAll(filterStartingWith(Arrays.asList("claim", "test"), args[1])); + } else if (args.length == 3 && (args[1].equalsIgnoreCase("claim") || args[1].equalsIgnoreCase("test"))) { + suggestions.addAll(filterStartingWith(communityMilestoneKeys(), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("claim")) { + suggestions.addAll(filterStartingWith(Arrays.asList("confirm"), args[3])); } } else if (args[0].equalsIgnoreCase("debug")) { if (args.length == 2) { @@ -291,9 +355,25 @@ public List onTabComplete(CommandSender sender, Command command, String private void handleGifts(CommandSender sender, String[] args) { if (args.length < 2 || args[1].equalsIgnoreCase("spawn")) { + if (!hasPermission(sender, PERMISSION_GIFTS)) { + sendNoPermission(sender); + return; + } spawnPresentsUnderTrees(); return; } + if (args[1].equalsIgnoreCase("addhand")) { + if (!hasPermission(sender, PERMISSION_ADDHAND)) { + sendNoPermission(sender); + return; + } + handleAddHand(sender, commandPath("gifts addhand")); + return; + } + if (!hasPermission(sender, PERMISSION_GIFTS)) { + sendNoPermission(sender); + return; + } if (args[1].equalsIgnoreCase("list")) { int page = parsePositiveInt(args.length >= 3 ? args[2] : null, 1); sendGiftList(sender, page); @@ -307,11 +387,246 @@ private void handleGifts(CommandSender sender, String[] args) { handleGiftRemove(sender, args); return; } + if (args[1].equalsIgnoreCase("weight")) { + handleGiftWeight(sender, args); + return; + } TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.unknown-action", "Unknown Gift Action"), TextUtils.error(args[1]))); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.usage", "Usage"), TextUtils.command(commandPath("gifts list")))); } + private void handleAddHand(CommandSender sender, String commandForPrompt) { + if (!(sender instanceof Player player)) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-only", TextUtils.error("Only players can use this command."))); + return; + } + ItemStack item = player.getInventory().getItemInMainHand(); + if (item.getType().isAir()) { + TextUtils.sendRawMessage(player, replaceToken( + LocaleManager.text("command.hold-item-first", TextUtils.error("Hold an item before running {command}.")), + "command", + commandForPrompt + )); + return; + } + plugin.addGiftItem(item.clone()); + TextUtils.sendRawMessage(player, LocaleManager.text("command.gift-added", TextUtils.success("Added the held item to the gift list."))); + } + + private void handleJournal(CommandSender sender, String[] args) { + boolean gui = args.length >= 2 && args[1].equalsIgnoreCase("gui"); + String targetName = gui + ? (args.length >= 3 ? args[2] : null) + : (args.length >= 2 ? args[1] : null); + OfflinePlayer target = resolvePlayerTarget(sender, targetName); + if (target == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-required", TextUtils.error("Player name required."))); + return; + } + if (!canViewTarget(sender, target)) { + sendNoPermission(sender); + return; + } + if (gui) { + if (!(sender instanceof Player viewer)) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-only", TextUtils.error("Only players can use this command."))); + return; + } + openJournalGui(viewer, target); + return; + } + sendJournal(sender, target); + } + + private void handleMilestones(CommandSender sender, String[] args) { + if (args.length >= 2 && args[1].equalsIgnoreCase("test")) { + if (!hasPermission(sender, PERMISSION_TEST)) { + sendNoPermission(sender); + return; + } + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.usage", "Usage"), TextUtils.command(commandPath("milestones test [player]")))); + return; + } + OfflinePlayer target = resolvePlayerTarget(sender, args.length >= 4 ? args[3] : null); + if (target == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-required", TextUtils.error("Player name required."))); + return; + } + sendRewardPreview(sender, plugin.getEventProgress().previewPlayerMilestone(target, args[2], false), + commandPath("milestones claim " + args[2])); + return; + } + if (args.length >= 2 && args[1].equalsIgnoreCase("claim")) { + if (!hasPermission(sender, PERMISSION_MILESTONES_CLAIM)) { + sendNoPermission(sender); + return; + } + if (!(sender instanceof Player player)) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-only", TextUtils.error("Only players can use this command."))); + return; + } + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.usage", "Usage"), TextUtils.command(commandPath("milestones claim ")))); + return; + } + EventProgress.ClaimResult result = plugin.getEventProgress().claimPlayerMilestone(player, args[2]); + TextUtils.sendRawMessage(sender, result.success() ? TextUtils.success(result.message()) : TextUtils.error(result.message())); + return; + } + OfflinePlayer target = resolvePlayerTarget(sender, args.length >= 2 ? args[1] : null); + if (target == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-required", TextUtils.error("Player name required."))); + return; + } + if (!canViewTarget(sender, target)) { + sendNoPermission(sender); + return; + } + sendMilestones(sender, target); + } + + private void handleCommunity(CommandSender sender, String[] args) { + if (args.length >= 2 && args[1].equalsIgnoreCase("test")) { + if (!hasPermission(sender, PERMISSION_TEST)) { + sendNoPermission(sender); + return; + } + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.community.labels.usage", "Usage"), TextUtils.command(commandPath("community test ")))); + return; + } + sendRewardPreview(sender, plugin.getEventProgress().previewCommunityMilestone(sender, args[2], false), + commandPath("community claim " + args[2] + " confirm")); + return; + } + if (args.length >= 2 && args[1].equalsIgnoreCase("claim")) { + if (!hasPermission(sender, PERMISSION_COMMUNITY_CLAIM)) { + sendNoPermission(sender); + return; + } + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.community.labels.usage", "Usage"), TextUtils.command(commandPath("community claim confirm")))); + return; + } + if (args.length < 4 || !args[3].equalsIgnoreCase("confirm")) { + EventProgress.RewardPreview preview = plugin.getEventProgress().previewCommunityMilestone(sender, args[2], true); + sendRewardPreview(sender, preview, commandPath("community claim " + args[2] + " confirm")); + if (preview.success()) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.community.confirm-required", TextUtils.warning("Run the confirm command to claim this community milestone."))); + } + return; + } + EventProgress.ClaimResult result = plugin.getEventProgress().claimCommunityMilestone(sender, args[2]); + TextUtils.sendRawMessage(sender, result.success() ? TextUtils.success(result.message()) : TextUtils.error(result.message())); + return; + } + sendCommunity(sender); + } + + private void sendJournal(CommandSender sender, OfflinePlayer target) { + EventProgress.PlayerStats stats = plugin.getEventProgress().playerStats(target); + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.journal.title", TextUtils.title("Tree Journal"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.player", "Player"), TextUtils.text(stats.name()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.trees", "Trees"), TextUtils.text(Integer.toString(countOwnerTrees(target.getUniqueId()))))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.highest-tree", "Highest Tree"), TextUtils.text(highestTreeLevelName(target.getUniqueId())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.gifts-today", "Gifts Today"), TextUtils.text(Integer.toString(stats.giftsToday())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.total-gifts", "Total Gifts"), TextUtils.text(Integer.toString(stats.totalGifts())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.streak", "Current Streak"), TextUtils.text(stats.currentStreak() + " days"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.best-streak", "Best Streak"), TextUtils.text(stats.bestStreak() + " days"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.claimable", "Claimable Milestones"), TextUtils.text(Integer.toString(stats.claimableMilestones())))); + if (sender instanceof Player) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.journal.labels.gui", "GUI"), TextUtils.command(commandPath("journal gui")))); + } + sendHistoryList(sender, + LocaleManager.text("ui.journal.labels.tree-history", "Tree History"), + plugin.getEventProgress().playerTreeHistory(target, 5)); + sendHistoryList(sender, + LocaleManager.text("ui.journal.labels.milestone-history", "Claimed Milestones"), + plugin.getEventProgress().playerMilestoneHistory(target, 5)); + } + + private void sendMilestones(CommandSender sender, OfflinePlayer target) { + UUID uuid = target.getUniqueId(); + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.milestones.title", TextUtils.title("Milestones"))); + for (EventProgress.Milestone milestone : plugin.getEventProgress().playerMilestones()) { + int progress = plugin.getEventProgress().progressValue(uuid, milestone); + boolean claimed = plugin.getEventProgress().isPlayerMilestoneClaimed(uuid, milestone.key()); + boolean eligible = plugin.getEventProgress().isPlayerMilestoneEligible(uuid, milestone); + TextUtils.sendRawMessage(sender, formatStyledListEntry(milestone.key(), milestoneLine(milestone, progress, claimed, eligible))); + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.claim", "Claim"), TextUtils.command(commandPath("milestones claim ")))); + } + + private void sendCommunity(CommandSender sender) { + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.community.title", TextUtils.title("Community Milestones"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.community.labels.gifts-opened", "Gifts Opened"), TextUtils.text(Integer.toString(plugin.getEventProgress().getCommunityGiftClaims())))); + for (EventProgress.Milestone milestone : plugin.getEventProgress().communityMilestones()) { + int progress = plugin.getEventProgress().getCommunityGiftClaims(); + boolean claimed = plugin.getEventProgress().isCommunityMilestoneClaimed(milestone.key()); + boolean eligible = plugin.getEventProgress().isCommunityMilestoneEligible(milestone); + TextUtils.sendRawMessage(sender, formatStyledListEntry(milestone.key(), milestoneLine(milestone, progress, claimed, eligible))); + } + if (hasPermission(sender, PERMISSION_COMMUNITY_CLAIM)) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.community.labels.claim", "Claim"), TextUtils.command(commandPath("community claim confirm")))); + } + } + + private String milestoneLine(EventProgress.Milestone milestone, int progress, boolean claimed, boolean eligible) { + String state; + if (claimed) { + state = TextUtils.success(LocaleManager.text("ui.milestones.values.claimed", "claimed")); + } else if (eligible) { + state = TextUtils.warning(LocaleManager.text("ui.milestones.values.ready", "ready")); + } else { + state = TextUtils.text(Math.min(progress, milestone.target()) + " / " + milestone.target()); + } + return TextUtils.text(milestone.displayName()) + TextUtils.muted(" - ") + state; + } + + private void openJournalGui(Player viewer, OfflinePlayer target) { + EventProgress.PlayerStats stats = plugin.getEventProgress().playerStats(target); + Inventory inventory = Bukkit.createInventory( + new JournalInventoryHolder(target.getUniqueId()), + 27, + TextUtils.parse(LocaleManager.text("ui.journal.gui.title", "Tree Journal")) + ); + inventory.setItem(10, journalItem(Material.SPRUCE_SAPLING, LocaleManager.text("ui.journal.gui.trees", "Trees"), + List.of( + formatPlain(LocaleManager.text("ui.journal.labels.trees", "Trees"), countOwnerTrees(target.getUniqueId())), + formatPlain(LocaleManager.text("ui.journal.labels.highest-tree", "Highest Tree"), highestTreeLevelName(target.getUniqueId())) + ))); + inventory.setItem(12, journalItem(Material.CHEST, LocaleManager.text("ui.journal.gui.gifts", "Gifts"), + List.of( + formatPlain(LocaleManager.text("ui.journal.labels.gifts-today", "Gifts Today"), stats.giftsToday()), + formatPlain(LocaleManager.text("ui.journal.labels.total-gifts", "Total Gifts"), stats.totalGifts()) + ))); + inventory.setItem(14, journalItem(Material.GOLD_INGOT, LocaleManager.text("ui.journal.gui.streak", "Streak"), + List.of( + formatPlain(LocaleManager.text("ui.journal.labels.streak", "Current Streak"), stats.currentStreak() + " days"), + formatPlain(LocaleManager.text("ui.journal.labels.best-streak", "Best Streak"), stats.bestStreak() + " days") + ))); + inventory.setItem(16, journalItem(Material.NETHER_STAR, LocaleManager.text("ui.journal.gui.milestones", "Milestones"), + List.of(formatPlain(LocaleManager.text("ui.journal.labels.claimable", "Claimable Milestones"), stats.claimableMilestones())))); + viewer.openInventory(inventory); + } + + private ItemStack journalItem(Material material, String name, List lore) { + ItemStack item = new ItemStack(material); + ItemMeta meta = item.getItemMeta(); + if (meta != null) { + meta.displayName(TextUtils.parse(TextUtils.title(name))); + List formattedLore = new ArrayList<>(); + for (String line : lore) { + formattedLore.add(TextUtils.text(line)); + } + meta.lore(TextUtils.parseList(formattedLore)); + item.setItemMeta(meta); + } + return item; + } + private void spawnPresentsUnderTrees() { Random random = new Random(); for (MagicTree magicTree : XMas.getAllTrees()) { @@ -323,21 +638,23 @@ private void spawnPresentsUnderTrees() { } private void sendGiftList(CommandSender sender, int page) { - List giftItems = plugin.getGiftItems(); + List giftItems = plugin.getGiftEntries(); int pageSize = 8; int pageCount = Math.max(1, (int) Math.ceil(giftItems.size() / (double) pageSize)); page = Math.max(1, Math.min(page, pageCount)); int start = (page - 1) * pageSize; int end = Math.min(giftItems.size(), start + pageSize); + int totalWeight = Main.getGiftTotalWeight(); TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.title", TextUtils.title("Gift Pool")) + " " + TextUtils.muted("(" + page + "/" + pageCount + ")")); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.total", "Total"), TextUtils.text(Integer.toString(giftItems.size())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.total-weight", "Total Weight"), TextUtils.text(Integer.toString(totalWeight)))); if (giftItems.isEmpty()) { TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.values.empty", TextUtils.warning("No gifts are configured."))); return; } for (int index = start; index < end; index++) { - TextUtils.sendRawMessage(sender, formatStyledListEntry(Integer.toString(index + 1), formatGiftItem(giftItems.get(index)))); + TextUtils.sendRawMessage(sender, formatStyledListEntry(Integer.toString(index + 1), formatGiftEntry(giftItems.get(index), totalWeight))); } if (page < pageCount) { TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.next", "Next"), TextUtils.command(commandPath("gifts list " + (page + 1))))); @@ -351,6 +668,7 @@ private void sendGiftRoll(CommandSender sender) { return; } TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.rolled", "Rolled"), formatGiftItem(gift))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.total-weight", "Total Weight"), TextUtils.text(Integer.toString(Main.getGiftTotalWeight())))); } private void handleGiftRemove(CommandSender sender, String[] args) { @@ -372,10 +690,35 @@ private void handleGiftRemove(CommandSender sender, String[] args) { TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.removed", "Removed"), formatGiftItem(removed))); } + private void handleGiftWeight(CommandSender sender, String[] args) { + if (args.length < 4) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.usage", "Usage"), TextUtils.command(commandPath("gifts weight ")))); + return; + } + int index = parsePositiveInt(args[2], -1); + int weight = parsePositiveInt(args[3], -1); + if (index < 1) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.index", "Index"), TextUtils.error(args[2]))); + return; + } + if (weight < 1) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.weight", "Weight"), TextUtils.error(args[3]))); + return; + } + Main.GiftEntry updated = plugin.updateGiftWeight(index, weight); + if (updated == null) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.index", "Index"), TextUtils.error(Integer.toString(index)))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.try", "Try"), TextUtils.command(commandPath("gifts list")))); + return; + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.updated", "Updated"), formatGiftEntry(updated, Main.getGiftTotalWeight()))); + } + private void handleTest(CommandSender sender, String[] args) { if (args.length < 2) { TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test sound first|repeat [player]")))); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test particle [all|ambient|swag|body] [player]")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test refund [tree-uuid|nearest [player]]")))); return; } @@ -387,9 +730,24 @@ private void handleTest(CommandSender sender, String[] args) { handleTestParticle(sender, args); return; } + if (args[1].equalsIgnoreCase("refund")) { + handleTestRefund(sender, args); + return; + } TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.unknown-test", "Unknown Test"), TextUtils.error(args[1]))); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.try", "Try"), TextUtils.command(commandPath("test sound first")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.try", "Try"), TextUtils.command(commandPath("test refund nearest")))); + } + + private void handleTestRefund(CommandSender sender, String[] args) { + MagicTree tree = resolveTreeForTestRefund(sender, args); + if (tree == null) { + return; + } + for (String line : getRefundTestLines(tree)) { + TextUtils.sendRawMessage(sender, line); + } } private void handleTestSound(CommandSender sender, String[] args) { @@ -461,19 +819,25 @@ private void handleTestParticle(CommandSender sender, String[] args) { private void handleData(CommandSender sender, String[] args) { if (args.length < 2) { TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data backup")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data backup progress")))); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data validate")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data report")))); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data migrate-world [dry-run|apply]")))); return; } if (args[1].equalsIgnoreCase("backup")) { - handleDataBackup(sender); + handleDataBackup(sender, args.length >= 3 ? args[2] : "trees"); return; } if (args[1].equalsIgnoreCase("validate")) { handleDataValidate(sender); return; } + if (args[1].equalsIgnoreCase("report")) { + handleDataReport(sender); + return; + } if (args[1].equalsIgnoreCase("migrate-world")) { handleDataMigrateWorld(sender, args); return; @@ -483,17 +847,74 @@ private void handleData(CommandSender sender, String[] args) { TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.try", "Try"), TextUtils.command(commandPath("data validate")))); } - private void handleDataBackup(CommandSender sender) { + private void handleDataReport(CommandSender sender) { + TreeSerializer.TreeSummary treeSummary = TreeSerializer.summarizeTreesFile(); + EventProgress.EventSummary eventSummary = plugin.getEventProgress().eventSummary(); + + TextUtils.sendRawMessage(sender, formatSectionTitle(LocaleManager.text("ui.data.report-title", "Event Wrap-Up Report"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.stored-trees", "Stored Trees"), TextUtils.text(Integer.toString(treeSummary.storedTreeCount())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.loaded-trees", "Loaded Trees"), TextUtils.text(Integer.toString(treeSummary.loadedTreeCount())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.tree-owners", "Tree Owners"), TextUtils.text(Integer.toString(treeSummary.owners().size())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.level-counts", "Level Counts"), TextUtils.text(formatLevelCounts(treeSummary.levelCounts())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.gifts-opened", "Gifts Opened"), TextUtils.text(Integer.toString(eventSummary.communityGiftClaims())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.players-with-gifts", "Players With Gifts"), TextUtils.text(Integer.toString(eventSummary.playersWithGifts())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.claimed-player-milestones", "Claimed Player Milestones"), TextUtils.text(Integer.toString(eventSummary.claimedPlayerMilestones())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.claimed-community-milestones", "Claimed Community Milestones"), TextUtils.text(Integer.toString(eventSummary.claimedCommunityMilestones())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.legacy-trees-preserved", "Legacy Trees Preserved"), TextUtils.text(Integer.toString(treeSummary.storedTreeCount())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.returning-player-reward-list", "Returning-Player Reward List"), TextUtils.text(formatReturningPlayers(treeSummary.owners(), 12)))); + sendHistoryList(sender, + LocaleManager.text("ui.data.labels.community-milestone-history", "Community Milestone History"), + plugin.getEventProgress().communityMilestoneHistory(6)); + } + + private void handleDataBackup(CommandSender sender, String mode) { + if (!mode.equalsIgnoreCase("trees") && !mode.equalsIgnoreCase("progress") && !mode.equalsIgnoreCase("all")) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.mode", "Mode"), TextUtils.error(mode))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data backup [trees|progress|all]")))); + return; + } try { - File backupFile = TreeSerializer.backupTreesFile(); - TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-created", TextUtils.success("Tree data backup created."))); + if (mode.equalsIgnoreCase("all")) { + File treesBackup = TreeSerializer.backupTreesFile(); + File progressBackup = plugin.getEventProgress().backupProgressFile(); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-created", TextUtils.success("Data backup created."))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.file", "File"), TextUtils.text(treesBackup.getPath()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.file", "File"), TextUtils.text(progressBackup.getPath()))); + return; + } + File backupFile = mode.equalsIgnoreCase("progress") + ? plugin.getEventProgress().backupProgressFile() + : TreeSerializer.backupTreesFile(); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-created", TextUtils.success("Data backup created."))); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.file", "File"), TextUtils.text(backupFile.getPath()))); } catch (IOException exception) { - TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-failed", TextUtils.error("Unable to back up trees.yml."))); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-failed", TextUtils.error("Unable to back up data."))); TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.error", "Error"), TextUtils.error(exception.getMessage()))); } } + private void sendRewardPreview(CommandSender sender, EventProgress.RewardPreview preview, String claimCommand) { + if (!preview.success()) { + TextUtils.sendRawMessage(sender, TextUtils.error(preview.message())); + return; + } + TextUtils.sendRawMessage(sender, formatSectionTitle(LocaleManager.text("ui.milestones.reward-preview-title", "Milestone Reward Preview"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.milestone", "Milestone"), TextUtils.command(preview.milestone().key()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.display-name", "Display Name"), TextUtils.text(preview.milestone().displayName()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.progress", "Progress"), TextUtils.text(preview.progress() + " / " + preview.milestone().target()))); + if (preview.commands().isEmpty()) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.commands", "Commands"), TextUtils.warning(LocaleManager.text("ui.milestones.values.no-commands", "none configured")))); + } else { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.commands", "Commands"), TextUtils.text(Integer.toString(preview.commands().size())))); + int index = 1; + for (String rewardCommand : preview.commands()) { + TextUtils.sendRawMessage(sender, formatStyledListEntry(Integer.toString(index), TextUtils.command(rewardCommand))); + index++; + } + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.milestones.labels.claim", "Claim"), TextUtils.command(claimCommand))); + } + private void handleDataValidate(CommandSender sender) { TreeSerializer.TreeDataValidationReport report = TreeSerializer.validateTreesFile(); TextUtils.sendRawMessage(sender, formatSectionTitle(LocaleManager.text("ui.data.title", "Tree Data Validation"))); @@ -556,7 +977,7 @@ private void sendValidationSet(CommandSender sender, String labelKey, Set values, int limit) { + private String joinLimited(Collection values, int limit) { List limited = new ArrayList<>(); int index = 0; for (String value : values) { @@ -572,6 +993,41 @@ private String joinLimited(Set values, int limit) { return String.join(", ", limited); } + private void sendHistoryList(CommandSender sender, String title, List history) { + TextUtils.sendRawMessage(sender, formatSectionTitle(title)); + if (history == null || history.isEmpty()) { + TextUtils.sendRawMessage(sender, TextUtils.muted(LocaleManager.text("ui.journal.values.no-history", "No history recorded yet."))); + return; + } + for (String entry : history) { + TextUtils.sendRawMessage(sender, TextUtils.text(entry)); + } + } + + private String formatLevelCounts(Map levelCounts) { + if (levelCounts == null || levelCounts.isEmpty()) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + List parts = new ArrayList<>(); + for (Map.Entry entry : levelCounts.entrySet()) { + parts.add(entry.getKey() + "=" + entry.getValue()); + } + return String.join(", ", parts); + } + + private String formatReturningPlayers(Set owners, int limit) { + if (owners == null || owners.isEmpty()) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + List names = new ArrayList<>(); + for (UUID owner : owners) { + OfflinePlayer player = Bukkit.getOfflinePlayer(owner); + names.add(player.getName() != null ? player.getName() : owner.toString()); + } + names.sort(String.CASE_INSENSITIVE_ORDER); + return joinLimited(names, limit); + } + private int parsePositiveInt(String rawValue, int fallback) { if (rawValue == null || rawValue.isBlank()) { return fallback; @@ -632,7 +1088,69 @@ private TreeLevel treeLevelByName(String levelName) { } private List treeLevelNames() { - return Arrays.asList("sapling", "small_tree", "tree", "magic_tree"); + return Arrays.asList("sapling", "small_tree", "tree", "magic_tree", "super_tree"); + } + + private MagicTree resolveTreeForTestRefund(CommandSender sender, String[] args) { + if (args.length >= 3 && args[2].equalsIgnoreCase("nearest")) { + Player target; + if (args.length >= 4) { + target = Bukkit.getPlayer(args[3]); + if (target == null) { + TextUtils.sendMessage(sender, LocaleManager.COMMAND_PLAYER_OFFLINE); + return null; + } + } else if (sender instanceof Player player) { + target = player; + } else { + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.test.labels.usage", "Usage"), + TextUtils.command(commandPath("test refund nearest ")) + )); + return null; + } + MagicTree tree = findNearestTree(target.getLocation(), 16); + if (tree == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.inspect.not-found", TextUtils.warning("No XMas Tree found nearby or in your line of sight."))); + } + return tree; + } + + if (args.length >= 3) { + MagicTree tree = findTreeByUuid(args[2]); + if (tree == null) { + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.requested", "Requested"), + TextUtils.error(args[2]) + )); + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.try", "Try"), + TextUtils.command(commandPath("test refund nearest")) + )); + } + return tree; + } + + if (sender instanceof Player player) { + MagicTree tree = findTargetTree(player); + if (tree == null) { + tree = findNearestTree(player.getLocation(), 8); + } + if (tree == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.inspect.not-found", TextUtils.warning("No XMas Tree found nearby or in your line of sight."))); + } + return tree; + } + + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.test.labels.usage", "Usage"), + TextUtils.command(commandPath("test refund ")) + )); + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.test.labels.usage", "Usage"), + TextUtils.command(commandPath("test refund nearest ")) + )); + return null; } private void handleInspect(CommandSender sender, String[] args) { @@ -722,28 +1240,92 @@ private List getInspectLines(MagicTree tree) { lines.add(LocaleManager.text("ui.inspect.title", TextUtils.title(TextUtils.displayName() + " Tree Inspect"))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.owner", "Owner"), TextUtils.text(ownerName) + TextUtils.muted(" (" + tree.getOwner() + ")"))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.tree-id", "Tree ID"), TextUtils.text(tree.getTreeUID().toString()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.owner-online", "Owner Online"), TextUtils.booleanValue(owner.isOnline()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.owner-loaded-trees", "Owner Loaded Trees"), TextUtils.text(Integer.toString(countOwnerTrees(tree.getOwner()))))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.level", "Level"), TextUtils.text(tree.getLevel().getLevelName()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.next-level", "Next Level"), TextUtils.text(formatNextLevel(tree)))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.location", "Location"), TextUtils.text(formatLocation(location)))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.world-loaded", "World Loaded"), TextUtils.booleanValue(location != null && location.getWorld() != null))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.chunk-loaded", "Chunk Loaded"), TextUtils.booleanValue(isChunkLoaded(location)))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.tree-built", "Tree Built"), TextUtils.booleanValue(tree.isBuilt()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.built-blocks", "Built Blocks"), TextUtils.text(Integer.toString(tree.getBuiltBlockCount())))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.can-level-up", "Can Level Up"), TextUtils.booleanValue(tree.canLevelUp()))); - lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.present-timer", "Present Timer"), TextUtils.text(Long.toString(tree.getPresentCounter())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.next-space-clear", "Next Space Clear"), TextUtils.booleanValue(canNextLevelFit(tree)))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.gift-cooldown", "Gift Cooldown"), TextUtils.text(formatTicks(tree.getLevel().getGiftDelay())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.present-timer", "Present Timer"), TextUtils.text(formatTicks(tree.getPresentCounter())))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.scheduled-presents", "Scheduled Presents"), TextUtils.text(Integer.toString(tree.getScheduledPresents())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.nearby-presents", "Nearby Presents"), TextUtils.text(Integer.toString(tree.getNearbyPresentCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.requirement-progress", "Requirement Progress"), TextUtils.text(formatRequirementProgress(tree)))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.remaining", "Remaining Requirements"), TextUtils.text(formatRequirements(tree.getLevelupRequirements())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.spent-resources", "Spent Resources"), TextUtils.text(formatSpentResources(tree)))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.resource-back", "Resource Back"), TextUtils.booleanValue(Main.resourceBack))); lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.refund-preview", "Refund Preview"), TextUtils.text(formatItems(tree.getRefundPreviewItems())))); return lines; } + private List getRefundTestLines(MagicTree tree) { + List lines = new ArrayList<>(); + OfflinePlayer owner = Bukkit.getOfflinePlayer(tree.getOwner()); + Location location = tree.getLocation(); + String ownerName = owner.getName() != null ? owner.getName() : LocaleManager.text("ui.inspect.values.unknown-owner", "unknown"); + + lines.add(formatSectionTitle(LocaleManager.text("ui.test.refund-title", "Refund Delivery Dry Run"))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.tree-id", "Tree ID"), TextUtils.text(tree.getTreeUID().toString()))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.owner", "Owner"), TextUtils.text(ownerName) + TextUtils.muted(" (" + tree.getOwner() + ")"))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.location", "Location"), TextUtils.text(formatLocation(location)))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.resource-back", "Resource Back"), TextUtils.booleanValue(Main.resourceBack))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.refund-items", "Refund Items"), TextUtils.text(formatItems(tree.getRefundPreviewItems())))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.delivery-order", "Delivery Order"), TextUtils.text(LocaleManager.text("ui.test.values.delivery-order", "chest -> barrel -> player inventory -> floor drops")))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.mode", "Mode"), TextUtils.warning(LocaleManager.text("ui.test.values.dry-run", "dry-run")))); + lines.add(formatKeyValue(LocaleManager.text("ui.test.labels.no-changes", "No Changes"), TextUtils.info(LocaleManager.text("ui.test.values.safe-preview", "This command only previews refund delivery. It does not edit blocks, inventories, tree data, or config.")))); + return lines; + } + private List getReloadSummaryLines(Main.ReloadSummary summary) { List lines = new ArrayList<>(); lines.add(formatSectionTitle(LocaleManager.text("ui.reload.title", "Reload Summary"))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.locale", "Locale"), TextUtils.text(summary.locale()))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.gifts", "Gifts"), TextUtils.text(Integer.toString(summary.giftCount())))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.present-heads", "Present Heads"), TextUtils.text(Integer.toString(summary.presentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.configured-present-heads", "Configured Present Heads"), TextUtils.text(Integer.toString(summary.configuredPresentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.texture-heads", "Texture Heads"), TextUtils.text(Integer.toString(summary.texturePresentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.legacy-player-heads", "Legacy Player Heads"), warningCount(summary.legacyPlayerHeadCount()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.invalid-present-heads", "Invalid Present Heads"), warningCount(summary.invalidPresentHeadCount()))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.trees", "Trees"), TextUtils.text(Integer.toString(summary.treeCount())))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.owners", "Owners"), TextUtils.text(Integer.toString(summary.treeOwnerCount())))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.particles", "Particles"), TextUtils.booleanValue(summary.particlesEnabled()))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.resource-back", "Resource Back"), TextUtils.booleanValue(summary.resourceBack()))); - lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.legacy-alias", "Legacy Alias"), TextUtils.booleanValue(summary.legacyAliasEnabled()))); lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.sounds", "Sounds"), TextUtils.text("first=" + summary.growFirstSoundVolume() + ", repeat=" + summary.growRepeatSoundVolume()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.invalid-gifts", "Invalid Gifts"), warningCount(summary.invalidGiftCount()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.tree-data-warnings", "Tree Data Warnings"), warningCount(summary.treeDataWarningCount()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.warnings", "Warnings"), warningCount(summary.configWarningCount()))); + for (String warning : summary.warnings()) { + lines.add(formatStyledListEntry(LocaleManager.text("ui.reload.labels.warning", "Warning"), TextUtils.warning(warning))); + } + return lines; + } + + private List getDiagnosticsLines(Main.ConfigDiagnostics diagnostics) { + List lines = new ArrayList<>(); + lines.add(""); + lines.add(formatSectionTitle(debugSectionDisplayName("diagnostics"))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.configured-present-heads", "Configured Present Heads"), TextUtils.text(Integer.toString(diagnostics.configuredPresentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.valid-present-heads", "Valid Present Heads"), TextUtils.text(Integer.toString(diagnostics.validPresentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.texture-heads", "Texture URL Heads"), TextUtils.text(Integer.toString(diagnostics.texturePresentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.legacy-player-heads", "Legacy Player Heads"), warningCount(diagnostics.legacyPlayerHeadCount()))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.invalid-present-heads", "Invalid Present Heads"), warningCount(diagnostics.invalidPresentHeadCount()))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.configured-gifts", "Configured Gifts"), TextUtils.text(Integer.toString(diagnostics.configuredGiftCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.valid-gifts", "Valid Gifts"), TextUtils.text(Integer.toString(diagnostics.validGiftCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.invalid-gifts", "Invalid Gifts"), warningCount(diagnostics.invalidGiftCount()))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.tree-data-warnings", "Tree Data Warnings"), warningCount(diagnostics.treeDataWarningCount()))); + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.warning-count", "Warning Count"), warningCount(diagnostics.configWarningCount()))); + if (diagnostics.warnings().isEmpty()) { + lines.add(formatKeyValue(LocaleManager.text("ui.diagnostics.labels.status", "Status"), LocaleManager.text("ui.data.values.clean", TextUtils.success("clean")))); + } else { + for (String warning : diagnostics.warnings()) { + lines.add(formatStyledListEntry(LocaleManager.text("ui.diagnostics.labels.warning", "Warning"), TextUtils.warning(warning))); + } + } return lines; } @@ -861,26 +1443,36 @@ private LinkedHashMap> buildDebugSections() { commandsPage.add(formatSectionTitle(debugSectionDisplayName("commands"))); commandsPage.add(formatListEntry(commandPath(""), LocaleManager.text("ui.command-descriptions.status", "status"))); commandsPage.add(formatListEntry(commandPath("help"), LocaleManager.text("ui.command-descriptions.help", "command list"))); + commandsPage.add(formatListEntry(commandPath("info"), LocaleManager.text("ui.command-descriptions.info", "how the XMas Tree event works"))); commandsPage.add(formatListEntry(commandPath("give "), LocaleManager.text("ui.command-descriptions.give", "give a Christmas Crystal"))); commandsPage.add(formatListEntry(commandPath("gifts"), LocaleManager.text("ui.command-descriptions.gifts", "spawn presents under all trees"))); commandsPage.add(formatListEntry(commandPath("gifts list [page]"), LocaleManager.text("ui.command-descriptions.gifts-list", "list configured gifts"))); - commandsPage.add(formatListEntry(commandPath("gifts roll"), LocaleManager.text("ui.command-descriptions.gifts-roll", "preview a random gift roll"))); + commandsPage.add(formatListEntry(commandPath("gifts roll"), LocaleManager.text("ui.command-descriptions.gifts-roll", "preview a weighted random gift roll"))); commandsPage.add(formatListEntry(commandPath("gifts remove "), LocaleManager.text("ui.command-descriptions.gifts-remove", "remove a configured gift"))); - commandsPage.add(formatListEntry(commandPath("addhand"), LocaleManager.text("ui.command-descriptions.addhand", "add held item to gifts"))); + commandsPage.add(formatListEntry(commandPath("gifts weight "), LocaleManager.text("ui.command-descriptions.gifts-weight", "set a gift roll weight"))); + commandsPage.add(formatListEntry(commandPath("gifts addhand"), LocaleManager.text("ui.command-descriptions.gifts-addhand", "add held item to gifts"))); + commandsPage.add(formatListEntry(commandPath("addhand"), LocaleManager.text("ui.command-descriptions.addhand", "legacy shortcut for gifts addhand"))); commandsPage.add(formatListEntry(commandPath("reload"), LocaleManager.text("ui.command-descriptions.reload", "reload config and locale"))); commandsPage.add(formatListEntry(commandPath("inspect"), LocaleManager.text("ui.command-descriptions.inspect", "inspect the tree you are looking at"))); commandsPage.add(formatListEntry(commandPath("test sound first|repeat [player]"), LocaleManager.text("ui.command-descriptions.test-sound", "preview grow sounds"))); commandsPage.add(formatListEntry(commandPath("test particle [effect] [player]"), LocaleManager.text("ui.command-descriptions.test-particle", "preview configured particles"))); - commandsPage.add(formatListEntry(commandPath("data backup"), LocaleManager.text("ui.command-descriptions.data-backup", "back up trees.yml"))); + commandsPage.add(formatListEntry(commandPath("test refund [tree-uuid|nearest [player]]"), LocaleManager.text("ui.command-descriptions.test-refund", "dry-run tree refund delivery"))); + commandsPage.add(formatListEntry(commandPath("data backup [trees|progress|all]"), LocaleManager.text("ui.command-descriptions.data-backup", "back up event data"))); commandsPage.add(formatListEntry(commandPath("data validate"), LocaleManager.text("ui.command-descriptions.data-validate", "validate trees.yml"))); + commandsPage.add(formatListEntry(commandPath("data report"), LocaleManager.text("ui.command-descriptions.data-report", "show event wrap-up report"))); commandsPage.add(formatListEntry(commandPath("data migrate-world [dry-run|apply]"), LocaleManager.text("ui.command-descriptions.data-migrate-world", "migrate saved tree world names"))); + commandsPage.add(formatListEntry(commandPath("journal [player]"), LocaleManager.text("ui.command-descriptions.journal", "view tree journal and event progress"))); + commandsPage.add(formatListEntry(commandPath("journal gui [player]"), LocaleManager.text("ui.command-descriptions.journal-gui", "open the tree journal GUI"))); + commandsPage.add(formatListEntry(commandPath("milestones [player]"), LocaleManager.text("ui.command-descriptions.milestones", "view personal milestones"))); + commandsPage.add(formatListEntry(commandPath("milestones test [player]"), LocaleManager.text("ui.command-descriptions.milestones-test", "preview personal milestone reward commands"))); + commandsPage.add(formatListEntry(commandPath("milestones claim "), LocaleManager.text("ui.command-descriptions.milestones-claim", "claim a personal milestone reward"))); + commandsPage.add(formatListEntry(commandPath("community"), LocaleManager.text("ui.command-descriptions.community", "view community milestone progress"))); + commandsPage.add(formatListEntry(commandPath("community test "), LocaleManager.text("ui.command-descriptions.community-test", "preview community milestone reward commands"))); + commandsPage.add(formatListEntry(commandPath("community claim confirm"), LocaleManager.text("ui.command-descriptions.community-claim", "claim a community milestone reward"))); commandsPage.add(formatListEntry(commandPath("end"), LocaleManager.text("ui.command-descriptions.end", "end the event"))); commandsPage.add(formatListEntry(commandPath("debug"), LocaleManager.text("ui.command-descriptions.debug", "open the status debug section"))); commandsPage.add(formatListEntry(commandPath("debug [section|page]"), LocaleManager.text("ui.command-descriptions.debug-section", "extended debug output by category"))); commandsPage.add(formatListEntry(commandPath("debug toggle true|false"), LocaleManager.text("ui.command-descriptions.debug-toggle", "toggle global booleans"))); - if (isLegacyAliasEnabled()) { - commandsPage.add(formatKeyValue(LocaleManager.text("ui.status.labels.legacy-alias", "Legacy Alias"), TextUtils.command("/" + LEGACY_COMMAND))); - } sections.put("commands", commandsPage); List permissionsPage = new ArrayList<>(); @@ -911,6 +1503,7 @@ private LinkedHashMap> buildDebugSections() { togglesPage.add(formatKeyValue(key, TextUtils.booleanValue(plugin.getConfig().getBoolean(key)))); } sections.put("config", togglesPage); + sections.put("diagnostics", getDiagnosticsLines(plugin.getConfigDiagnostics())); return sections; } @@ -925,6 +1518,7 @@ private String normalizeDebugSection(String requested) { case "permissions", "permission", "perms", "perm" -> "permissions"; case "placeholders", "placeholder", "papi" -> "placeholders"; case "config", "configuration", "cfg", "toggles" -> "config"; + case "diagnostics", "diagnostic", "diag", "warnings" -> "diagnostics"; default -> null; }; } @@ -978,18 +1572,27 @@ private void sendInvalidDebugSelection(CommandSender sender, String requested, i private List getHelpLines() { List lines = new ArrayList<>(); for (String line : LocaleManager.COMMAND_HELP) { - lines.add(line.replaceAll("/" + LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND)); + lines.add(line.replaceAll("/" + RETIRED_LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND)); } addMissingBundledHelpLines(lines); - if (isLegacyAliasEnabled()) { - lines.add(LocaleManager.text( - "command.legacy-alias-enabled", - TextUtils.muted("Legacy alias: ") + TextUtils.command("/" + LEGACY_COMMAND) + TextUtils.muted(" still works.") - )); - } return lines; } + private List getInfoLines() { + List lines = LocaleManager.textList("command.info"); + if (!lines.isEmpty()) { + return lines; + } + return Arrays.asList( + TextUtils.title(TextUtils.displayName() + " Event Info"), + TextUtils.text("Get a Christmas Crystal, place a spruce sapling, then right-click the sapling with the crystal."), + TextUtils.text("Right-click the tree with the listed ingredients to grow it through multiple levels."), + TextUtils.text("Grown trees spawn gifts below them. Right-click gifts to claim rewards."), + TextUtils.text("After the event, break the tree log to reclaim the ingredients you spent."), + TextUtils.text("Claim daily, weekly, and full-event gifts to build streaks, unlock milestones, and help community progress.") + ); + } + private String commandPath(String suffix) { if (suffix == null || suffix.isBlank()) { return "/" + PRIMARY_COMMAND; @@ -1000,7 +1603,7 @@ private String commandPath(String suffix) { private void addMissingBundledHelpLines(List lines) { List bundledLines = LocaleManager.bundledTextList("command.help"); for (String bundledLine : bundledLines) { - String normalizedLine = bundledLine.replaceAll("/" + LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND); + String normalizedLine = bundledLine.replaceAll("/" + RETIRED_LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND); String commandNeedle = findCommandNeedle(normalizedLine); if (commandNeedle != null && lines.stream().noneMatch(line -> line.contains(commandNeedle))) { lines.add(normalizedLine); @@ -1011,15 +1614,29 @@ private void addMissingBundledHelpLines(List lines) { private String findCommandNeedle(String line) { List commandNeedles = List.of( commandPath("debug toggle"), + commandPath("community claim"), + commandPath("community test"), + commandPath("community"), + commandPath("milestones claim"), + commandPath("milestones test"), + commandPath("milestones"), + commandPath("journal gui"), + commandPath("journal"), commandPath("debug status"), commandPath("debug"), + commandPath("test refund"), commandPath("test particle"), commandPath("test sound"), commandPath("data validate"), + commandPath("data report"), commandPath("data migrate-world"), + commandPath("data backup progress"), + commandPath("data backup all"), commandPath("data backup"), commandPath("gifts remove"), + commandPath("gifts weight"), commandPath("gifts roll"), + commandPath("gifts addhand"), commandPath("gifts list"), commandPath("inspect"), commandPath("reload"), @@ -1027,6 +1644,7 @@ private String findCommandNeedle(String line) { commandPath("gifts"), commandPath("give"), commandPath("help"), + commandPath("info"), commandPath("end"), commandPath("") ); @@ -1038,125 +1656,10 @@ private String findCommandNeedle(String line) { return null; } - private boolean isLegacyAliasEnabled() { - return plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true); - } - public static boolean canOverrideTree(CommandSender sender) { return hasPermission(sender, PERMISSION_TREE_OVERRIDE); } - private static void syncLegacyAlias(Main plugin, XMasCommand executor) { - CommandMap commandMap = getCommandMap(); - if (commandMap == null) { - plugin.getLogger().warning(LocaleManager.text("console.alias.command-map-unavailable", "Unable to access the Bukkit command map. Skipping legacy /xmas alias registration.")); - return; - } - if (!plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true)) { - unregisterLegacyAlias(commandMap); - return; - } - - Command existing = commandMap.getCommand(LEGACY_COMMAND); - if (existing instanceof PluginCommand existingPluginCommand) { - if (existingPluginCommand.getPlugin() == plugin) { - existingPluginCommand.setExecutor(executor); - existingPluginCommand.setTabCompleter(executor); - legacyAliasCommand = existingPluginCommand; - return; - } - plugin.getLogger().warning(LocaleManager.text("console.alias.owned-by-plugin", "Legacy alias '/{alias}' is already owned by plugin '{plugin}'. Skipping alias registration.", - "{alias}", LEGACY_COMMAND, - "{plugin}", existingPluginCommand.getPlugin().getName())); - return; - } - if (existing != null) { - plugin.getLogger().warning(LocaleManager.text("console.alias.registered-by-other", "Legacy alias '/{alias}' is already registered by another command source. Skipping alias registration.", - "{alias}", LEGACY_COMMAND)); - return; - } - - PluginCommand aliasCommand = createPluginCommand(plugin, LEGACY_COMMAND); - if (aliasCommand == null) { - plugin.getLogger().warning(LocaleManager.text("console.alias.create-failed", "Unable to create the legacy /xmas alias command.")); - return; - } - aliasCommand.setDescription("Legacy alias for /" + PRIMARY_COMMAND); - aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|inspect|test|data|debug [section|page]|end]"); - aliasCommand.setPermission(null); - aliasCommand.setExecutor(executor); - aliasCommand.setTabCompleter(executor); - commandMap.register(plugin.getPluginMeta().getName().toLowerCase(Locale.ENGLISH), aliasCommand); - legacyAliasCommand = aliasCommand; - plugin.getLogger().info(LocaleManager.text("console.alias.registered", "Registered legacy alias '/{alias}' for '/{primary}'.", - "{alias}", LEGACY_COMMAND, - "{primary}", PRIMARY_COMMAND)); - } - - private static void unregisterLegacyAlias(CommandMap commandMap) { - if (legacyAliasCommand == null) { - Command existing = commandMap.getCommand(LEGACY_COMMAND); - if (existing instanceof PluginCommand existingPluginCommand && existingPluginCommand.getPlugin() == Main.getInstance()) { - legacyAliasCommand = existingPluginCommand; - } - } - if (legacyAliasCommand == null) { - return; - } - legacyAliasCommand.unregister(commandMap); - if (commandMap instanceof SimpleCommandMap simpleCommandMap) { - try { - Field knownCommandsField = SimpleCommandMap.class.getDeclaredField("knownCommands"); - knownCommandsField.setAccessible(true); - Object rawKnownCommands = knownCommandsField.get(simpleCommandMap); - if (rawKnownCommands instanceof Map rawMap) { - List keysToRemove = new ArrayList<>(); - for (Map.Entry entry : rawMap.entrySet()) { - if (entry.getValue() == legacyAliasCommand) { - keysToRemove.add(entry.getKey()); - } - } - for (Object key : keysToRemove) { - removeKnownCommand(rawMap, key); - } - } - } catch (ReflectiveOperationException ignored) { - } - } - legacyAliasCommand = null; - } - - @SuppressWarnings({"rawtypes", "unchecked"}) - private static void removeKnownCommand(Map rawMap, Object key) { - ((Map) rawMap).remove(key); - } - - private static CommandMap getCommandMap() { - try { - Field commandMapField = Bukkit.getServer().getClass().getDeclaredField("commandMap"); - commandMapField.setAccessible(true); - Object rawCommandMap = commandMapField.get(Bukkit.getServer()); - if (rawCommandMap instanceof CommandMap commandMap) { - return commandMap; - } - } catch (ReflectiveOperationException ignored) { - } - return null; - } - - private static PluginCommand createPluginCommand(Main plugin, String name) { - try { - Constructor constructor = PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class); - constructor.setAccessible(true); - return constructor.newInstance(name, plugin); - } catch (ReflectiveOperationException e) { - plugin.getLogger().warning(LocaleManager.text("console.alias.construct-failed", "Unable to construct dynamic command '/{command}': {error}", - "{command}", name, - "{error}", e.getMessage())); - return null; - } - } - private List filterStartingWith(List values, String typed) { String lower = typed.toLowerCase(Locale.ENGLISH); List matches = new ArrayList<>(); @@ -1171,14 +1674,18 @@ private List filterStartingWith(List values, String typed) { private boolean canUseSubCommand(CommandSender sender, String subCommand) { return switch (subCommand.toLowerCase(Locale.ENGLISH)) { case "help" -> hasPermission(sender, PERMISSION_HELP); + case "info" -> hasPermission(sender, PERMISSION_INFO); case "give" -> hasPermission(sender, PERMISSION_GIVE); case "end" -> hasPermission(sender, PERMISSION_END); - case "gifts" -> hasPermission(sender, PERMISSION_GIFTS); + case "gifts" -> hasPermission(sender, PERMISSION_GIFTS) || hasPermission(sender, PERMISSION_ADDHAND); case "reload" -> hasPermission(sender, PERMISSION_RELOAD); case "addhand" -> hasPermission(sender, PERMISSION_ADDHAND); case "inspect" -> hasPermission(sender, PERMISSION_INSPECT); case "test" -> hasPermission(sender, PERMISSION_TEST); case "data" -> hasPermission(sender, PERMISSION_DATA); + case "journal" -> hasPermission(sender, PERMISSION_JOURNAL); + case "milestones" -> hasPermission(sender, PERMISSION_MILESTONES); + case "community" -> hasPermission(sender, PERMISSION_COMMUNITY); case "debug" -> hasPermission(sender, PERMISSION_DEBUG) || hasPermission(sender, PERMISSION_DEBUG_TOGGLE); default -> false; }; @@ -1213,13 +1720,19 @@ private static Map createPermissionDescriptions() { permissions.put(PERMISSION_ADMIN, LocaleManager.text("ui.permission-descriptions.admin", "allows all {plugin_name} commands and overrides")); permissions.put(PERMISSION_STATUS, LocaleManager.text("ui.permission-descriptions.status", "shows /xmastree status output")); permissions.put(PERMISSION_HELP, LocaleManager.text("ui.permission-descriptions.help", "shows /xmastree help output")); + permissions.put(PERMISSION_INFO, LocaleManager.text("ui.permission-descriptions.info", "shows /xmastree info output")); permissions.put(PERMISSION_GIVE, LocaleManager.text("ui.permission-descriptions.give", "allows /xmastree give")); permissions.put(PERMISSION_GIFTS, LocaleManager.text("ui.permission-descriptions.gifts", "allows /xmastree gifts")); - permissions.put(PERMISSION_ADDHAND, LocaleManager.text("ui.permission-descriptions.addhand", "allows /xmastree addhand")); + permissions.put(PERMISSION_ADDHAND, LocaleManager.text("ui.permission-descriptions.addhand", "allows /xmastree gifts addhand and /xmastree addhand")); permissions.put(PERMISSION_RELOAD, LocaleManager.text("ui.permission-descriptions.reload", "allows /xmastree reload")); permissions.put(PERMISSION_INSPECT, LocaleManager.text("ui.permission-descriptions.inspect", "allows /xmastree inspect")); permissions.put(PERMISSION_TEST, LocaleManager.text("ui.permission-descriptions.test", "allows /xmastree test")); permissions.put(PERMISSION_DATA, LocaleManager.text("ui.permission-descriptions.data", "allows /xmastree data")); + permissions.put(PERMISSION_JOURNAL, LocaleManager.text("ui.permission-descriptions.journal", "allows /xmastree journal")); + permissions.put(PERMISSION_MILESTONES, LocaleManager.text("ui.permission-descriptions.milestones", "allows /xmastree milestones")); + permissions.put(PERMISSION_MILESTONES_CLAIM, LocaleManager.text("ui.permission-descriptions.milestones-claim", "allows /xmastree milestones claim")); + permissions.put(PERMISSION_COMMUNITY, LocaleManager.text("ui.permission-descriptions.community", "allows /xmastree community")); + permissions.put(PERMISSION_COMMUNITY_CLAIM, LocaleManager.text("ui.permission-descriptions.community-claim", "allows /xmastree community claim")); permissions.put(PERMISSION_DEBUG, LocaleManager.text("ui.permission-descriptions.debug", "allows /xmastree debug")); permissions.put(PERMISSION_DEBUG_TOGGLE, LocaleManager.text("ui.permission-descriptions.debug-toggle", "allows /xmastree debug toggle")); permissions.put(PERMISSION_END, LocaleManager.text("ui.permission-descriptions.end", "allows /xmastree end")); @@ -1282,6 +1795,201 @@ private String formatGiftItem(ItemStack item) { return TextUtils.text(name + amount) + TextUtils.muted(" (" + item.getType().name() + ")"); } + private String formatGiftEntry(Main.GiftEntry gift, int totalWeight) { + if (gift == null) { + return TextUtils.muted(LocaleManager.text("ui.inspect.values.none", "none")); + } + return formatGiftItem(gift.item()) + + TextUtils.muted(" | ") + + TextUtils.label(LocaleManager.text("ui.gifts.labels.weight", "Weight")) + + TextUtils.muted(": ") + + TextUtils.text(Integer.toString(gift.weight())) + + TextUtils.muted(" | ") + + TextUtils.label(LocaleManager.text("ui.gifts.labels.chance", "Chance")) + + TextUtils.muted(": ") + + TextUtils.text(formatGiftChance(gift.weight(), totalWeight)); + } + + private String formatGiftChance(int weight, int totalWeight) { + if (totalWeight <= 0) { + return "0%"; + } + double chance = (weight * 100.0) / totalWeight; + return String.format(Locale.ENGLISH, "%.2f%%", chance); + } + + private List giftIndexes() { + List indexes = new ArrayList<>(); + int count = plugin.getGiftEntries().size(); + for (int index = 1; index <= count; index++) { + indexes.add(Integer.toString(index)); + } + return indexes; + } + + private List playerMilestoneKeys() { + List keys = new ArrayList<>(); + if (plugin.getEventProgress() != null) { + for (EventProgress.Milestone milestone : plugin.getEventProgress().playerMilestones()) { + keys.add(milestone.key()); + } + } + return keys; + } + + private List communityMilestoneKeys() { + List keys = new ArrayList<>(); + if (plugin.getEventProgress() != null) { + for (EventProgress.Milestone milestone : plugin.getEventProgress().communityMilestones()) { + keys.add(milestone.key()); + } + } + return keys; + } + + private List onlinePlayerNames() { + List names = new ArrayList<>(); + addOnlinePlayerNames(names); + return names; + } + + private void addOnlinePlayerNames(List names) { + for (Player player : Bukkit.getOnlinePlayers()) { + names.add(player.getName()); + } + } + + private OfflinePlayer resolvePlayerTarget(CommandSender sender, String targetName) { + if (targetName == null || targetName.isBlank()) { + if (sender instanceof Player player) { + return player; + } + return null; + } + Player online = Bukkit.getPlayerExact(targetName); + if (online != null) { + return online; + } + return Bukkit.getOfflinePlayerIfCached(targetName); + } + + private boolean canViewTarget(CommandSender sender, OfflinePlayer target) { + if (sender instanceof Player player && player.getUniqueId().equals(target.getUniqueId())) { + return true; + } + return canOverrideTree(sender); + } + + private int countOwnerTrees(UUID ownerId) { + int count = 0; + for (MagicTree tree : XMas.getAllTrees()) { + if (tree.getOwner().equals(ownerId)) { + count++; + } + } + return count; + } + + private String highestTreeLevelName(UUID ownerId) { + TreeLevel highestLevel = null; + int highestRank = 0; + for (MagicTree tree : XMas.getAllTrees()) { + if (!tree.getOwner().equals(ownerId)) { + continue; + } + int rank = treeLevelRank(tree.getLevel()); + if (rank > highestRank) { + highestRank = rank; + highestLevel = tree.getLevel(); + } + } + return highestLevel == null + ? LocaleManager.text("ui.inspect.values.none", "none") + : highestLevel.getLevelName(); + } + + private int treeLevelRank(TreeLevel level) { + if (level == null || level.getLevelName() == null) { + return 0; + } + return switch (level.getLevelName().toLowerCase(Locale.ENGLISH)) { + case "sapling" -> 1; + case "small_tree" -> 2; + case "tree" -> 3; + case "magic_tree" -> 4; + case "super_tree" -> 5; + default -> 0; + }; + } + + private String formatNextLevel(MagicTree tree) { + TreeLevel nextLevel = tree.getLevel().getNextLevel(); + if (nextLevel == null) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + return nextLevel.getLevelName(); + } + + private boolean isChunkLoaded(Location location) { + return location != null + && location.getWorld() != null + && location.getWorld().isChunkLoaded(location.getBlockX() >> 4, location.getBlockZ() >> 4); + } + + private boolean canNextLevelFit(MagicTree tree) { + TreeLevel nextLevel = tree.getLevel().getNextLevel(); + Location location = tree.getLocation(); + return nextLevel != null + && location != null + && location.getWorld() != null + && nextLevel.getStructureTemplate().canGrow(location); + } + + private String formatTicks(long updateCycles) { + if (updateCycles < 0) { + return LocaleManager.text("ui.inspect.values.disabled", "disabled"); + } + long seconds = Math.max(0, updateCycles * Main.getUpdateSpeed() / 20); + if (seconds < 60) { + return updateCycles + " cycles (" + seconds + "s)"; + } + long minutes = seconds / 60; + long remainingSeconds = seconds % 60; + return updateCycles + " cycles (" + minutes + "m " + remainingSeconds + "s)"; + } + + private String formatRequirementProgress(MagicTree tree) { + Map totalRequirements = tree.getLevel().getLevelupRequirements(); + if (totalRequirements == null || totalRequirements.isEmpty()) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + int total = 0; + int spent = 0; + for (Map.Entry entry : totalRequirements.entrySet()) { + int required = Math.max(0, entry.getValue()); + int remaining = Math.max(0, tree.getLevelupRequirements().getOrDefault(entry.getKey(), 0)); + total += required; + spent += Math.max(0, required - remaining); + } + return spent + " / " + total; + } + + private String formatSpentResources(MagicTree tree) { + Map spentResources = new LinkedHashMap<>(); + Map totalRequirements = tree.getLevel().getLevelupRequirements(); + if (totalRequirements != null) { + for (Map.Entry entry : totalRequirements.entrySet()) { + int required = Math.max(0, entry.getValue()); + int remaining = Math.max(0, tree.getLevelupRequirements().getOrDefault(entry.getKey(), 0)); + int spent = Math.max(0, required - remaining); + if (spent > 0) { + spentResources.put(entry.getKey(), spent); + } + } + } + return formatRequirements(spentResources); + } + private List getWorldNames() { List worlds = new ArrayList<>(); for (World world : Bukkit.getWorlds()) { @@ -1298,6 +2006,10 @@ private String formatMaterial(Material material) { .orElse(material.name()); } + private String formatPlain(String label, Object value) { + return label + ": " + value; + } + private static Map createDebugSections() { Map sections = new LinkedHashMap<>(); sections.put("status", "Status"); @@ -1305,9 +2017,14 @@ private static Map createDebugSections() { sections.put("permissions", "Permissions"); sections.put("placeholders", "Placeholders"); sections.put("config", "Config"); + sections.put("diagnostics", "Diagnostics"); return sections; } + private String warningCount(int value) { + return value > 0 ? TextUtils.warning(Integer.toString(value)) : TextUtils.success("0"); + } + private String debugSectionDisplayName(String sectionKey) { return LocaleManager.text("ui.debug.sections." + sectionKey, DEBUG_SECTIONS.getOrDefault(sectionKey, sectionKey)); } diff --git a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java index 86feedf..8f2d0ef 100644 --- a/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java @@ -33,6 +33,15 @@ final class XMasPlaceholders { "%onembxmastree_trees.total%", "%onembxmastree_trees.owners%", "%onembxmastree_player.trees%", + "%onembxmastree_player.highest_tree_level%", + "%onembxmastree_player.highest_tree_level_name%", + "%onembxmastree_player.highest_tree_level_display%", + "%onembxmastree_player.gifts_today%", + "%onembxmastree_player.gifts_total%", + "%onembxmastree_player.streak_current%", + "%onembxmastree_player.streak_best%", + "%onembxmastree_player.milestones_claimable%", + "%onembxmastree_community.gifts_opened%", "%onembxmastree_version%" ); private static final SimpleDateFormat DATE_FORMAT = new SimpleDateFormat("dd-MM-yyyy HH-mm-ss"); @@ -68,6 +77,15 @@ public static String resolve(Main plugin, OfflinePlayer player, String params) { case "trees_total" -> Integer.toString(XMas.getAllTrees().size()); case "trees_owners" -> Integer.toString(countOwners(XMas.getAllTrees())); case "player_trees" -> Integer.toString(countPlayerTrees(player)); + case "player_highest_tree_level" -> Integer.toString(highestPlayerTreeRank(player)); + case "player_highest_tree_level_name" -> highestPlayerTreeLevelName(player); + case "player_highest_tree_level_display" -> displayTreeLevel(highestPlayerTreeLevelName(player)); + case "player_gifts_today" -> Integer.toString(player != null && plugin.getEventProgress() != null ? plugin.getEventProgress().getGiftClaimsToday(player.getUniqueId()) : 0); + case "player_gifts_total" -> Integer.toString(player != null && plugin.getEventProgress() != null ? plugin.getEventProgress().getTotalGiftClaims(player.getUniqueId()) : 0); + case "player_streak_current" -> Integer.toString(player != null && plugin.getEventProgress() != null ? plugin.getEventProgress().getCurrentStreak(player.getUniqueId()) : 0); + case "player_streak_best" -> Integer.toString(player != null && plugin.getEventProgress() != null ? plugin.getEventProgress().getBestStreak(player.getUniqueId()) : 0); + case "player_milestones_claimable" -> Integer.toString(player != null && plugin.getEventProgress() != null ? plugin.getEventProgress().playerStats(player).claimableMilestones() : 0); + case "community_gifts_opened" -> Integer.toString(plugin.getEventProgress() != null ? plugin.getEventProgress().getCommunityGiftClaims() : 0); case "version" -> plugin.getPluginMeta().getVersion(); default -> null; }; @@ -138,6 +156,69 @@ private static int countPlayerTrees(OfflinePlayer player) { return count; } + private static int highestPlayerTreeRank(OfflinePlayer player) { + TreeLevel highestLevel = highestPlayerTreeLevel(player); + return highestLevel == null ? 0 : treeLevelRank(highestLevel); + } + + private static String highestPlayerTreeLevelName(OfflinePlayer player) { + TreeLevel highestLevel = highestPlayerTreeLevel(player); + return highestLevel == null + ? LocaleManager.text("placeholders.values.none", "none") + : highestLevel.getLevelName(); + } + + private static TreeLevel highestPlayerTreeLevel(OfflinePlayer player) { + if (player == null || player.getUniqueId() == null) { + return null; + } + TreeLevel highestLevel = null; + int highestRank = 0; + for (MagicTree tree : XMas.getAllTrees()) { + if (!player.getUniqueId().equals(tree.getOwner())) { + continue; + } + int rank = treeLevelRank(tree.getLevel()); + if (rank > highestRank) { + highestRank = rank; + highestLevel = tree.getLevel(); + } + } + return highestLevel; + } + + private static int treeLevelRank(TreeLevel level) { + if (level == null) { + return 0; + } + String levelName = level.getLevelName(); + if (levelName == null) { + return 0; + } + return switch (levelName.toLowerCase(Locale.ENGLISH)) { + case "sapling" -> 1; + case "small_tree" -> 2; + case "tree" -> 3; + case "magic_tree" -> 4; + case "super_tree" -> 5; + default -> 0; + }; + } + + private static String displayTreeLevel(String levelName) { + if (levelName == null || levelName.isBlank() || levelName.equalsIgnoreCase(LocaleManager.text("placeholders.values.none", "none"))) { + return LocaleManager.text("placeholders.values.none", "none"); + } + return switch (levelName.toLowerCase(Locale.ENGLISH)) { + case "sapling" -> LocaleManager.text("placeholders.values.tree-level.sapling", "Sapling"); + case "small_tree" -> LocaleManager.text("placeholders.values.tree-level.small-tree", "Small Tree"); + case "tree" -> LocaleManager.text("placeholders.values.tree-level.tree", "Tree"); + case "magic_tree" -> LocaleManager.text("placeholders.values.tree-level.magic-tree", "Magic Tree"); + case "super_tree" -> LocaleManager.text("placeholders.values.tree-level.super-tree", "Super Tree"); + default -> levelName; + }; + } + public static Map descriptions() { Map descriptions = new LinkedHashMap<>(); descriptions.put("%onembxmastree_event.active%", LocaleManager.text("placeholders.descriptions.event-active", "whether the event is currently active")); @@ -156,6 +237,15 @@ public static Map descriptions() { descriptions.put("%onembxmastree_trees.total%", LocaleManager.text("placeholders.descriptions.trees-total", "total loaded tree count")); descriptions.put("%onembxmastree_trees.owners%", LocaleManager.text("placeholders.descriptions.trees-owners", "number of unique tree owners")); descriptions.put("%onembxmastree_player.trees%", LocaleManager.text("placeholders.descriptions.player-trees", "loaded trees owned by the placeholder player")); + descriptions.put("%onembxmastree_player.highest_tree_level%", LocaleManager.text("placeholders.descriptions.player-highest-tree-level", "highest loaded tree level owned by the placeholder player, as 0-5")); + descriptions.put("%onembxmastree_player.highest_tree_level_name%", LocaleManager.text("placeholders.descriptions.player-highest-tree-level-name", "highest loaded tree level owned by the placeholder player, as a config key")); + descriptions.put("%onembxmastree_player.highest_tree_level_display%", LocaleManager.text("placeholders.descriptions.player-highest-tree-level-display", "highest loaded tree level owned by the placeholder player, as display text")); + descriptions.put("%onembxmastree_player.gifts_today%", LocaleManager.text("placeholders.descriptions.player-gifts-today", "present gifts opened by the placeholder player today")); + descriptions.put("%onembxmastree_player.gifts_total%", LocaleManager.text("placeholders.descriptions.player-gifts-total", "present gifts opened by the placeholder player during the event")); + descriptions.put("%onembxmastree_player.streak_current%", LocaleManager.text("placeholders.descriptions.player-streak-current", "current daily present-opening streak for the placeholder player")); + descriptions.put("%onembxmastree_player.streak_best%", LocaleManager.text("placeholders.descriptions.player-streak-best", "best daily present-opening streak for the placeholder player")); + descriptions.put("%onembxmastree_player.milestones_claimable%", LocaleManager.text("placeholders.descriptions.player-milestones-claimable", "claimable personal milestone count for the placeholder player")); + descriptions.put("%onembxmastree_community.gifts_opened%", LocaleManager.text("placeholders.descriptions.community-gifts-opened", "community-wide present gifts opened")); descriptions.put("%onembxmastree_version%", LocaleManager.text("placeholders.descriptions.version", "loaded plugin version")); return descriptions; } diff --git a/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java b/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java index a3c0063..fbad0e2 100644 --- a/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java @@ -13,10 +13,53 @@ import java.util.logging.Logger; public final class HeadProfileUtils { + public enum HeadConfigType { + TEXTURE_URL, + PLAYER_NAME, + INVALID + } + + public record HeadConfigValidation(HeadConfigType type, String warning) { + public boolean valid() { + return type != HeadConfigType.INVALID; + } + } private HeadProfileUtils() { } + public static HeadConfigValidation validateConfiguredHead(String configuredHead) { + if (configuredHead == null || configuredHead.trim().isEmpty()) { + return new HeadConfigValidation( + HeadConfigType.INVALID, + LocaleManager.text("console.heads.blank", "Blank present head entry.") + ); + } + + String trimmedHead = configuredHead.trim(); + if (!trimmedHead.contains("://")) { + return new HeadConfigValidation(HeadConfigType.PLAYER_NAME, null); + } + + try { + URL skinUrl = URI.create(trimmedHead).toURL(); + if (!"textures.minecraft.net".equalsIgnoreCase(skinUrl.getHost())) { + return new HeadConfigValidation( + HeadConfigType.INVALID, + LocaleManager.text("console.heads.non-mojang-url", "Ignoring non-Mojang present skin URL: {url}", + "{url}", trimmedHead) + ); + } + return new HeadConfigValidation(HeadConfigType.TEXTURE_URL, null); + } catch (IllegalArgumentException | MalformedURLException exception) { + return new HeadConfigValidation( + HeadConfigType.INVALID, + LocaleManager.text("console.heads.invalid-url", "Invalid present skin URL: {url}", + "{url}", trimmedHead) + ); + } + } + public static void applyConfiguredHead(Skull skull, String configuredHead, Logger logger) { Object legacyProfile = createLegacyProfile(configuredHead, logger); if (legacyProfile == null) { @@ -34,7 +77,11 @@ public static void applyConfiguredHead(Skull skull, String configuredHead, Logge } private static Object createLegacyProfile(String configuredHead, Logger logger) { - if (configuredHead == null || configuredHead.trim().isEmpty()) { + HeadConfigValidation validation = validateConfiguredHead(configuredHead); + if (!validation.valid()) { + if (validation.warning() != null) { + logger.warning(validation.warning()); + } return null; } @@ -46,12 +93,6 @@ private static Object createLegacyProfile(String configuredHead, Logger logger) } URL skinUrl = URI.create(trimmedHead).toURL(); - if (!"textures.minecraft.net".equalsIgnoreCase(skinUrl.getHost())) { - logger.warning(LocaleManager.text("console.heads.non-mojang-url", "Ignoring non-Mojang present skin URL: {url}", - "{url}", trimmedHead)); - return null; - } - Method createProfile = Bukkit.class.getMethod("createProfile", UUID.class); Object legacyProfile = createProfile.invoke(null, UUID.randomUUID()); Method getTextures = legacyProfile.getClass().getMethod("getTextures"); diff --git a/src/main/java/ru/meloncode/xmas/utils/TextUtils.java b/src/main/java/ru/meloncode/xmas/utils/TextUtils.java index 7114ec2..3264e26 100644 --- a/src/main/java/ru/meloncode/xmas/utils/TextUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/TextUtils.java @@ -93,7 +93,7 @@ public static Component parse(String message) { if (message == null) { return Component.empty(); } - String themed = applyThemeAliases(LocaleManager.replaceCommonTokens(message)); + String themed = applyThemeAliases(LocaleManager.replaceCommonTokens(message)).replace("\\>", ">"); if (themed.indexOf('ยง') >= 0) { return LEGACY_SECTION.deserialize(themed); } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index ae409db..420ee56 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -33,15 +33,6 @@ core: # Reload behavior: /xmastree reload applies immediately for future placement checks. tree-limit: 3 - commands: - # Registers the legacy /xmas alias alongside the primary /xmastree command. - # Default: true - # Safe values: true or false - # Reload behavior: - # - File edits apply on /xmastree reload. - # - /xmastree debug toggle core.commands.legacy-command-enabled true|false applies immediately. - legacy-command-enabled: true - # Automatic end-of-event behavior. holiday-ends: # Enables time-based event shutdown. @@ -153,14 +144,20 @@ xmas: # Safe values: # - MATERIAL # - MATERIAL:AMOUNT - # - Base64 item strings saved by /xmastree addhand + # - Base64 item strings saved by /xmastree gifts addhand or /xmastree addhand + # - Weighted entries: + # - item: MATERIAL or MATERIAL:AMOUNT or Base64 item string + # weight: positive whole number # Notes: # - Use modern Paper/Bukkit material names only. + # - Existing simple entries are treated as weight 1. + # - Higher weights are more likely to roll. For example, weight 10 is ten times as likely as weight 1. # - Invalid or legacy material names are skipped safely. - # - /xmastree addhand appends the exact held item and saves it immediately. + # - /xmastree gifts addhand appends the exact held item and saves it immediately. + # - /xmastree gifts weight converts that entry to the structured weighted format. # Reload behavior: # - File edits apply on /xmastree reload. - # - /xmastree addhand applies immediately and persists to disk. + # - /xmastree gifts addhand and /xmastree gifts weight apply immediately and persist to disk. The old /xmastree addhand shortcut still works. gifts: - DIAMOND - EMERALD @@ -178,12 +175,191 @@ xmas: - DIAMOND_HOE - NAME_TAG + # Event progress milestones. + # Default: enabled examples for personal daily gifts, total gifts, streaks, and community gift openings. + # Safe values: + # - enabled: true or false + # - type: daily_gifts, total_gifts, streak_days, or community_gifts + # - target: whole number 1 or higher + # - reward-commands: console commands without a leading slash + # Supported reward-command tokens: + # - {player}: player who claimed a personal milestone; for community milestones, the staff/admin claimer + # - {claimer}: player or staff/admin who claimed the milestone + # - {milestone}: config key, such as streak_7 or gifts_250 + # - {display_name}: configured display-name + # - {target}: configured milestone target + # - {progress}: progress value at claim time + # Example reward commands: + # - cmi kit christmas {player} + # - cmi money give {player} 250 + # - cmi toast {player} -t:task -icon:EMERALD -text:"{display_name} complete!" + # - cmi broadcast ! {display_name} reached: {progress}/{target} + # Reload behavior: + # - /xmastree reload applies milestone definition changes immediately. + # - Existing progress and claimed-state are stored separately in progress.yml and are preserved. + milestones: + player: + daily_gifts_6: + # Enables the personal milestone for opening 6 presents in one server-local day. + # Default: true + # Reload behavior: /xmastree reload applies immediately. + enabled: true + # Progress type tracked for this milestone. + # Default: daily_gifts + # Safe values: daily_gifts, total_gifts, streak_days + # Reload behavior: /xmastree reload applies immediately. + type: daily_gifts + # Required present opens today. + # Default: 6 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload applies immediately. + target: 6 + # Display name shown in /xmastree milestones and the journal. + # Default: Hardcore Elf + # Reload behavior: /xmastree reload applies immediately. + display-name: Hardcore Elf + # Commands run from console when the player claims this milestone. + # Default: runs the global gift spawn command once. + # Safe values: valid console commands; supported tokens are listed above under xmas.milestones. + # Reload behavior: /xmastree reload applies immediately. + reward-commands: + - xmastree gifts + streak_7: + # Enables the personal milestone for opening at least one present on 7 consecutive server-local days. + # Default: true + # Reload behavior: /xmastree reload applies immediately. + enabled: true + # Progress type tracked for this milestone. + # Default: streak_days + # Safe values: daily_gifts, total_gifts, streak_days + # Reload behavior: /xmastree reload applies immediately. + type: streak_days + # Required consecutive days with at least one present opened. + # Default: 7 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload applies immediately. + target: 7 + # Display name shown in /xmastree milestones and the journal. + # Default: Seven-Day Spark + # Reload behavior: /xmastree reload applies immediately. + display-name: Seven-Day Spark + # Commands run from console when the player claims this milestone. + # Default: runs the global gift spawn command once. + # Safe values: valid console commands; supported tokens are listed above under xmas.milestones. + # Reload behavior: /xmastree reload applies immediately. + reward-commands: + - xmastree gifts + total_gifts_25: + # Enables the personal milestone for opening 25 presents during the event. + # Default: true + # Reload behavior: /xmastree reload applies immediately. + enabled: true + # Progress type tracked for this milestone. + # Default: total_gifts + # Safe values: daily_gifts, total_gifts, streak_days + # Reload behavior: /xmastree reload applies immediately. + type: total_gifts + # Required present opens for the whole event. + # Default: 25 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload applies immediately. + target: 25 + # Display name shown in /xmastree milestones and the journal. + # Default: Gift Gatherer + # Reload behavior: /xmastree reload applies immediately. + display-name: Gift Gatherer + # Commands run from console when the player claims this milestone. + # Default: gives the claiming player one Christmas Crystal. + # Safe values: valid console commands; supported tokens are listed above under xmas.milestones. + # Reload behavior: /xmastree reload applies immediately. + reward-commands: + - xmastree give {player} + community: + gifts_100: + # Enables the community milestone for 100 total present opens by all players. + # Default: true + # Reload behavior: /xmastree reload applies immediately. + enabled: true + # Progress type tracked for this community milestone. + # Default: community_gifts + # Safe values: community_gifts + # Reload behavior: /xmastree reload applies immediately. + type: community_gifts + # Required community-wide present opens. + # Default: 100 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload applies immediately. + target: 100 + # Display name shown in /xmastree community. + # Default: Shared Sleigh Run + # Reload behavior: /xmastree reload applies immediately. + display-name: Shared Sleigh Run + # Commands run from console when an admin claims this community milestone. + # Default: runs the global gift spawn command once. + # Safe values: valid console commands; supported tokens are listed above under xmas.milestones. + # Reload behavior: /xmastree reload applies immediately. + reward-commands: + - xmastree gifts + gifts_250: + # Enables the community milestone for 250 total present opens by all players. + # Default: true + # Reload behavior: /xmastree reload applies immediately. + enabled: true + # Progress type tracked for this community milestone. + # Default: community_gifts + # Safe values: community_gifts + # Reload behavior: /xmastree reload applies immediately. + type: community_gifts + # Required community-wide present opens. + # Default: 250 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload applies immediately. + target: 250 + # Display name shown in /xmastree community. + # Default: Village Cheer Rush + # Reload behavior: /xmastree reload applies immediately. + display-name: Village Cheer Rush + # Commands run from console when an admin claims this community milestone. + # Default: runs the global gift spawn command twice. + # Safe values: valid console commands; supported tokens are listed above under xmas.milestones. + # Reload behavior: /xmastree reload applies immediately. + reward-commands: + - xmastree gifts + - xmastree gifts + gifts_500: + # Enables the community milestone for 500 total present opens by all players. + # Default: true + # Reload behavior: /xmastree reload applies immediately. + enabled: true + # Progress type tracked for this community milestone. + # Default: community_gifts + # Safe values: community_gifts + # Reload behavior: /xmastree reload applies immediately. + type: community_gifts + # Required community-wide present opens. + # Default: 500 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload applies immediately. + target: 500 + # Display name shown in /xmastree community. + # Default: North Pole Finale + # Reload behavior: /xmastree reload applies immediately. + display-name: North Pole Finale + # Commands run from console when an admin claims this community milestone. + # Default: runs the global gift spawn command three times. + # Safe values: valid console commands; supported tokens are listed above under xmas.milestones. + # Reload behavior: /xmastree reload applies immediately. + reward-commands: + - xmastree gifts + - xmastree gifts + - xmastree gifts + # Tree stage settings. # Notes: # - gift-cooldown values are written in seconds and converted using core.update-speed. # - lvlup sections use modern material names and item counts for the next stage. # - Particle names should come from the Paper Particle enum: - # https://jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html + # https://jd.papermc.io/paper/26.2/org/bukkit/Particle.html # - Existing loaded trees may keep their current stage objects until the next full restart. # /xmastree reload is best for new trees and newly loaded trees, while a restart is recommended # when you want every existing tree to use the refreshed stage settings. @@ -229,10 +405,10 @@ xmas: # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. speed: 0.01 # Number of ambient particles spawned per effect tick. - # Default: 2 + # Default: 5 # Safe values: whole numbers 0 or higher # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. - count: 2 + count: 5 swag: # Enables the ornament-style particle effect for saplings. # Default: false @@ -279,10 +455,10 @@ xmas: # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: true # Particle type for the small tree ambient effect. - # Default: CHERRY_LEAVES + # Default: ITEM_SNOWBALL # Safe values: a valid Particle enum name # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. - particle: CHERRY_LEAVES + particle: ITEM_SNOWBALL # Horizontal X offset for the small tree ambient particle spread. # Default: 1.0 # Safe values: decimal numbers, usually 0.0 or higher @@ -484,7 +660,7 @@ xmas: GLOWSTONE_DUST: 16 magic_tree: - # Delay before present spawns at the max-level magic_tree stage. + # Delay before present spawns at the magic_tree stage. # Default: 120 # Units: seconds before scaling by core.update-speed # Safe values: 0 or higher @@ -571,6 +747,146 @@ xmas: # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. enabled: false + lvlup: + # Nether stars required to grow from magic_tree to super_tree. + # Default: 1 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for newly loaded trees; existing loaded trees keep their saved remaining requirements. + NETHER_STAR: 1 + # Diamond blocks required to grow from magic_tree to super_tree. + # Default: 2 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for newly loaded trees; existing loaded trees keep their saved remaining requirements. + DIAMOND_BLOCK: 2 + # Emerald blocks required to grow from magic_tree to super_tree. + # Default: 2 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for newly loaded trees; existing loaded trees keep their saved remaining requirements. + EMERALD_BLOCK: 2 + # Amethyst shards required to grow from magic_tree to super_tree. + # Default: 16 + # Safe values: whole numbers 1 or higher + # Reload behavior: /xmastree reload for newly loaded trees; existing loaded trees keep their saved remaining requirements. + AMETHYST_SHARD: 16 + + super_tree: + # Delay before present spawns at the max-level super_tree stage. + # Default: 90 + # Units: seconds before scaling by core.update-speed + # Safe values: 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + gift-cooldown: 90 + + particles: + ambient: + # Enables the ambient particle effect for super trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: true + # Particle type for the super tree ambient effect. + # Default: ITEM_SNOWBALL + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + particle: ITEM_SNOWBALL + # Horizontal X offset for the super tree ambient particle spread. + # Default: 2.2 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-x: 2.2 + # Vertical Y offset for the super tree ambient particle spread. + # Default: 2.4 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-y: 2.4 + # Horizontal Z offset for the super tree ambient particle spread. + # Default: 2.2 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-z: 2.2 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + speed: 0.0 + # Number of ambient particles spawned per effect tick. + # Default: 5 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 5 + swag: + # Enables the ornament-style particle effect for super trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: true + # Particle type for the super tree ornament effect. + # Default: DUST + # Safe values: a valid Particle enum name supported by the plugin's simple particle loader + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + particle: DUST + # Horizontal X offset for the super tree ornament particle spread. + # Default: 0.45 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-x: 0.45 + # Vertical Y offset for the super tree ornament particle spread. + # Default: 0.45 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-y: 0.45 + # Horizontal Z offset for the super tree ornament particle spread. + # Default: 0.45 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-z: 0.45 + # Particle speed value passed to the Paper particle API. + # Default: 6.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + speed: 6.0 + # Number of ornament particles spawned per effect tick. + # Default: 18 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 18 + body: + # Enables the trunk/body particle effect for super trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: true + # Particle type for the super tree trunk/body effect. + # Default: SPORE_BLOSSOM_AIR + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + particle: SPORE_BLOSSOM_AIR + # Horizontal X offset for the super tree body particle spread. + # Default: 0.55 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-x: 0.55 + # Vertical Y offset for the super tree body particle spread. + # Default: 1.75 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-y: 1.75 + # Horizontal Z offset for the super tree body particle spread. + # Default: 0.55 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-z: 0.55 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + speed: 0.0 + # Number of trunk/body particles spawned per effect tick. + # Default: 6 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 6 + # Max-level trees have no next-stage ingredient requirements. # Default: empty section # Safe values: leave empty diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 9c47602..a29c5fe 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -7,7 +7,7 @@ softdepend: [Multiverse-Core, PlotMe, PlotSquared, Plotz, PlaceholderAPI] commands: xmastree: description: Manage the 1MB XMas Tree event - usage: / [help|give|gifts [list|roll|remove|spawn]|addhand|reload|inspect|test|data|debug [section|page]|end] + usage: / [help|info|give|gifts [list|roll|remove|weight|spawn|addhand]|addhand|reload|inspect|test [sound|particle|refund]|data|journal|milestones|community|debug [section|page]|end] permissions: onembxmastree.admin: description: Allows all XMas Tree commands and overrides @@ -15,6 +15,7 @@ permissions: children: onembxmastree.command.status: true onembxmastree.command.help: true + onembxmastree.command.info: true onembxmastree.command.give: true onembxmastree.command.gifts: true onembxmastree.command.addhand: true @@ -22,6 +23,11 @@ permissions: onembxmastree.command.inspect: true onembxmastree.command.test: true onembxmastree.command.data: true + onembxmastree.command.journal: true + onembxmastree.command.milestones: true + onembxmastree.command.milestones.claim: true + onembxmastree.command.community: true + onembxmastree.command.community.claim: true onembxmastree.command.debug: true onembxmastree.command.debug.toggle: true onembxmastree.command.end: true @@ -32,6 +38,9 @@ permissions: onembxmastree.command.help: description: Allows viewing /xmastree help default: true + onembxmastree.command.info: + description: Allows viewing /xmastree info + default: true onembxmastree.command.give: description: Allows /xmastree give default: op @@ -39,7 +48,7 @@ permissions: description: Allows /xmastree gifts default: op onembxmastree.command.addhand: - description: Allows /xmastree addhand + description: Allows /xmastree gifts addhand and /xmastree addhand default: op onembxmastree.command.reload: description: Allows /xmastree reload @@ -53,6 +62,21 @@ permissions: onembxmastree.command.data: description: Allows /xmastree data default: op + onembxmastree.command.journal: + description: Allows /xmastree journal + default: true + onembxmastree.command.milestones: + description: Allows /xmastree milestones + default: true + onembxmastree.command.milestones.claim: + description: Allows /xmastree milestones claim + default: true + onembxmastree.command.community: + description: Allows /xmastree community + default: true + onembxmastree.command.community.claim: + description: Allows /xmastree community claim confirm + default: op onembxmastree.command.debug: description: Allows /xmastree debug default: op diff --git a/src/main/resources/translations/locale_en.yml b/src/main/resources/translations/locale_en.yml index 97c6f54..df07916 100644 --- a/src/main/resources/translations/locale_en.yml +++ b/src/main/resources/translations/locale_en.yml @@ -44,19 +44,19 @@ messages: grow-not-enough-place: There is not enough room for the tree to grow. grow-lvl-max: This tree has reached its maximum level. tree-limit: Hey, do not be greedy. You have reached your tree limit. - destroy-sapling: You can cut down this tree. - destroy-leaves-santa: Santa Claus sees everything. - destroy-leaves-tut: To destroy a tree, cut the log. - destroy-warning: You can cut down the tree. Hit it again to confirm. - destroy-resource-back: Your used upgrade items will be returned for collection. - destroy-tut: Hit it again to confirm. + destroy-sapling: You can cut down this tree. + destroy-leaves-santa: Santa Claus sees everything. + destroy-leaves-tut: To destroy a tree, cut the log. + destroy-warning: You can cut down the tree. Hit it again to confirm. + destroy-resource-back: Your used upgrade items will be returned for collection. + destroy-tut: Hit it again to confirm. destroy-fail-owner: It is not your tree. - destroy-complete: Your tree has been packed away for the season. + destroy-complete: Your tree has been packed away for the season. refund: - chest: Your tree resources were returned in a chest. - barrel: Your tree resources were returned in a barrel. - inventory: Your tree resources were returned to your inventory. - inventory-overflow: Your tree resources were returned. Inventory overflow dropped at the tree. + chest: Your tree resources were returned in a chest. + barrel: Your tree resources were returned in a barrel. + inventory: Your tree resources were returned to your inventory. + inventory-overflow: Your tree resources were returned. Inventory overflow dropped at the tree. gift: luck-message: _UNUSED @@ -72,28 +72,51 @@ command: help: - 'Use /xmastree to show plugin version and status.' - 'Use /xmastree help to show this command list.' - - 'Use /xmastree give \ to give a player a Christmas Crystal.' + - 'Use /xmastree info to learn how the XMas Tree event works.' + - 'Use /xmastree give \ to give a player a Christmas Crystal.' - 'Use /xmastree gifts to spawn presents under all trees.' - 'Use /xmastree gifts list [page] to list configured gift rewards.' - - 'Use /xmastree gifts roll to preview one random gift roll.' - - 'Use /xmastree gifts remove \ to remove a configured gift reward.' - - 'Use /xmastree addhand to add your held item as a gift.' + - 'Use /xmastree gifts roll to preview one weighted random gift roll.' + - 'Use /xmastree gifts remove \ to remove a configured gift reward.' + - 'Use /xmastree gifts weight \ \ to set a gift roll weight.' + - 'Use /xmastree gifts addhand to add your held item as a gift.' - 'Use /xmastree reload to reload config, translations, theme, and crystal item text.' - 'Use /xmastree inspect to inspect the tree you are looking at.' - 'Use /xmastree test sound first|repeat [player] to preview grow sound volume.' - - 'Use /xmastree test particle \ [effect] [player] to preview configured particles.' - - 'Use /xmastree data backup to back up trees.yml.' + - 'Use /xmastree test particle \ [effect] [player] to preview configured particles.' + - 'Use /xmastree test refund [tree-uuid|nearest [player]] to dry-run refund delivery for a tree.' + - 'Use /xmastree data backup [trees|progress|all] to back up event data.' - 'Use /xmastree data validate to check saved tree data.' - - 'Use /xmastree data migrate-world \ \ [dry-run|apply] to migrate saved tree world names.' + - 'Use /xmastree data report to show the event wrap-up report.' + - 'Use /xmastree data migrate-world \ \ [dry-run|apply] to migrate saved tree world names.' + - 'Use /xmastree journal [gui] to view your tree journal and event progress.' + - 'Use /xmastree milestones to view and claim personal milestone rewards.' + - 'Use /xmastree milestones test \ [player] to preview personal milestone reward commands.' + - 'Use /xmastree community to view community milestone progress.' + - 'Use /xmastree community test \ to preview community milestone reward commands.' - 'Use /xmastree debug to open the status debug section.' - - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, and toggles.' - - 'Use /xmastree debug toggle \ true|false to change supported global toggles.' + - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, diagnostics, and toggles.' + - 'Use /xmastree debug toggle \ true|false to change supported global toggles.' - 'Use /xmastree end to end the event.' + info: + - 'XMas Tree Event Info' + - 'XMas Tree is the 1MoreBlock Christmas tree event plugin.' + - 'Get a Christmas Crystal, place a spruce sapling, then right-click the sapling with the crystal.' + - 'Right-click your tree while holding the listed ingredients to grow it through multiple levels.' + - 'Grown trees spawn gifts below them. Right-click those gifts to claim rewards.' + - 'After the event, break the tree log to remove the tree and reclaim the ingredients you spent.' + - 'Open gifts daily, weekly, and across all 25 event days to build streaks, hit personal milestones, and help unlock community milestones.' + - 'Useful commands: /xmastree journal, /xmastree milestones, /xmastree community' + - 'Docs: https://docs.1moreblock.com/custom-server-plugins/xmas-tree/' + - 'Run /xmastree to see the installed plugin version and event status.' player-offline: 'Player not found.' no-player-name: 'Missing player name.' giveaway: 'Ho! Ho! Ho! Looks like it is time to check presents.' - no-permission: 'You do not have permission to use this command.' - player-only: 'Only players can use this command.' + no-permission: 'You do not have permission to use this command.' + player-only: 'Only players can use this command.' + player-required: 'Player name required.' + community: + confirm-required: 'Run the confirm command to claim this community milestone.' hold-item-first: 'Hold an item before running {command}.' gift-added: 'Added the held item to the gift list.' reload-success: '{plugin_name} configuration reloaded.' @@ -102,11 +125,22 @@ command: test: no-particles: 'No enabled particle effects matched that preview.' data: - backup-created: 'Tree data backup created.' - backup-failed: 'Unable to back up trees.yml.' + backup-created: 'Data backup created.' + backup-failed: 'Unable to back up data.' migration-applied: 'World migration saved. Restart the server before testing migrated trees.' migration-failed: 'Unable to migrate tree world names.' - legacy-alias-enabled: 'Legacy alias: /xmas still works.' +progress: + milestone: + unknown: Unknown milestone. + already-claimed: That milestone is already claimed. + not-ready: That milestone is not ready yet. + claimed: Milestone claimed! + invalid: Skipping invalid milestone {key} + history: + tree-created: '{time} - Created {level} at {location} ({tree})' + tree-removed: '{time} - Removed {level} at {location} ({tree})' + player-milestone-claimed: '{time} - Claimed {display_name} ({key}) at {progress}/{target}' + community-milestone-claimed: '{time} - {claimer} claimed {display_name} ({key}) at {progress}/{target}' ui: status: @@ -121,7 +155,6 @@ ui: loaded-trees: Loaded Trees tree-owners: Tree Owners help: Help - legacy-alias: Legacy Alias values: in-progress: 'In Progress' holidays-end: 'Holidays End' @@ -129,19 +162,32 @@ ui: command-descriptions: status: status help: command list + info: how the XMas Tree event works give: give a Christmas Crystal gifts: spawn presents under all trees gifts-list: list configured gifts - gifts-roll: preview a random gift roll + gifts-roll: preview a weighted random gift roll gifts-remove: remove a configured gift - addhand: add held item to gifts + gifts-weight: set a gift roll weight + gifts-addhand: add held item to gifts + addhand: legacy shortcut for gifts addhand reload: reload config and translations inspect: inspect the tree you are looking at test-sound: preview grow sounds test-particle: preview configured particles - data-backup: back up trees.yml + test-refund: dry-run tree refund delivery + data-backup: back up event data data-validate: validate trees.yml + data-report: show event wrap-up report data-migrate-world: migrate saved tree world names + journal: view your tree journal and event progress + journal-gui: open the tree journal GUI + milestones: view and claim milestone rewards + milestones-test: preview personal milestone reward commands + milestones-claim: claim a personal milestone reward + community: view community milestone progress + community-test: preview community milestone reward commands + community-claim: claim a community milestone reward end: end the event debug: open the status debug section debug-section: extended debug output by category @@ -151,13 +197,19 @@ ui: admin: allows all {plugin_name} commands and overrides status: shows /xmastree status output help: shows /xmastree help output + info: shows /xmastree info output give: allows /xmastree give gifts: allows /xmastree gifts - addhand: allows /xmastree addhand + addhand: allows /xmastree gifts addhand and /xmastree addhand reload: allows /xmastree reload inspect: allows /xmastree inspect test: allows /xmastree test data: allows /xmastree data + journal: allows /xmastree journal + milestones: allows /xmastree milestones + milestones-claim: allows /xmastree milestones claim + community: allows /xmastree community + community-claim: allows /xmastree community claim debug: allows /xmastree debug debug-toggle: allows /xmastree debug toggle end: allows /xmastree end @@ -171,6 +223,7 @@ ui: permissions: Permissions placeholders: Placeholders config: Config + diagnostics: Diagnostics labels: usage: Usage keys: Keys @@ -193,18 +246,32 @@ ui: labels: owner: Owner tree-id: Tree ID + owner-online: Owner Online + owner-loaded-trees: Owner Loaded Trees level: Level + next-level: Next Level location: Location + world-loaded: World Loaded + chunk-loaded: Chunk Loaded + tree-built: Tree Built + built-blocks: Built Blocks can-level-up: Can Level Up + next-space-clear: Next Space Clear + gift-cooldown: Gift Cooldown present-timer: Present Timer scheduled-presents: Scheduled Presents + nearby-presents: Nearby Presents + requirement-progress: Requirement Progress remaining: Remaining Requirements + spent-resources: Spent Resources + resource-back: Resource Back refund-preview: Refund Preview requested: Requested try: Try usage: Usage values: none: none + disabled: disabled unknown-owner: unknown unknown-location: unknown @@ -214,14 +281,37 @@ ui: locale: Locale gifts: Gifts present-heads: Present Heads + configured-present-heads: Configured Present Heads + texture-heads: Texture Heads + legacy-player-heads: Legacy Player Heads + invalid-present-heads: Invalid Present Heads trees: Trees owners: Owners particles: Particles resource-back: Resource Back - legacy-alias: Legacy Alias sounds: Sounds + invalid-gifts: Invalid Gifts + tree-data-warnings: Tree Data Warnings + warnings: Warnings + warning: Warning + + diagnostics: + labels: + status: Status + configured-present-heads: Configured Present Heads + valid-present-heads: Valid Present Heads + texture-heads: Texture URL Heads + legacy-player-heads: Legacy Player Heads + invalid-present-heads: Invalid Present Heads + configured-gifts: Configured Gifts + valid-gifts: Valid Gifts + invalid-gifts: Invalid Gifts + tree-data-warnings: Tree Data Warnings + warning-count: Warning Count + warning: Warning test: + refund-title: Refund Delivery Dry Run labels: usage: Usage unknown-test: Unknown Test @@ -233,6 +323,18 @@ ui: level: Level levels: Levels effects-played: Effects Played + tree-id: Tree ID + owner: Owner + location: Location + resource-back: Resource Back + refund-items: Refund Items + delivery-order: Delivery Order + mode: Mode + no-changes: No Changes + values: + delivery-order: chest -> barrel -> player inventory -> floor drops + dry-run: dry-run + safe-preview: This command only previews refund delivery. It does not edit blocks, inventories, tree data, or config. gifts: title: Gift Pool @@ -240,15 +342,20 @@ ui: usage: Usage unknown-action: Unknown Gift Action total: Total + total-weight: Total Weight rolled: Rolled removed: Removed + updated: Updated index: Index + weight: Weight + chance: Chance try: Try values: empty: 'No gifts are configured.' data: title: Tree Data Validation + report-title: Event Wrap-Up Report world-migration-title: Tree World Migration labels: usage: Usage @@ -258,6 +365,15 @@ ui: error: Error stored-trees: Stored Trees loaded-trees: Loaded Trees + tree-owners: Tree Owners + level-counts: Level Counts + gifts-opened: Gifts Opened + players-with-gifts: Players With Gifts + claimed-player-milestones: Claimed Player Milestones + claimed-community-milestones: Claimed Community Milestones + legacy-trees-preserved: Legacy Trees Preserved + returning-player-reward-list: Returning-Player Reward List + community-milestone-history: Community Milestone History status: Status invalid-tree-ids: Invalid Tree IDs invalid-owners: Invalid Owners @@ -274,7 +390,46 @@ ui: apply-command: Apply Command values: clean: 'clean' - warnings: 'warnings' + warnings: 'warnings' + + journal: + title: 'Tree Journal' + labels: + player: Player + trees: Trees + highest-tree: Highest Tree + gifts-today: Gifts Today + total-gifts: Total Gifts + streak: Current Streak + best-streak: Best Streak + claimable: Claimable Milestones + gui: GUI + tree-history: Tree History + milestone-history: Claimed Milestones + values: + no-history: No history recorded yet. + gui: + title: 'Tree Journal' + trees: Trees + gifts: Gifts + streak: Streak + milestones: Milestones + + milestones: + title: 'Milestones' + labels: + usage: Usage + claim: Claim + values: + claimed: claimed + ready: ready + + community: + title: 'Community Milestones' + labels: + usage: Usage + gifts-opened: Gifts Opened + claim: Claim placeholders: values: @@ -285,12 +440,19 @@ placeholders: manual: manual yes: Yes no: No + none: none unknown: unknown disabled: disabled ended: ended duration-days-hours: '{days}d {hours}h' duration-hours-minutes: '{hours}h {minutes}m' duration-minutes: '{minutes}m' + tree-level: + sapling: Sapling + small-tree: Small Tree + tree: Tree + magic-tree: Magic Tree + super-tree: Super Tree descriptions: event-active: whether the event is currently active event-active-text: human-readable active state @@ -308,6 +470,15 @@ placeholders: trees-total: total loaded tree count trees-owners: number of unique tree owners player-trees: loaded trees owned by the placeholder player + player-highest-tree-level: highest loaded tree level owned by the placeholder player, as 0-5 + player-highest-tree-level-name: highest loaded tree level owned by the placeholder player, as a config key + player-highest-tree-level-display: highest loaded tree level owned by the placeholder player, as display text + player-gifts-today: present gifts opened by the placeholder player today + player-gifts-total: present gifts opened by the placeholder player during the event + player-streak-current: current daily present-opening streak for the placeholder player + player-streak-best: best daily present-opening streak for the placeholder player + player-milestones-claimable: claimable personal milestone count for the placeholder player + community-gifts-opened: community-wide present gifts opened version: loaded plugin version console: @@ -328,6 +499,8 @@ console: save-yaml-failed: Failed to save YAML configuration to {file} missing-bundled-resource: Missing bundled configuration resource {resource} load-bundled-resource-failed: Failed to load bundled configuration resource {resource} + milestone: + invalid: Skipping invalid milestone {key} gifts: no-heads: Warning! No heads loaded. Presents cannot spawn without a box head. load-item-failed: 'Failed to load gift item: {item}' @@ -338,6 +511,9 @@ console: too-large: 'Gift item is too large to deserialize safely: {length} characters' invalid-material-or-base64: 'Invalid material name or Base64 gift item: {item}' deserialize-error: 'Failed to deserialize gift item: {error}' + invalid-entry: 'Invalid gift entry skipped: {item}' + no-valid-heads: No valid present head entries are configured. + no-valid-gifts: No valid gift entries are configured. placeholder: registered: 'Registered PlaceholderAPI expansion: {identifier}' registration-failed: PlaceholderAPI is present, but placeholder registration failed. @@ -355,17 +531,13 @@ console: material-numeric-required: Tree level material '{material}' must use a numeric amount. material-load-failed: Cannot load material '{material}' for tree level. world-alias: Loading legacy X-Mas trees from saved world '{source}' into '{target}' via migration.world-aliases. - alias: - command-map-unavailable: Unable to access the Bukkit command map. Skipping legacy /xmas alias registration. - owned-by-plugin: Legacy alias '/{alias}' is already owned by plugin '{plugin}'. Skipping alias registration. - registered-by-other: Legacy alias '/{alias}' is already registered by another command source. Skipping alias registration. - create-failed: Unable to create the legacy /xmas alias command. - registered: Registered legacy alias '/{alias}' for '/{primary}'. - construct-failed: 'Unable to construct dynamic command ''/{command}'': {error}' + validation-warnings: trees.yml has {count} validation warning(s). Run /xmastree data validate for details. heads: apply-failed: Unable to apply configured present head profile. + blank: Blank present head entry. non-mojang-url: 'Ignoring non-Mojang present skin URL: {url}' invalid-url: 'Invalid present skin URL: {url}' build-failed: 'Failed to build present head profile: {error}' + legacy-player-names: Present heads include {count} legacy player-name entr{plural}. Texture URLs are preferred for predictable Paper 26.x behavior. refund: place-container-failed: 'Failed to place refund {container}: {error}' diff --git a/todo.log b/todo.log index 7ebdcc5..6e1e88b 100644 --- a/todo.log +++ b/todo.log @@ -16,32 +16,33 @@ Near-term implementation candidates ----------------------------------- - Finish MiniMessage theme polish: - - move any remaining visible colors, labels, and wording into `translations/locale_en.yml` - - tune the soft 1MB pastel theme for chat, debug pages, crystal text, requirement lists, and refund messages + - done: moved destroy/refund message styling into `translations/locale_en.yml` + - tune the soft 1MB pastel theme for any remaining chat/debug/crystal/requirement copy as it is touched - Add admin preview and test commands: - done: inspect tree state - done: preview particles by tree stage with `/xmastree test particle` - done: test first/repeat grow sounds with `/xmastree test sound` - - later: test refund delivery safely + - done: test refund delivery safely with `/xmastree test refund` dry-run output - Improve tree inspection: + - done: expanded `/xmastree inspect` with owner online state, owner tree count, world/chunk state, built block count, next level, next-level space check, gift cooldown, nearby present count, requirement progress, spent resources, and refund state - show owner, tree UUID, level, world/location, remaining requirements, spent/refundable resources, present timer, scheduled presents, and whether it can level up - policy-must: existing trees from previous years must keep working; returning players waited for their legacy tree and should be able to keep it while receiving another crystal this year, letting them have two trees and earn more gifts - Add gift management UI: - done: view configured gifts with `/xmastree gifts list` - - add held item + - done: add held item with `/xmastree gifts addhand`, while keeping `/xmastree addhand` as a compatibility shortcut - done: remove gifts with `/xmastree gifts remove ` - done: test a gift roll with `/xmastree gifts roll` - - eventually support weighted gift pools + - done: support weighted gift pools with `/xmastree gifts weight ` while keeping simple entries compatible as weight 1 - Modernize PlaceholderAPI output: - - player tree count - - player highest tree level - - total active trees - - event status text - - future timer/status values useful for CMI holograms and ajLeaderboards + - done: player tree count + - done: player highest tree level, level key, and display text + - done: total active trees + - done: event status text + - done: future timer/status values useful for CMI holograms and ajLeaderboards - Add safer data tools: - done: backup `trees.yml` with `/xmastree data backup` @@ -51,36 +52,40 @@ Near-term implementation candidates - Continue present head and texture cleanup: - verify Paper 26.2 profile behavior for player names and texture URLs - - improve diagnostics around invalid head entries + - done: improve diagnostics around invalid head entries, legacy player-name heads, and invalid gift entries - Add a reload report: - - show locale, gift count, present head count, tree count, tree owner count, particles setting, sound volumes, legacy alias state, and any warnings + - done: show locale, gift count, present head count, tree count, tree owner count, particles setting, sound volumes, legacy alias state, and warning diagnostics Future feature ideas to review ------------------------------ - Add event streaks: - - 6 gift claims in a day for a "hardcore elf" style daily milestone - - 7 days in a row - - every day of the event/month - - add a player command or GUI to view streak progress and claim milestone rewards - - possible milestone reward: trigger extra gifts for all online players + - done: track present openings in progress.yml + - done: 6 gift claims in a day for a "hardcore elf" style daily milestone + - done: 7 days in a row milestone support + - done: add /xmastree milestones and /xmastree milestones claim + - done: milestone reward commands can trigger extra gifts for all online players + - future: add an every-day-of-the-event/month milestone once the exact 2026 event calendar is decided - Add one extra progression level after the current max tree: - - "super tree" or similarly festive final stage - - modern Paper 26.2 particles/effects - - distinctive design for returning players who can upgrade their old max tree once more + - done: hardcoded super_tree final stage after magic_tree + - done: super_tree has Paper 26.2 particle config defaults + - done: existing legacy magic_tree saves receive new super_tree requirements on load instead of upgrading for free - Add seasonal admin presets: - save/load named config presets for gift pools, particle styles, sound tuning, and event dates - Add player tree journal: - - command or GUI showing a player's tree history, current tree locations, old legacy tree, current-year tree, upgrade progress, and collected milestone rewards + - done: /xmastree journal text summary + - done: /xmastree journal gui informational inventory + - future: add tree history and collected milestone reward details - Add community milestones: - - total server-wide gifts opened - - total trees grown - - unlock server-wide bonus gift waves when thresholds are reached + - done: total server-wide gifts opened + - done: /xmastree community and /xmastree community claim + - done: community reward commands can unlock server-wide bonus gift waves + - future: total trees grown - Add configurable tree protection helpers: - staff-only teleport to a tree @@ -93,3 +98,13 @@ Future feature ideas to review - gifts spawned/opened - legacy trees preserved - players eligible for returning-player rewards + +Live hardening completed after v2.1.0-049 +---------------------------------------- + +- done: add `/xmastree milestones test [player]` to preview personal milestone reward commands without claiming or executing them +- done: add `/xmastree community test ` to preview community milestone reward commands without claiming or executing them +- done: require `/xmastree community claim confirm` before global community milestone rewards execute +- done: log whether each milestone reward command was accepted by the server command dispatcher +- done: debounce `progress.yml` writes during active present claiming and flush on reload, disable, and progress backup +- done: add `/xmastree data backup progress` and `/xmastree data backup all` alongside the existing tree-data backup From bf80652ff71ebcb30115fd93678f40ba578f0fb3 Mon Sep 17 00:00:00 2001 From: Floris Date: Sun, 23 Aug 2026 09:31:53 +0200 Subject: [PATCH 10/10] Use portable paths in public docs (#1) --- .public-docs-excludes | 2 ++ README.md | 4 +++- build.gradle | 2 +- docs/installation.md | 4 +++- 4 files changed, 9 insertions(+), 3 deletions(-) create mode 100644 .public-docs-excludes diff --git a/.public-docs-excludes b/.public-docs-excludes new file mode 100644 index 0000000..cd8d9ee --- /dev/null +++ b/.public-docs-excludes @@ -0,0 +1,2 @@ +# Paths are relative to docs/. +agents diff --git a/README.md b/README.md index 8733d03..0089575 100644 --- a/README.md +++ b/README.md @@ -144,9 +144,11 @@ Requirements: - JDK 25 - Gradle -- Centralized Paper server cache at `/Users/floris/Projects/Codex/servers/cache/Paper-26.2` +- Shared Paper server cache at `../servers/cache/Paper-26.2` - PlaceholderAPI build `266` in the centralized 26.2 cache, sourced from the matching 26.2 1MB CMI-API server setup +Set `CODEX_SHARED_SERVERS_ROOT` to use a shared servers directory somewhere other than the default sibling `../servers` directory. + This repo no longer requires a local `servers/` folder for compilation. If an ignored local `servers/` folder exists here for ad-hoc compatibility testing, treat it as optional local data rather than part of the project. Build the current Paper 26.2 jar: diff --git a/build.gradle b/build.gradle index b4bb26e..cd9b3a1 100644 --- a/build.gradle +++ b/build.gradle @@ -15,7 +15,7 @@ def resourceExpansionProperties = [ version : projectVersion, apiVersion: declaredPluginApiVersion ] -def sharedServersRoot = file(System.getenv('CODEX_SHARED_SERVERS_ROOT') ?: '/Users/floris/Projects/Codex/servers') +def sharedServersRoot = file(System.getenv('CODEX_SHARED_SERVERS_ROOT') ?: '../servers') def sharedPaperCache = new File(sharedServersRoot, 'cache/Paper-26.2') def paper262LibrariesDir = new File(sharedPaperCache, 'libraries') def paper262Api = new File(sharedPaperCache, "libraries/io/papermc/paper/paper-api/${paperCompileVersion}/paper-api-${paperCompileVersion}.jar") diff --git a/docs/installation.md b/docs/installation.md index 7bf1a1b..3472ac5 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -52,9 +52,11 @@ Requirements: - JDK 25 - Gradle -- Centralized Paper 26.2 cache at `/Users/floris/Projects/Codex/servers/cache/Paper-26.2` +- Shared Paper 26.2 cache at `../servers/cache/Paper-26.2` - PlaceholderAPI build `266` in the centralized Paper 26.2 cache +Set `CODEX_SHARED_SERVERS_ROOT` to use a shared servers directory somewhere other than the default sibling `../servers` directory. + Build and copy the release jar into `libs/`: ```bash