diff --git a/.gitignore b/.gitignore index d1000bd..3a246cc 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,20 @@ 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/ +cache/ +libraries/ +versions/ + +# Local release jars for the centralized Paper runner +libs/ 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 5efa817..0089575 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,531 @@ -# 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 target -* **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 current Paper 26.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.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. + +## 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.2 particle names. +- `/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 +- 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` +- 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 +- 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`, `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 +- 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 +- 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 +- 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 + +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.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 + +Requirements: + +- JDK 25 +- Gradle +- 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: + +```bash +gradle clean buildAllJars +``` + +Build only the Paper 26.2 jar: + +```bash +gradle jar +``` + +The `paper262Jar` task is kept as an alias: + +```bash +gradle paper262Jar +``` + +`buildAllJars` now: + +- 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, declared plugin API version, and forward-compatibility target + +You can also inspect the build metadata directly with: + +```bash +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 + +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, 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 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`, `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`, `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`. | + +### 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 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: + +| Page | Section | +| --- | --- | +| `1` | `status` | +| `2` | `commands` | +| `3` | `permissions` | +| `4` | `placeholders` | +| `5` | `config` | +| `6` | `diagnostics` | + +### Debug toggle keys + +`/xmastree debug toggle true|false` currently supports: + +- `core.plugin-enabled` +- `core.holiday-ends.enabled` +- `core.holiday-ends.resource-back` +- `core.particles-enabled` +- `xmas.luck.enabled` + +## Permissions + +| 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`, 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`. | +| `onembxmastree.tree.override` | `op` | Allows managing other players' trees. | + +## 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. 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`, `/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 + +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`. + +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 +``` + +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, 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: + +```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. + +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, 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: + +[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 + +Translation messages, crystal names, crystal lore, command/debug text, and prefixes support MiniMessage: + +```yaml +crystal: + name: Christmas Crystal + lore: + - 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. + +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_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: + +```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.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 + +- 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. +- Treat `config.yml` and translation 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) +- **mrfloris** - 2026 Paper modernization, Java 25 builds, and XMasTree maintenance - [mrfloris](https://github.com/mrfloris) + +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..cd9b3a1 --- /dev/null +++ b/build.gradle @@ -0,0 +1,110 @@ +plugins { + id 'java' +} + +group = 'com.onemb.xmas' +version = '2.1.0-051' +def projectVersion = version.toString() +def enableDeprecationLint = providers.gradleProperty('lintDeprecatedApi').map { it.toBoolean() }.getOrElse(false) + +def javaRelease = 25 +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') ?: '../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") +def placeholderApiJar = new File(sharedPaperCache, 'plugins/PlaceholderAPI-2.12.3-DEV-266.jar') +def paper262ArchiveName = "1MB-XMas-2026-v${projectVersion}-v25-26.2.jar" + +[ + [paper262Api, "Paper API jar"], + [paper262LibrariesDir, "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(javaRelease) + } +} + +dependencies { + 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(resourceExpansionProperties) + } +} + +tasks.named('jar') { + archiveFileName = paper262ArchiveName +} + +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('paper262Jar') { + description = 'Assembles the Paper 26.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 " 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 " 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.2 target jar and copies it into libs/.' + group = 'build' + dependsOn tasks.named('releaseJar') + finalizedBy tasks.named('printBuildConfig') +} + +tasks.named('assemble') { + dependsOn tasks.named('paper262Jar') +} 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..3472ac5 --- /dev/null +++ b/docs/installation.md @@ -0,0 +1,90 @@ +# 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 +- 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 +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/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..cf51085 100644 --- a/src/main/java/ru/meloncode/xmas/Effects.java +++ b/src/main/java/ru/meloncode/xmas/Effects.java @@ -6,15 +6,17 @@ 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); + 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 5ce427c..c53014e 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; @@ -11,15 +12,15 @@ 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.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; import org.bukkit.inventory.EquipmentSlot; 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; @@ -34,13 +35,27 @@ 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) { 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()); } } @@ -49,7 +64,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()); } } @@ -70,9 +86,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 +117,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 +131,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 +145,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); @@ -163,17 +185,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(); - if (meta.getOwner() != null && Main.getHeads().contains(meta.getOwner())) { - event.setCancelled(true); - } - } - } - @EventHandler public void onPistonRetract(BlockPistonRetractEvent event) { if (MagicTree.isBlockBelongs(event.getBlock())) { @@ -210,20 +221,27 @@ 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) { - 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,27 +250,34 @@ public void onPlayerBreakBlock(BlockBreakEvent event) { case SPRUCE_LEAVES: case GLOWSTONE: if (Main.inProgress) - TextUtils.sendMessage(player, ChatColor.DARK_GREEN + 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_SANTA); + 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) { - 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,10 +315,10 @@ 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().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); } } diff --git a/src/main/java/ru/meloncode/xmas/ItemMaker.java b/src/main/java/ru/meloncode/xmas/ItemMaker.java index 444639f..ea75c15 100644 --- a/src/main/java/ru/meloncode/xmas/ItemMaker.java +++ b/src/main/java/ru/meloncode/xmas/ItemMaker.java @@ -2,12 +2,14 @@ //I plan to make this plugin bigger. So... -import org.bukkit.ChatColor; +import net.kyori.adventure.text.Component; 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 +27,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 +45,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,30 +61,31 @@ 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; } 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.setLore(lore); + lore.add(TextUtils.parse(line)); + im.lore(lore); return this; } 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/LocaleManager.java b/src/main/java/ru/meloncode/xmas/LocaleManager.java index 889d840..88db720 100644 --- a/src/main/java/ru/meloncode/xmas/LocaleManager.java +++ b/src/main/java/ru/meloncode/xmas/LocaleManager.java @@ -1,119 +1,341 @@ package ru.meloncode.xmas; -import org.bukkit.ChatColor; 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; 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 List CRYSTAL_LORE = new ArrayList<>(); public static String GIFT_LUCK; public static String GIFT_FAIL; - public static String MONSTER; 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 + "' successfuly 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_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_TUT = getString("messages.tree.destroy-tut"); - DESTROY_FAIL_OWNER = getString("messages.tree.destroy-fail-owner"); - CRYSTAL_NAME = ChatColor.GREEN + 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 = ChatColor.translateAlternateColorCodes('&', 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"); - 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(ChatColor.translateAlternateColorCodes('&', 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(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"); - 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 da86ce7..5089785 100644 --- a/src/main/java/ru/meloncode/xmas/MagicTree.java +++ b/src/main/java/ru/meloncode/xmas/MagicTree.java @@ -9,7 +9,9 @@ import org.bukkit.inventory.Inventory; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.FireworkMeta; -import org.bukkit.metadata.FixedMetadataValue; +import org.bukkit.persistence.PersistentDataType; +import ru.meloncode.xmas.utils.HeadProfileUtils; +import ru.meloncode.xmas.utils.TextUtils; import org.bukkit.util.Vector; import java.util.*; @@ -54,10 +56,17 @@ public MagicTree(UUID owner, UUID uid, TreeLevel level, Location location, Map(level.getLevelupRequirements()); + save(); + } + } + public Location getLocation() { return location; } @@ -84,8 +105,33 @@ 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); + float volume = levelRequirement == remainingBefore ? Main.growFirstSoundVolume : Main.growRepeatSoundVolume; if (levelupRequirements.get(material) <= 1) { levelupRequirements.remove(material); } else { @@ -94,10 +140,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; } @@ -119,8 +166,11 @@ 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.getWorld().isChunkLoaded(block.getX() / 16, block.getZ() / 16)) + if (!block.getChunk().isLoaded()) continue; if (block.getType() == Material.SPRUCE_LEAVES) { if (level.getSwagEffect() != null) { @@ -132,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)); - } } } } @@ -160,28 +207,38 @@ 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(); } 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); } } 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) { @@ -190,9 +247,8 @@ 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++; @@ -206,20 +262,22 @@ 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)]; } while (face == BlockFace.DOWN || face == BlockFace.UP || face == BlockFace.SELF); - //skull.setRotation(face); Rotatable skullRotatable = (Rotatable) skull.getBlockData(); skullRotatable.setRotation(face); - //skull.setSkullType(SkullType.PLAYER); + skull.setBlockData(skullRotatable); skull.setType(Material.PLAYER_HEAD); - //skull.setOwner(); - skull.setOwningPlayer(Bukkit.getOfflinePlayer(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); } } @@ -238,58 +296,144 @@ 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 (XMas.isPresentHead(bl)) { + bl.setType(Material.AIR); } + } + } + } + + private void refundResources(Player refundTarget) { + List refundItems = collectRefundItems(); + if (refundItems.isEmpty()) { + return; + } - if (cLevel.nextLevel == null) - break; - cLevel = cLevel.nextLevel; + List leftovers = putRefundsInContainer(Material.CHEST, refundItems); + if (leftovers != null) { + dropRefunds(leftovers); + 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, LocaleManager.text("messages.tree.refund.barrel", "Your tree resources were returned in a barrel.")); + return; + } + + leftovers = refundTarget != null ? addItems(refundTarget.getInventory(), refundItems) : refundItems; + dropRefunds(leftovers); + if (refundTarget != null) { + if (leftovers.isEmpty()) { + notifyRefund(refundTarget, LocaleManager.text("messages.tree.refund.inventory", "Your tree resources were returned to your inventory.")); + } else { + notifyRefund(refundTarget, LocaleManager.text("messages.tree.refund.inventory-overflow", "Your tree resources were returned. Inventory overflow dropped at the tree.")); } + } + } + + private List collectRefundItems() { + List refundItems = new ArrayList<>(); + + TreeLevel cLevel = TreeLevel.SAPLING; + while (cLevel != null && cLevel != level) { + addRequirements(refundItems, cLevel.getLevelupRequirements()); + cLevel = cLevel.nextLevel; + } - int count = 0; + 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)); + } + } + } + return refundItems; + } + + public List getRefundPreviewItems() { + return collectRefundItems(); + } + + 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(LocaleManager.text("console.refund.place-container-failed", "Failed to place refund {container}: {error}", + "{container}", containerMaterial.name(), + "{error}", 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()); } } - XMas.removeTree(this); + } + + private void notifyRefund(Player refundTarget, String message) { + if (refundTarget != null && refundTarget.isOnline()) { + TextUtils.sendRawMessage(refundTarget, message); + } } public long getPresentCounter() { @@ -300,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 bf3dc8f..a3196e5 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,36 +9,107 @@ 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; +import ru.meloncode.xmas.utils.ConfigUtils; +import ru.meloncode.xmas.utils.HeadProfileUtils; import ru.meloncode.xmas.utils.TextUtils; 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 { + private static final String CONFIG_RESOURCE_PATH = "config.yml"; + private static final String TREES_RESOURCE_PATH = "trees.yml"; // 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; 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 NamespacedKey noDamageFireworkKey; + private static NamespacedKey presentHeadKey; + private static NamespacedKey crystalRecipeKey; 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; + 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, + float growFirstSoundVolume, + 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; @@ -48,6 +119,52 @@ public static List getHeads() { return heads; } + public static NamespacedKey getCrystalKey() { + return crystalKey; + } + + public static NamespacedKey getNoDamageFireworkKey() { + return noDamageFireworkKey; + } + + 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); + } + + @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; @@ -56,21 +173,30 @@ public void onLoad() { @Override 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; } 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,43 +206,21 @@ 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(); + eventProgress = new EventProgress(this); for (World world : getServer().getWorlds()) { TreeSerializer.loadTrees(this, world); } - LocaleManager.loadLocale(locale); - heads = config.getStringList("xmas.presents"); + loadDiagnosticsAndConfiguredItems(true); if (heads.size() == 0) { - getLogger().warning(ChatColor.RED + "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<>(); - 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"); - } - } if (gifts.size() == 0) { - getLogger().warning(ChatColor.RED + "[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; } @@ -124,27 +228,11 @@ 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); - XMas.XMAS_CRYSTAL = new ItemMaker(Material.EMERALD, LocaleManager.CRYSTAL_NAME, LocaleManager.CRYSTAL_LORE).make(); - - 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) { - } + new PlayParticlesTask(this).runTaskTimer(this, 5, PARTICLES_DELAY); + refreshCrystalItem(); + registerCrystalRecipe(); XMasCommand.register(this); + registerPlaceholderApi(); TextUtils.sendConsoleMessage(LocaleManager.PLUGIN_ENABLED); } @@ -156,32 +244,528 @@ 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); + } + } + + public ReloadSummary reloadPluginConfig() { + reloadConfig(); + config = getConfig(); + 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"); + 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(TextUtils.error(LocaleManager.text("console.config.invalid-holiday-end-date", "Invalid holiday end date in config.yml"))); + } + + defineTreeLevels(); + 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; + refreshCrystalItem(); + registerCrystalRecipe(); + XMasCommand.refreshCommandConfiguration(this); + 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, + configDiagnostics.configuredPresentHeadCount(), + configDiagnostics.texturePresentHeadCount(), + configDiagnostics.legacyPlayerHeadCount(), + configDiagnostics.invalidPresentHeadCount(), + configDiagnostics.invalidGiftCount(), + configDiagnostics.configWarningCount(), + configDiagnostics.treeDataWarningCount(), + XMas.getAllTrees().size(), + owners.size(), + particlesEnabled, + resourceBack, + growFirstSoundVolume, + 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; + } + 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(LocaleManager.text("console.gifts.serialize-list-item-failed", "Failed to serialize item for saving to the gift list. Item: {item}", + "{item}", gift.toString())); + return; + } + + List giftList = getRawGiftList(); + giftList.add(serializedItem); + config.set("xmas.gifts", giftList); + saveConfig(); + reloadGiftItemsFromConfig(); + } + + public List getGiftItems() { + List snapshot = new ArrayList<>(); + if (gifts == null) { + return snapshot; + } + 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; + } + 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) { + if (displayIndex < 1) { + return null; + } + + List giftList = getRawGiftList(); + int validIndex = 0; + for (int rawIndex = 0; rawIndex < giftList.size(); rawIndex++) { + GiftEntry gift = deserializeGiftEntry(giftList.get(rawIndex), true); + if (gift == null) { + continue; + } + validIndex++; + if (validIndex == displayIndex) { + giftList.remove(rawIndex); + config.set("xmas.gifts", giftList); + saveConfig(); + reloadGiftItemsFromConfig(); + 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; + } + + private void reloadGiftItemsFromConfig() { + gifts = new ArrayList<>(); + 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(); + return Base64.getEncoder().encodeToString(serializedBytes); + } catch (Exception e) { + getLogger().severe(LocaleManager.text("console.gifts.serialize-item-failed", "Failed to serialize item: {error}", + "{error}", e.getMessage())); + return null; + } + } + + public static ItemStack deserializeItem(String serializedItem) { + return deserializeItem(serializedItem, true); + } + + private static ItemStack deserializeItem(String serializedItem, boolean logWarnings) { + 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) { + 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; + } + + try { + byte[] serializedBytes = Base64.getDecoder().decode(trimmed); + return ItemStack.deserializeBytes(serializedBytes); + } catch (IllegalArgumentException e) { + 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) { + 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; + 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 (eventProgress != null) { + eventProgress.save(); + } if (XMas.getAllTrees().size() > 0) for (MagicTree tree : XMas.getAllTrees()) { - tree.unbuild(); + tree.unbuild(false); } } + private void registerPlaceholderApi() { + if (!getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) { + return; + } + placeholderExpansion = new XMasPlaceholderExpansion(this); + if (placeholderExpansion.register()) { + getLogger().info(LocaleManager.text("console.placeholder.registered", "Registered PlaceholderAPI expansion: {identifier}", + "{identifier}", XMasPlaceholders.IDENTIFIER)); + } else { + getLogger().warning(LocaleManager.text("console.placeholder.registration-failed", "PlaceholderAPI is present, but placeholder registration failed.")); + } + } + public void end() { - Bukkit.broadcastMessage(ChatColor.GREEN + LocaleManager.HAPPY_NEW_YEAR); + Bukkit.broadcast(TextUtils.parse(TextUtils.accent(LocaleManager.HAPPY_NEW_YEAR))); inProgress = false; config.set("core.plugin-enabled", false); saveConfig(); } 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"); - for (String path : defaults) - if (!new File(getDataFolder(), '/' + path).exists()) plugin.saveResource(path, false); + reloadConfig(); + 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() { @@ -190,13 +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.MAGIC_TREE = new TreeLevel("magic_tree", Effects.TREE_WHITE_AMBIENT, Effects.TREE_SWAG, null, null, magic_delay, Collections.emptyMap(), new StructureTemplate(new HashMap() { + 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), + TreeLevel.SUPER_TREE, magic_delay, magicLevelUp, new StructureTemplate(new HashMap() { private static final long serialVersionUID = 1L; { @@ -232,7 +872,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 +909,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 +940,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 +953,45 @@ 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(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(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; + } + + 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..0b2db2b 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,45 @@ 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(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/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/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 b4108d9..36e8558 100644 --- a/src/main/java/ru/meloncode/xmas/TreeSerializer.java +++ b/src/main/java/ru/meloncode/xmas/TreeSerializer.java @@ -1,9 +1,10 @@ package ru.meloncode.xmas; -import org.bukkit.ChatColor; 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; @@ -11,13 +12,74 @@ 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.LinkedHashMap; +import java.util.LinkedHashSet; 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(); + 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 int warningCount() { + return invalidTreeIds.size() + + invalidOwners.size() + + invalidLevels.size() + + invalidLocations.size() + + missingWorlds.size() + + invalidRequirements.size() + + duplicateLocations.size(); + } + } + + public record WorldMigrationReport( + String sourceWorld, + String targetWorld, + int matchedTrees, + boolean applied, + File backupFile + ) { + } + + public record TreeSummary( + int storedTreeCount, + int loadedTreeCount, + Set owners, + Map levelCounts + ) { + } public static void loadTrees(JavaPlugin plugin, World world) { try { @@ -31,7 +93,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")); @@ -41,26 +104,41 @@ 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().severe(String.format("Error while loading tree `%s`", cKey)); - e.printStackTrace(); - System.out.println("================================================"); + 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(ChatColor.DARK_RED + "ERROR WHILE LOADING TREES"); - e.printStackTrace(); + 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); } } @@ -74,15 +152,19 @@ 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()); + trees.set("trees." + cKey + ".scheduled_presents", tree.getScheduledPresents()); try { trees.save(treesFile); } catch (IOException e) { - e.printStackTrace(); + Main.getInstance().getLogger().log(Level.SEVERE, LocaleManager.text("console.trees.unable-save", "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,8 +172,122 @@ public static void removeTree(MagicTree tree) { try { trees.save(treesFile); } catch (IOException e) { - e.printStackTrace(); + 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 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"); + 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) { @@ -101,14 +297,163 @@ 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(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(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 find material '" + sMaterial + "' for tree level."); - return null; + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.trees.material-load-failed", "Cannot load material '{material}' for tree level.", + "{material}", sMaterial))); } } return levelupRequirements; } -} \ No newline at end of file + + 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; + } + 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(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 7335851..38bb294 100644 --- a/src/main/java/ru/meloncode/xmas/XMas.java +++ b/src/main/java/ru/meloncode/xmas/XMas.java @@ -8,7 +8,7 @@ import org.bukkit.block.Skull; import org.bukkit.entity.Player; import org.bukkit.inventory.ItemStack; -import org.jetbrains.annotations.Nullable; +import org.bukkit.persistence.PersistentDataType; import ru.meloncode.xmas.utils.LocationUtils; import ru.meloncode.xmas.utils.TextUtils; @@ -31,10 +31,19 @@ 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) { 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 +51,69 @@ 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(); + } + if (Main.getInstance() instanceof Main plugin && plugin.getEventProgress() != null) { + plugin.getEventProgress().recordTreeRemoved(tree); + } 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(); - - 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 (!isPresentHead(block)) { + return; + } + + Location loc = block.getLocation(); + World world = loc.getWorld(); + if (world != null) { + if (RANDOM.nextFloat() < Main.LUCK_CHANCE || !Main.LUCK_CHANCE_ENABLED) { + 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 { + Effects.SMOKE.playEffect(loc); + world.dropItemNaturally(loc, new ItemStack(Material.COAL)); + TextUtils.sendMessage(player, LocaleManager.GIFT_FAIL); + } + block.setType(Material.AIR); + if (Main.getInstance() instanceof Main plugin && plugin.getEventProgress() != null) { + plugin.getEventProgress().recordGiftClaim(player); + } + } + } + + 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.getPersistentDataContainer().has(Main.getPresentHeadKey(), PersistentDataType.BYTE); } public static List getTreesPlayerOwn(Player player) { @@ -86,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 943ee1b..745b1cc 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -1,20 +1,63 @@ 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.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.PluginCommand; +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; +import ru.meloncode.xmas.utils.TextUtils; +import java.io.File; +import java.io.IOException; 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 { + private static final PlainTextComponentSerializer PLAIN_TEXT = PlainTextComponentSerializer.plainText(); + public static final String PRIMARY_COMMAND = "xmastree"; + 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"; + 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_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", "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.plugin-enabled", + "core.holiday-ends.enabled", + "core.holiday-ends.resource-back", + "core.particles-enabled", + "xmas.luck.enabled" + )); + private static final Map DEBUG_SECTIONS = createDebugSections(); + private static XMasCommand registeredExecutor; private final Main plugin; @@ -23,47 +66,148 @@ 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); + } + + public static void refreshCommandConfiguration(Main plugin) { + if (registeredExecutor == null) { + register(plugin); + return; + } } @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); + if (!hasPermission(sender, PERMISSION_HELP)) { + sendNoPermission(sender); + break; + } + for (String line : getHelpLines()) { + TextUtils.sendRawMessage(sender, line); + } + 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); + break; + } if (args.length > 1) { String name = args[1]; Player player = Bukkit.getPlayer(name); 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; } case "end": { + if (!hasPermission(sender, PERMISSION_END)) { + sendNoPermission(sender); + break; + } plugin.end(); break; } case "gifts": { - Random random = new Random(); - for (MagicTree magicTree : XMas.getAllTrees()) { - for (int i = 0; i < 3 + random.nextInt(4); i++) { - magicTree.spawnPresent(); - } + handleGifts(sender, args); + break; + } + case "reload": { + if (!hasPermission(sender, PERMISSION_RELOAD)) { + sendNoPermission(sender); + break; + } + 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 "journal": { + if (!hasPermission(sender, PERMISSION_JOURNAL)) { + sendNoPermission(sender); + break; + } + handleJournal(sender, args); + break; + } + case "milestones": { + if (!hasPermission(sender, PERMISSION_MILESTONES)) { + sendNoPermission(sender); + break; } - Bukkit.broadcastMessage(LocaleManager.COMMAND_GIVEAWAY); + 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; + } + handleAddHand(sender, commandPath("addhand")); + break; + } + case "debug": { + handleDebug(sender, args); break; } @@ -71,13 +215,1127 @@ 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; } + @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) && canUseSubCommand(sender, subCommand)) { + 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("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", "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()) { + 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", "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")) { + 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", "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) { + 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")) { + suggestions.addAll(filterStartingWith(Arrays.asList("true", "false"), args[3])); + } + } + return suggestions; + } + + 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); + return; + } + if (args[1].equalsIgnoreCase("roll")) { + sendGiftRoll(sender); + return; + } + if (args[1].equalsIgnoreCase("remove")) { + 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()) { + 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.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), 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))))); + } + } + + 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))); + 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) { + 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 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; + } + + if (args[1].equalsIgnoreCase("sound")) { + handleTestSound(sender, args); + return; + } + if (args[1].equalsIgnoreCase("particle")) { + 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) { + 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 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, 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; + } + + 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 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 { + 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 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"))); + 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(Collection 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 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; + } + 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", "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) { + 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.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.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.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; + } + 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 +1343,697 @@ 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( + 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( + 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) { - sender.sendMessage(DARK_GREEN + "Current Time: " + GREEN + sdf.format(System.currentTimeMillis())); - sender.sendMessage(DARK_GREEN + "Holidays end: " + RED + 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(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(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; + } + + private void handleDebug(CommandSender sender, String[] args) { + if (args.length >= 2 && args[1].equalsIgnoreCase("toggle")) { + if (!hasPermission(sender, PERMISSION_DEBUG_TOGGLE)) { + sendNoPermission(sender); + return; + } + handleDebugToggle(sender, args); + return; + } + + if (!hasPermission(sender, PERMISSION_DEBUG)) { + sendNoPermission(sender); + return; + } + + if (args.length < 2) { + sendDebugSection(sender, "status"); + return; + } + + 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, 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(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(LocaleManager.text("ui.debug.labels.value", "Value"), LocaleManager.text("ui.debug.invalid-boolean", TextUtils.error("must be true or false")))); + return; + } + + boolean value = Boolean.parseBoolean(args[3]); + plugin.getConfig().set(key, value); + plugin.saveConfig(); + plugin.reloadPluginConfig(); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.updated", "Updated"), TextUtils.command(key) + TextUtils.muted(" -> ") + TextUtils.booleanValue(value))); + } + + private LinkedHashMap> buildDebugSections() { + LinkedHashMap> sections = new LinkedHashMap<>(); + sections.put("status", getStatusLines()); + + List commandsPage = new ArrayList<>(); + commandsPage.add(""); + 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 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("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("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"))); + sections.put("commands", commandsPage); + + List permissionsPage = new ArrayList<>(); + permissionsPage.add(""); + 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(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, 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(debugSectionDisplayName("config"))); + for (String key : DEBUG_TOGGLE_KEYS) { + togglesPage.add(formatKeyValue(key, TextUtils.booleanValue(plugin.getConfig().getBoolean(key)))); + } + sections.put("config", togglesPage); + sections.put("diagnostics", getDiagnosticsLines(plugin.getConfigDiagnostics())); + + 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"; + case "diagnostics", "diagnostic", "diag", "warnings" -> "diagnostics"; + default -> null; + }; + } + + private void sendDebugSection(CommandSender sender, String sectionKey) { + LinkedHashMap> sections = buildDebugSections(); + if (!sections.containsKey(sectionKey)) { + sendInvalidDebugSelection(sender, sectionKey, sections.size()); + return; + } + 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, + 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(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(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() { + List lines = new ArrayList<>(); + for (String line : LocaleManager.COMMAND_HELP) { + lines.add(line.replaceAll("/" + RETIRED_LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND)); + } + addMissingBundledHelpLines(lines); + 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; + } + return "/" + PRIMARY_COMMAND + " " + suffix; + } + + private void addMissingBundledHelpLines(List lines) { + List bundledLines = LocaleManager.bundledTextList("command.help"); + for (String bundledLine : bundledLines) { + 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); + } + } + } + + 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"), + commandPath("addhand"), + commandPath("gifts"), + commandPath("give"), + commandPath("help"), + commandPath("info"), + commandPath("end"), + commandPath("") + ); + for (String commandNeedle : commandNeedles) { + if (line.contains(commandNeedle)) { + return commandNeedle; + } + } + return null; + } + + public static boolean canOverrideTree(CommandSender sender) { + return hasPermission(sender, PERMISSION_TREE_OVERRIDE); + } + + 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; + } + + 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) || 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; + }; + } + + private static boolean hasPermission(CommandSender sender, String permission) { + return sender.hasPermission(PERMISSION_ADMIN) || sender.hasPermission(permission); + } + + private void sendNoPermission(CommandSender sender) { + 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 TextUtils.title(title); + } + + private String formatListEntry(String key, String value) { + return TextUtils.command(key) + TextUtils.muted(" : ") + TextUtils.text(value); + } + + private String formatStyledListEntry(String key, String value) { + return TextUtils.command(key) + TextUtils.muted(" : ") + value; + } + + 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, 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 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")); + 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"); } - 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"); + 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 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()) { + 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 String formatPlain(String label, Object value) { + return label + ": " + value; + } + + 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"); + 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)); + } + + 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/XMasPlaceholderExpansion.java b/src/main/java/ru/meloncode/xmas/XMasPlaceholderExpansion.java new file mode 100644 index 0000000..c3d726f --- /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 "mrfloris"; + } + + @Override + public String getVersion() { + return plugin.getPluginMeta().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..8f2d0ef --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java @@ -0,0 +1,252 @@ +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.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; + +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_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"); + + 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 + ? 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 + ? 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)); + 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; + }; + } + + private static String normalize(String params) { + return params.trim() + .toLowerCase(Locale.ENGLISH) + .replace('.', '_') + .replace('-', '_'); + } + + private static String formatEndDate() { + if (Main.endTime <= 0) { + return LocaleManager.text("placeholders.values.unknown", "unknown"); + } + return DATE_FORMAT.format(new Date(Main.endTime)); + } + + private static String formatDurationUntilEnd() { + if (!Main.autoEnd) { + return LocaleManager.text("placeholders.values.disabled", "disabled"); + } + if (Main.endTime <= 0) { + return LocaleManager.text("placeholders.values.unknown", "unknown"); + } + long remainingMillis = Main.endTime - System.currentTimeMillis(); + if (remainingMillis <= 0) { + return LocaleManager.text("placeholders.values.ended", "ended"); + } + + long totalSeconds = remainingMillis / 1000; + long days = totalSeconds / 86400; + long hours = (totalSeconds % 86400) / 3600; + long minutes = (totalSeconds % 3600) / 60; + if (days > 0) { + return LocaleManager.text("placeholders.values.duration-days-hours", "{days}d {hours}h", + "{days}", Long.toString(days), + "{hours}", Long.toString(hours)); + } + if (hours > 0) { + return LocaleManager.text("placeholders.values.duration-hours-minutes", "{hours}h {minutes}m", + "{hours}", Long.toString(hours), + "{minutes}", Long.toString(minutes)); + } + return LocaleManager.text("placeholders.values.duration-minutes", "{minutes}m", + "{minutes}", Long.toString(minutes)); + } + + 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; + } + + 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")); + 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_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/ConfigUtils.java b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java index 552986b..fb04e03 100644 --- a/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java +++ b/src/main/java/ru/meloncode/xmas/utils/ConfigUtils.java @@ -1,14 +1,153 @@ 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 org.bukkit.plugin.java.JavaPlugin; +import ru.meloncode.xmas.LocaleManager; 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) { - return YamlConfiguration.loadConfiguration(file); + public static YamlConfiguration loadConfig(File file) { + YamlConfiguration configuration = newConfiguration(); + if (!file.exists()) { + return configuration; + } + + try { + configuration.loadFromString(Files.readString(file.toPath(), StandardCharsets.UTF_8)); + } catch (IOException | InvalidConfigurationException 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; + } + + 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 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(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, LocaleManager.text("console.config.save-yaml-failed", "Failed to save YAML configuration to {file}", + "{file}", file.getPath()), exception); + } + } + + public static YamlConfiguration loadResourceConfig(JavaPlugin plugin, String resourcePath) { + YamlConfiguration configuration = newConfiguration(); + try (InputStream inputStream = plugin.getResource(resourcePath)) { + if (inputStream == null) { + 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, LocaleManager.text("console.config.load-bundled-resource-failed", "Failed to load bundled configuration resource {resource}", + "{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/java/ru/meloncode/xmas/utils/HeadProfileUtils.java b/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java new file mode 100644 index 0000000..fbad0e2 --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java @@ -0,0 +1,139 @@ +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 { + 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) { + 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) { + HeadConfigValidation validation = validateConfiguredHead(configuredHead); + if (!validation.valid()) { + if (validation.warning() != null) { + logger.warning(validation.warning()); + } + 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(); + 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 94eeddf..3264e26 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.TextColor; +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.Map; +import java.util.Objects; public class TextUtils { + public static final String DISPLAY_NAME = "XMas Tree"; + private static final String FALLBACK_SUCCESS_HEX = "#b9e8b5"; + private static final String FALLBACK_ERROR_HEX = "#f3a7a7"; - 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 = 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))); + } + 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,163 @@ 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))); + 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) + .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(consolePrefix() + message)); + } + } + + public static Component parse(String message) { + if (message == null) { + return Component.empty(); + } + String themed = applyThemeAliases(LocaleManager.replaceCommonTokens(message)).replace("\\>", ">"); + if (themed.indexOf('§') >= 0) { + return LEGACY_SECTION.deserialize(themed); + } + if (themed.indexOf('&') >= 0 && themed.indexOf('<') < 0) { + return LEGACY_AMPERSAND.deserialize(themed); + } + return MINI_MESSAGE.deserialize(themed); + } + + public static List parseList(List messages) { + List components = new ArrayList<>(); + if (messages != null) { + for (String message : messages) { + components.add(parse(message)); + } + } + 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 a7a74ac..420ee56 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,38 +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: -#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 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 + + # Translation file loaded from plugins/X-Mas/translations/locale_.yml. + # Default: en + # 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 -#Max tree count per player + + # 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 -#On date all trees stop to spawn presents and particles. -#Players can unbuild it and get resources back (if enabled) + + # Automatic end-of-event behavior. holiday-ends: + # 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 -#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 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 + + # 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 - - #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 + + # 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 + + # 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 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 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. + # 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: + # 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 -# Value 1-100 + + # 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 -#Add here all nicknames of players which head you can use as one of the gift skin + + # 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: - - CruXXx - - SeerPotion -# Alternative looking -# - MHF_Present1 -# - MHF_Present2 + - http://textures.minecraft.net/texture/21bc9d42b0041e8f95cb9b26628fdaf50cd0e36f7bb9d6b3a4d2af3949da97d6 + - http://textures.minecraft.net/texture/2b1ec7dc753061ca174424ea45cf9490b39cd5dcca477d138a603e6be755ec72 + + # Gift pool for presents. + # Default: the material list below + # Safe values: + # - MATERIAL + # - MATERIAL:AMOUNT + # - 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 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 gifts addhand and /xmastree gifts weight apply immediately and persist to disk. The old /xmastree addhand shortcut still works. gifts: - DIAMOND - EMERALD @@ -50,29 +175,734 @@ 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.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 to disable present spawning + # 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 - #Resources to up level tree to next. + + 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: 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 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 + 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: + # 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 + + 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: 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 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 + 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: + # 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 + + 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 + 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: + # Delay before present spawns at the 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 + + 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 + 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 + # Reload behavior: informational only; no practical effect unless custom code starts reading it later. + lvlup: {} + +migration: + # Maps world names from legacy trees.yml data to current world names. + # Default: empty map + # Safe format: + # old_world_name: new_world_name + # Example: + # 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: {} diff --git a/src/main/resources/locales/default.yml b/src/main/resources/locales/default.yml deleted file mode 100644 index 0e793bf..0000000 --- a/src/main/resources/locales/default.yml +++ /dev/null @@ -1,56 +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: X-Mas - -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: Required - 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-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-fail-owner: It's not your tree! - destroy-complete: You're MONSTER! - 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 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 diff --git a/src/main/resources/locales/en.yml b/src/main/resources/locales/en.yml deleted file mode 100644 index 9abaef3..0000000 --- a/src/main/resources/locales/en.yml +++ /dev/null @@ -1,47 +0,0 @@ -#_UNUSED is a keyword to disable message. - -#For chat -plugin-name: X-Mas - -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: Required - 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-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-fail-owner: It's not your tree! - destroy-complete: You're MONSTER! - 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 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' - -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 diff --git a/src/main/resources/locales/hu.yml b/src/main/resources/locales/hu.yml deleted file mode 100644 index aefd1c2..0000000 --- a/src/main/resources/locales/hu.yml +++ /dev/null @@ -1,47 +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, és nincs erőforrás. - 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 , 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!' diff --git a/src/main/resources/locales/ru.yml b/src/main/resources/locales/ru.yml deleted file mode 100644 index 46baeab..0000000 --- a/src/main/resources/locales/ru.yml +++ /dev/null @@ -1,40 +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: - - '&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 diff --git a/src/main/resources/locales/ru_santa.yml b/src/main/resources/locales/ru_santa.yml deleted file mode 100644 index 239cd4c..0000000 --- a/src/main/resources/locales/ru_santa.yml +++ /dev/null @@ -1,40 +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: - - '&2Команда &c/xmas &2- отобразить версию плагина и статус' - - '&2Команда &c/xmas give &2<Ник> - выдать игроку кристалл' - - '&2Команда &c/xmas gifts &2- под каждой елкой появится несколько подарков!' - - '&2Команда &c/xmas end &2Переводит плагин в режим завершения праздников' - player-offline: 'Игрок не найден' - no-player-name: 'Вы не ввели имя игрока!' - giveaway: '&cХоу! &2Хоу! &cХоу! &2Кажется время проверять подарки!' \ No newline at end of file diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index 8bcb742..a29c5fe 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,10 +1,91 @@ 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: '${apiVersion}' +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|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 + default: op + 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 + onembxmastree.command.reload: true + 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 + 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.info: + description: Allows viewing /xmastree info + 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 gifts addhand and /xmastree addhand + default: op + 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.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 + 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 diff --git a/src/main/resources/translations/locale_en.yml b/src/main/resources/translations/locale_en.yml new file mode 100644 index 0000000..df07916 --- /dev/null +++ b/src/main/resources/translations/locale_en.yml @@ -0,0 +1,543 @@ +# 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 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 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 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 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, 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.' + 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.' + 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: '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.' +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: + 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 + values: + in-progress: 'In Progress' + holidays-end: 'Holidays End' + + 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 weighted random gift roll + gifts-remove: remove a configured gift + 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 + 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 + 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 + info: shows /xmastree info output + give: allows /xmastree give + gifts: allows /xmastree gifts + 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 + tree-override: allows managing other players' trees + + debug: + section-title: '{plugin_name} Debug' + sections: + status: Status + commands: Commands + permissions: Permissions + placeholders: Placeholders + config: Config + diagnostics: Diagnostics + 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 + 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 + + reload: + title: Reload Summary + labels: + 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 + 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 + try: Try + sound: Sound + particle: Particle + target: Target + volume: Volume + 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 + labels: + 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 + unknown-action: Unknown Data Action + try: Try + file: File + 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 + 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' + + 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: + active: Active + inactive: Inactive + in-progress: In Progress + holidays-end: Holidays End + 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 + 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 + 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: + 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} + 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}' + 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}' + 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. + 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. + 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 new file mode 100644 index 0000000..6e1e88b --- /dev/null +++ b/todo.log @@ -0,0 +1,110 @@ +Remaining warnings and deferred deprecation cleanup +================================================= + +- `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: + - 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` + - 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` + - 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` + - done: support weighted gift pools with `/xmastree gifts weight ` while keeping simple entries compatible as weight 1 + +- Modernize PlaceholderAPI output: + - 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` + - 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 + - done: improve diagnostics around invalid head entries, legacy player-name heads, and invalid gift entries + +- Add a reload report: + - 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: + - 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: + - 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: + - done: /xmastree journal text summary + - done: /xmastree journal gui informational inventory + - future: add tree history and collected milestone reward details + +- Add community milestones: + - 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 + - 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 + +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