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/README.md b/README.md index 5efa817..de7f2ee 100644 --- a/README.md +++ b/README.md @@ -1,16 +1,432 @@ -# 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-032-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`, and `config`, plus live global boolean toggles. +- Primary `/xmastree` command with an optional legacy `/xmas` alias. +- Optional PlaceholderAPI placeholders for CMI holograms, ajLeaderboards, scoreboards, and menus. +- Legacy `trees.yml` world-name alias support for renamed destination worlds. +- Comment-preserving `config.yml` syncing that keeps admin values while safely adding missing defaults and missing template comments. +- Present heads are tagged with plugin PDC data, so gift handling no longer depends on deprecated skull profile reads. + +## v2 changelog + +- modernize the plugin from the legacy deployed build to an actively maintained Paper 26.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 +- keep `/xmas` as an optional legacy alias controlled by config +- fix legacy alias reload and unregister behavior so reload no longer crashes when alias settings change +- improve `/xmastree help` output and keep it aligned with the actual command surface + +- add granular permissions under `onembxmastree.*` +- replace the old `xmas.admin` permission with `onembxmastree.admin` +- add separate permissions for `status`, `help`, `give`, `gifts`, `addhand`, `reload`, `debug`, `debug.toggle`, `end`, and `tree.override` + +- add a modern debug system with named categories: `status`, `commands`, `permissions`, `placeholders`, and `config` +- keep numeric debug pages working as a legacy shortcut +- improve debug output formatting with clearer key/value coloring +- make invalid debug page or section requests return a helpful response instead of silently falling back +- add `/xmastree debug toggle true|false` for live boolean config changes +- keep tab completion focused on named debug categories instead of numeric page suggestions +- add `/xmastree inspect` for staff tree support checks, including owner, tree UUID, level, location, remaining requirements, refund preview, present timer, and scheduled presents +- make `/xmastree reload` print a short reload report with locale, gift/head/tree counts, key toggles, alias state, and sound volumes +- add `/xmastree test sound` and `/xmastree test particle` so admins can preview reloadable sound volume and particle settings without waiting for live tree interactions +- add `/xmastree data backup` and `/xmastree data validate` for safe `trees.yml` support checks before event work or migration testing +- add `/xmastree data migrate-world [dry-run|apply]` so saved tree world names can be reviewed and migrated safely with an automatic backup +- add `/xmastree gifts list`, `/xmastree gifts roll`, and `/xmastree gifts remove ` for command-line gift pool management before a GUI exists + +- add optional PlaceholderAPI support with the `onembxmastree` namespace +- add placeholders for event state, end time, end countdown, auto-end, resource-back, particles, luck, tree totals, owner totals, player tree count, and plugin version +- 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-032-v25-26.2.jar` +- Future 26.x: use the same jar for forward-compatibility testing + +## Building + +Requirements: + +- JDK 25 +- Gradle +- Centralized Paper server cache at `/Users/floris/Projects/Codex/servers/cache/Paper-26.2` +- PlaceholderAPI build `266` in the centralized 26.2 cache, sourced from the matching 26.2 1MB CMI-API server setup + +This repo no longer 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 primary command is `/xmastree`. + +If `core.commands.legacy-command-enabled` is `true`, the legacy `/xmas` alias is also registered. + +| Command | Description | +| --- | --- | +| `/xmastree` | Shows plugin version, event status, auto-end status, resource-back status, tree count, and owner count. | +| `/xmastree help` | Shows the command list. | +| `/xmastree give ` | Gives an online player a Christmas Crystal. | +| `/xmastree gifts` | Spawns a small batch of presents under every loaded Christmas tree. | +| `/xmastree gifts spawn` | Explicit form of `/xmastree gifts`; spawns presents under every loaded Christmas tree. | +| `/xmastree gifts list [page]` | Lists configured gift rewards by index. | +| `/xmastree gifts roll` | Previews one random gift roll without giving it to a player. | +| `/xmastree gifts remove ` | Removes a configured gift reward by the index shown in `/xmastree gifts list`. | +| `/xmastree addhand` | Adds the item in your main hand to the gift list and saves it to `config.yml`. | +| `/xmastree reload` | Reloads config, translations, theme, prefix, crystal item text, present heads, gifts, luck settings, command alias settings, and tree level requirements, then prints a short reload report. | +| `/xmastree inspect` | Inspects the tree you are looking at, or the nearest tree if no tree block is targeted. | +| `/xmastree inspect nearest ` | Inspects the nearest loaded tree to an online player. | +| `/xmastree inspect ` | Inspects a specific loaded tree by UUID. | +| `/xmastree test sound first [player]` | Plays the configured first ingredient sound volume for yourself or an online player. | +| `/xmastree test sound repeat [player]` | Plays the configured repeat ingredient sound volume for yourself or an online player. | +| `/xmastree test particle [all\|ambient\|swag\|body] [player]` | Previews configured particle effects for `sapling`, `small_tree`, `tree`, or `magic_tree`. | +| `/xmastree data backup` | Creates a timestamped copy of `plugins/X-Mas/trees.yml` in `plugins/X-Mas/backups/`. | +| `/xmastree data validate` | Reads `trees.yml` and reports missing worlds, invalid IDs, owners, levels, coordinates, requirements, and duplicate locations. | +| `/xmastree data migrate-world [dry-run\|apply]` | Counts or rewrites saved tree world names in `trees.yml`. `dry-run` is the default. `apply` creates a backup first, writes the change, and should be followed by a server restart before testing migrated trees. | +| `/xmastree debug` | Opens the `status` debug section by default. | +| `/xmastree debug [section\|page]` | Shows debug output for `status`, `commands`, `permissions`, `placeholders`, or `config`. Numeric pages `1-5` still work as a legacy shortcut. | +| `/xmastree debug toggle true\|false` | Toggles supported global boolean config keys and reloads the plugin config. | +| `/xmastree end` | Ends the event and sets `core.plugin-enabled` to `false`. | + +### Debug sections + +The preferred debug syntax is category-based: + +| Section | Example | Purpose | +| --- | --- | --- | +| `status` | `/xmastree debug status` | Event state, end date, auto-end, refund state, particles, loaded tree count, and owner count. | +| `commands` | `/xmastree debug commands` | Command list, debug syntax, and legacy alias state. | +| `permissions` | `/xmastree debug permissions` | All registered `onembxmastree.*` permissions and what they allow. | +| `placeholders` | `/xmastree debug placeholders` | All built-in PlaceholderAPI placeholders plus their descriptions. | +| `config` | `/xmastree debug config` | The current values of the toggleable global config keys. | + +Numeric compatibility remains available for existing habits and old screenshots: + +| Page | Section | +| --- | --- | +| `1` | `status` | +| `2` | `commands` | +| `3` | `permissions` | +| `4` | `placeholders` | +| `5` | `config` | + +### Debug toggle keys + +`/xmastree debug toggle true|false` currently supports: + +- `core.commands.legacy-command-enabled` +- `core.plugin-enabled` +- `core.holiday-ends.enabled` +- `core.holiday-ends.resource-back` +- `core.particles-enabled` +- `xmas.luck.enabled` + +## Permissions + +| Permission | Default | Description | +| --- | --- | --- | +| `onembxmastree.admin` | `op` | Umbrella permission for all XMas Tree commands and override actions. | +| `onembxmastree.command.status` | `true` | Allows viewing `/xmastree` status output. | +| `onembxmastree.command.help` | `true` | Allows viewing `/xmastree help`. | +| `onembxmastree.command.give` | `op` | Allows `/xmastree give `. | +| `onembxmastree.command.gifts` | `op` | Allows `/xmastree gifts`. | +| `onembxmastree.command.addhand` | `op` | Allows `/xmastree addhand`. | +| `onembxmastree.command.reload` | `op` | Allows `/xmastree reload`. | +| `onembxmastree.command.inspect` | `op` | Allows `/xmastree inspect`. | +| `onembxmastree.command.test` | `op` | Allows `/xmastree test sound` and `/xmastree test particle`. | +| `onembxmastree.command.data` | `op` | Allows `/xmastree data backup` and `/xmastree data validate`. | +| `onembxmastree.command.debug` | `op` | Allows `/xmastree debug [section\|page]`. | +| `onembxmastree.command.debug.toggle` | `op` | Allows `/xmastree debug toggle true\|false`. | +| `onembxmastree.command.end` | `op` | Allows `/xmastree end`. | +| `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. Presents spawn around grown trees while the event is enabled. + +If `core.holiday-ends.resource-back` is enabled, confirmed tree destruction returns the upgrade materials that were actually spent on the tree. The plugin tries to place a chest first, then a barrel, then the player's inventory, and finally drops any overflow at the tree location. + +Ingredient accept sounds can be tuned live in `config.yml` under `core.sounds.grow`. Use `0.0` for silent, `0.1` for quiet, `0.5` for half volume, and `1.0` for full volume. `/xmastree reload` applies the new values without a server restart. + +Admins can preview those sound values with `/xmastree test sound first` and `/xmastree test sound repeat`. Particle configuration can be previewed with `/xmastree test particle sapling`, `/xmastree test particle small_tree`, `/xmastree test particle tree`, or `/xmastree test particle magic_tree`. + +## Configuration + +The plugin writes its files to `plugins/X-Mas`: + +- `config.yml` controls event timing, locale, tree limits, gift cooldowns, present skins, gift items, and level-up requirements. +- `trees.yml` stores placed tree data and should be kept when upgrading an existing event. +- `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 +``` + +Admins can also hold an item and run `/xmastree addhand`. This saves the exact item as Base64 so custom names, lore, enchantments, and metadata can be used as gifts. + +Gift pool maintenance commands: + +- `/xmastree gifts list [page]` shows the currently loaded gift rewards. +- `/xmastree gifts roll` previews one random gift selection without giving it to anyone. +- `/xmastree gifts remove ` removes the indexed gift from `xmas.gifts`, saves `config.yml`, and reloads the gift pool. + +Legacy world-name remapping lives under `migration.world-aliases`. This is useful when an old `trees.yml` was saved in worlds like `general`, `wild`, or `santa`, but the new Paper 26.2 server uses different world names: + +```yaml +migration: + 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. + +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_version%` | `2.1.0-032` | 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..0ab0ad3 --- /dev/null +++ b/build.gradle @@ -0,0 +1,110 @@ +plugins { + id 'java' +} + +group = 'com.onemb.xmas' +version = '2.1.0-032' +def projectVersion = version.toString() +def enableDeprecationLint = providers.gradleProperty('lintDeprecatedApi').map { it.toBoolean() }.getOrElse(false) + +def javaRelease = 25 +def paperCompileVersion = '26.2.build.29-alpha' +def declaredPluginApiVersion = '26.2' +def experimentalCompatibilityTarget = 'future 26.x' +def resourceExpansionProperties = [ + version : projectVersion, + apiVersion: declaredPluginApiVersion +] +def sharedServersRoot = file(System.getenv('CODEX_SHARED_SERVERS_ROOT') ?: '/Users/floris/Projects/Codex/servers') +def sharedPaperCache = new File(sharedServersRoot, 'cache/Paper-26.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/pom.xml b/pom.xml deleted file mode 100644 index b894d55..0000000 --- a/pom.xml +++ /dev/null @@ -1,92 +0,0 @@ - - - 4.0.0 - - me.meloncode - xmas - 2.5 - - - - spigot-repo - https://hub.spigotmc.org/nexus/content/repositories/snapshots/ - - - bungeecord-repo - https://oss.sonatype.org/content/repositories/snapshots - - - - - - - org.apache.maven.plugins - maven-compiler-plugin - 3.1 - - 1.8 - 1.8 - UTF-8 - - - - maven-shade-plugin - - false - - - - package - - shade - - - - - - - - true - src/main/resources - - - - - - - - org.spigotmc - spigot-api - 1.15-R0.1-SNAPSHOT - provided - - - - org.bukkit - bukkit - 1.15-R0.1-SNAPSHOT - provided - - - - net.md-5 - bungeecord-api - 1.15-SNAPSHOT - - - - org.apache.commons - commons-lang3 - 3.0 - - - org.jetbrains - annotations - 17.0.0 - compile - - - - \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..192ee77 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,15 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = '1MB-XMas' diff --git a/src/main/java/ru/meloncode/xmas/Effects.java b/src/main/java/ru/meloncode/xmas/Effects.java index 0270af3..b7a7b31 100644 --- a/src/main/java/ru/meloncode/xmas/Effects.java +++ b/src/main/java/ru/meloncode/xmas/Effects.java @@ -6,15 +6,15 @@ class Effects { public static final ParticleContainer AMBIENT_SAPLING = new ParticleContainer(Particle.PORTAL, 0.2f, 0.25f, 0.2f, 0.1f, 16); public static final ParticleContainer AMBIENT_PORTAL = new ParticleContainer(Particle.PORTAL, 2f, 2f, 2f, 0.1f, 16); - public static final ParticleContainer TREE_SWAG = new ParticleContainer(Particle.REDSTONE, 0.25f, 0.25f, 0.25f, 10f, 16); + public static final ParticleContainer TREE_SWAG = new ParticleContainer(Particle.DUST, 0.25f, 0.25f, 0.25f, 10f, 16); public static final ParticleContainer TREE_HEARTS_AMBIENT = new ParticleContainer(Particle.HEART, 1.25f, 1.25f, 1.25f, 10f, 1); - public static final ParticleContainer TREE_RED_SWAG = new ParticleContainer(Particle.REDSTONE, 0.25f, 0.25f, 0.25f, 0f, 16); - public static final ParticleContainer TREE_WHITE_AMBIENT = new ParticleContainer(Particle.FIREWORKS_SPARK, 2.25f, 2.25f, 2.25f, 0f, 4); + public static final ParticleContainer TREE_RED_SWAG = new ParticleContainer(Particle.DUST, 0.25f, 0.25f, 0.25f, 0f, 16); + public static final ParticleContainer TREE_WHITE_AMBIENT = new ParticleContainer(Particle.FIREWORK, 2.25f, 2.25f, 2.25f, 0f, 4); public static final ParticleContainer TREE_CRIT_SWAG = new ParticleContainer(Particle.CRIT, 0.25f, 0.25f, 0.25f, 0f, 16); public static final ParticleContainer TREE_GOLD_SWAG = new ParticleContainer(Particle.FLAME, 0.25f, 0.25f, 0.25f, 0f, 16); - public static final ParticleContainer SMOKE = new ParticleContainer(Particle.SMOKE_LARGE, 0f, 0f, 0f, 0f, 16); + public static final ParticleContainer SMOKE = new ParticleContainer(Particle.LARGE_SMOKE, 0f, 0f, 0f, 0f, 16); - public static final ParticleContainer GROW = new ParticleContainer(Particle.VILLAGER_HAPPY, 0.25f, 0.25f, 0.25f, 1f, 16); - public static final ParticleContainer AMBIENT_SNOW = new ParticleContainer(Particle.SNOW_SHOVEL, 1.5f, 3f, 1.5f, 0, 16); + public static final ParticleContainer GROW = new ParticleContainer(Particle.HAPPY_VILLAGER, 0.25f, 0.25f, 0.25f, 1f, 16); + public static final ParticleContainer AMBIENT_SNOW = new ParticleContainer(Particle.ITEM_SNOWBALL, 1.5f, 3f, 1.5f, 0, 16); } diff --git a/src/main/java/ru/meloncode/xmas/Events.java b/src/main/java/ru/meloncode/xmas/Events.java index 5ce427c..a44ee63 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,13 @@ import org.bukkit.event.block.*; import org.bukkit.event.entity.EntityDamageByEntityEvent; import org.bukkit.event.entity.EntityExplodeEvent; -import org.bukkit.event.entity.ItemSpawnEvent; import org.bukkit.event.player.PlayerInteractEvent; import org.bukkit.event.world.ChunkLoadEvent; import org.bukkit.event.world.StructureGrowEvent; import org.bukkit.inventory.EquipmentSlot; import org.bukkit.inventory.ItemStack; import org.bukkit.inventory.meta.ItemMeta; -import org.bukkit.inventory.meta.SkullMeta; -import org.jetbrains.annotations.Nullable; +import org.bukkit.persistence.PersistentDataType; import ru.meloncode.xmas.utils.TextUtils; import java.util.Collection; @@ -40,7 +39,7 @@ public void onPlayerOpenPresent(PlayerInteractEvent event) { if (event.getHand() == EquipmentSlot.OFF_HAND) return; //Event firing for both hands if (event.getAction() == Action.RIGHT_CLICK_BLOCK) { Block block = event.getClickedBlock(); - if (block != null && block.getType() == Material.PLAYER_HEAD) { + if (XMas.isPresentHead(block)) { XMas.processPresent(block, event.getPlayer()); } } @@ -49,7 +48,8 @@ public void onPlayerOpenPresent(PlayerInteractEvent event) { @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) public void onPlayerOpenPresent(BlockBreakEvent event) { Block block = event.getBlock(); - if (block != null && block.getType() == Material.PLAYER_HEAD) { + if (XMas.isPresentHead(block)) { + event.setDropItems(false); XMas.processPresent(block, event.getPlayer()); } } @@ -70,9 +70,15 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { Player player = event.getPlayer(); if (event.getAction() == Action.RIGHT_CLICK_BLOCK) { Block block = event.getClickedBlock(); + if (block == null) { + return; + } if (MagicTree.isBlockBelongs(block)) { event.setCancelled(true); MagicTree tree = MagicTree.getTreeByBlock(block); + if (tree == null) { + return; + } if (Main.inProgress) { if (tree.getLevel().hasNext()) { if (tree.canLevelUp()) { @@ -95,7 +101,7 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { } if (tree.level.nextLevel != null) { TextUtils.sendMessage(player, LocaleManager.GROW_LVL_PROGRESS); - for (String line : TextUtils.generateChatReqList(tree)) { + for (var line : TextUtils.generateChatReqList(tree)) { TextUtils.sendMessage(player, line); } @@ -109,7 +115,7 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { } } else { if (player.getUniqueId().equals(tree.getOwner())) { - tree.end(); + tree.end(player); TextUtils.sendMessage(player, LocaleManager.TIMEOUT); } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); @@ -123,7 +129,7 @@ public void onPlayerClickBlock(PlayerInteractEvent event) { if (XMas.getTreesPlayerOwn(player).size() < Main.MAX_TREE_COUNT) { if (is.getType() == XMas.XMAS_CRYSTAL.getType() && is.hasItemMeta() && is.getItemMeta().hasLore()) { ItemMeta im = is.getItemMeta(); - if (im.getLore().equals(XMas.XMAS_CRYSTAL.getItemMeta().getLore())) { + if (im != null && im.getPersistentDataContainer().has(Main.getCrystalKey(), PersistentDataType.BYTE)) { if (player.getGameMode() != GameMode.CREATIVE) { if (is.getAmount() > 1) { is.setAmount(is.getAmount() - 1); @@ -163,17 +169,6 @@ public void onBlockBreakByExplosion(EntityExplodeEvent event) { } } - @EventHandler - public void onItemSpawn(ItemSpawnEvent event) { - ItemStack item = event.getEntity().getItemStack(); - if (item.getType() == Material.PLAYER_HEAD) { - SkullMeta meta = (SkullMeta) item.getItemMeta(); - 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 +205,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, TextUtils.success(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, TextUtils.warning(LocaleManager.DESTROY_WARNING)); + TextUtils.sendMessage(player, TextUtils.muted(LocaleManager.DESTROY_TUT)); + if (Main.resourceBack) { + TextUtils.sendMessage(player, TextUtils.info(LocaleManager.DESTROY_RESOURCE_BACK)); + } } else { - tree.end(); + tree.end(player); } } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); @@ -232,27 +234,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, TextUtils.accent(LocaleManager.DESTROY_LEAVES_SANTA)); + if (player.getUniqueId().equals(tree.getOwner()) || XMasCommand.canOverrideTree(player)) { + TextUtils.sendMessage(player, TextUtils.warning(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, TextUtils.success(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, TextUtils.warning(LocaleManager.DESTROY_SAPLING)); + TextUtils.sendMessage(player, TextUtils.muted(LocaleManager.DESTROY_TUT)); + if (Main.resourceBack) { + TextUtils.sendMessage(player, TextUtils.info(LocaleManager.DESTROY_RESOURCE_BACK)); + } } } else { - tree.end(); + tree.end(player); } } else { TextUtils.sendMessage(player, LocaleManager.DESTROY_FAIL_OWNER); @@ -290,10 +299,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/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..cb4dc44 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,16 @@ public MagicTree(UUID owner, UUID uid, TreeLevel level, Location location, Map getLevelupRequirements() { public boolean grow(Material material) { if (levelupRequirements.containsKey(material)) { + int levelRequirement = level.getLevelupRequirements().getOrDefault(material, levelupRequirements.get(material)); + int remainingBefore = levelupRequirements.get(material); + float volume = levelRequirement == remainingBefore ? Main.growFirstSoundVolume : Main.growRepeatSoundVolume; if (levelupRequirements.get(material) <= 1) { levelupRequirements.remove(material); } else { @@ -94,10 +105,11 @@ public boolean grow(Material material) { for (Block block : blocks) { if (block.getType() == Material.SPRUCE_LEAVES || block.getType() == Material.SPRUCE_SAPLING) { Effects.GROW.playEffect(block.getLocation()); - for (int i = 0; i <= 3; i++) - location.getWorld().playSound(location, Sound.ENTITY_PLAYER_LEVELUP, 1, Main.RANDOM.nextFloat() + 0.2f); } } + if (volume > 0) { + location.getWorld().playSound(location, Sound.ENTITY_PLAYER_LEVELUP, volume, Main.RANDOM.nextFloat() + 0.2f); + } save(); return true; } @@ -120,7 +132,7 @@ public void playParticles() { if (blocks != null && blocks.size() > 0) { for (Block block : blocks) { - if(!block.getWorld().isChunkLoaded(block.getX() / 16, block.getZ() / 16)) + if (!block.getChunk().isLoaded()) continue; if (block.getType() == Material.SPRUCE_LEAVES) { if (level.getSwagEffect() != null) { @@ -160,7 +172,7 @@ private void levelUp() { FireworkMeta meta = fw.getFireworkMeta(); meta.addEffect(FireworkEffect.builder().trail(true).withColor(Color.RED).withFade(Color.LIME).withFlicker().with(Type.BURST).build()); fw.setFireworkMeta(meta); - fw.setMetadata("nodamage", new FixedMetadataValue(Main.getInstance(), true)); + fw.getPersistentDataContainer().set(Main.getNoDamageFireworkKey(), PersistentDataType.BYTE, (byte) 1); } build(); save(); @@ -179,9 +191,13 @@ public void unbuild() { } } location.clone().add(0, -1, 0).getBlock().setType(Material.GRASS_BLOCK); + blocks = null; } public void build() { + if (blocks != null && !blocks.isEmpty()) { + return; + } if (level.getStructureTemplate().canGrow(location)) { blocks = level.getStructureTemplate().build(location); for (Block block : blocks) { @@ -190,9 +206,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 +221,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 +255,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, TextUtils.success(LocaleManager.text("messages.tree.refund.chest", "Your tree resources were returned in a chest."))); + return; + } + + leftovers = putRefundsInContainer(Material.BARREL, refundItems); + if (leftovers != null) { + dropRefunds(leftovers); + notifyRefund(refundTarget, TextUtils.success(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, TextUtils.success(LocaleManager.text("messages.tree.refund.inventory", "Your tree resources were returned to your inventory."))); + } else { + notifyRefund(refundTarget, TextUtils.info(LocaleManager.text("messages.tree.refund.inventory-overflow", "Your tree resources were returned. Inventory overflow dropped at the tree."))); } + } + } - int count = 0; + private List collectRefundItems() { + List refundItems = new ArrayList<>(); + + TreeLevel cLevel = TreeLevel.SAPLING; + while (cLevel != null && cLevel != level) { + addRequirements(refundItems, cLevel.getLevelupRequirements()); + cLevel = cLevel.nextLevel; + } + + if (level.getLevelupRequirements() != null) { for (Entry currItem : level.getLevelupRequirements().entrySet()) { - if (getLevelupRequirements().containsKey(currItem.getKey())) - count = getLevelupRequirements().get(currItem.getKey()); - if (currItem.getValue() - count > 0) - inv.addItem(new ItemStack(currItem.getKey(), currItem.getValue() - count)); - count = 0; + int remaining = getLevelupRequirements().getOrDefault(currItem.getKey(), 0); + int spent = Math.max(0, currItem.getValue() - remaining); + if (spent > 0) { + refundItems.add(new ItemStack(currItem.getKey(), spent)); + } + } + } + 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()); } } - XMas.removeTree(this); + Map leftovers = inventory.addItem(clones.toArray(new ItemStack[0])); + return new ArrayList<>(leftovers.values()); + } + + private void dropRefunds(List items) { + if (items == null || items.isEmpty() || location.getWorld() == null) { + return; + } + Location dropLocation = location.clone().add(0.5, 0.5, 0.5); + for (ItemStack item : items) { + if (item != null && !item.getType().isAir()) { + location.getWorld().dropItemNaturally(dropLocation, item.clone()); + } + } + } + + private void notifyRefund(Player refundTarget, String message) { + if (refundTarget != null && refundTarget.isOnline()) { + TextUtils.sendRawMessage(refundTarget, message); + } } public long getPresentCounter() { diff --git a/src/main/java/ru/meloncode/xmas/Main.java b/src/main/java/ru/meloncode/xmas/Main.java index bf3dc8f..0aa4789 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,19 +9,25 @@ 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.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)); @@ -31,14 +37,37 @@ public class Main extends JavaPlugin implements Listener { static boolean resourceBack; static int MAX_TREE_COUNT; static boolean autoEnd; + static boolean particlesEnabled; + static float growFirstSoundVolume; + static float growRepeatSoundVolume; static long endTime; static boolean inProgress; private static int UPDATE_SPEED; private static int PARTICLES_DELAY; + private static NamespacedKey crystalKey; + private static 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; + + public record ReloadSummary( + String locale, + int giftCount, + int presentHeadCount, + int treeCount, + int treeOwnerCount, + boolean particlesEnabled, + boolean resourceBack, + boolean legacyAliasEnabled, + float growFirstSoundVolume, + float growRepeatSoundVolume + ) { + } public static Plugin getInstance() { return plugin; @@ -48,6 +77,44 @@ public static List getHeads() { return heads; } + public static NamespacedKey getCrystalKey() { + return crystalKey; + } + + public static NamespacedKey getNoDamageFireworkKey() { + return noDamageFireworkKey; + } + + public static NamespacedKey getPresentHeadKey() { + return presentHeadKey; + } + + 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 +123,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 +156,30 @@ public void onEnable() { date = sdf.parse(config.getString("core.holiday-ends.date")); endTime = date.getTime(); } catch (ParseException e1) { - TextUtils.sendConsoleMessage("Unable to load date"); + TextUtils.sendConsoleMessage(TextUtils.error(LocaleManager.text("console.config.unable-load-holiday-end-date", "Unable to load holiday end date"))); } defineTreeLevels(); for (World world : getServer().getWorlds()) { TreeSerializer.loadTrees(this, world); } - LocaleManager.loadLocale(locale); heads = config.getStringList("xmas.presents"); if (heads.size() == 0) { - getLogger().warning(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"); + for (String serializedItem : config.getStringList("xmas.gifts")) { + ItemStack item = deserializeItem(serializedItem); + if (item != null) { + gifts.add(item); + } else { + getLogger().warning(LocaleManager.text("console.gifts.load-item-failed", "Failed to load gift item: {item}", + "{item}", serializedItem)); } } if (gifts.size() == 0) { - getLogger().warning(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 +187,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); } @@ -160,28 +207,283 @@ public void onWorldUnload(WorldUnloadEvent event) { } } + 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(); + heads = config.getStringList("xmas.presents"); + + gifts = new ArrayList<>(); + for (String serializedItem : config.getStringList("xmas.gifts")) { + ItemStack item = deserializeItem(serializedItem); + if (item != null) { + gifts.add(item); + } else { + getLogger().warning(LocaleManager.text("console.gifts.deserialize-item-failed", "Failed to deserialize gift item: {item}", + "{item}", serializedItem)); + } + } + + 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, + XMas.getAllTrees().size(), + owners.size(), + particlesEnabled, + resourceBack, + getConfig().getBoolean("core.commands.legacy-command-enabled", true), + growFirstSoundVolume, + growRepeatSoundVolume + ); + } + + private void loadSoundConfig() { + growFirstSoundVolume = clampVolume(config.getDouble("core.sounds.grow.first-volume", 0.5)); + growRepeatSoundVolume = clampVolume(config.getDouble("core.sounds.grow.repeat-volume", 0.2)); + } + + private float clampVolume(double volume) { + if (Double.isNaN(volume)) { + return 0; + } + return (float) Math.max(0, Math.min(1, volume)); + } + + public void addGiftItem(ItemStack item) { + ItemStack gift = item.clone(); + String serializedItem = serializeItem(gift); + if (serializedItem == null) { + getLogger().warning(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 = config.getStringList("xmas.gifts"); + giftList.add(serializedItem); + config.set("xmas.gifts", giftList); + saveConfig(); + gifts.add(gift); + } + + public List getGiftItems() { + List snapshot = new ArrayList<>(); + if (gifts == null) { + return snapshot; + } + for (ItemStack gift : gifts) { + if (gift != null) { + snapshot.add(gift.clone()); + } + } + return snapshot; + } + + public ItemStack rollGiftItem() { + if (gifts == null || gifts.isEmpty()) { + return null; + } + return gifts.get(RANDOM.nextInt(gifts.size())).clone(); + } + + public ItemStack removeGiftItem(int displayIndex) { + if (displayIndex < 1) { + return null; + } + + List giftList = config.getStringList("xmas.gifts"); + int validIndex = 0; + for (int rawIndex = 0; rawIndex < giftList.size(); rawIndex++) { + ItemStack item = deserializeItem(giftList.get(rawIndex)); + if (item == null) { + continue; + } + validIndex++; + if (validIndex == displayIndex) { + giftList.remove(rawIndex); + config.set("xmas.gifts", giftList); + saveConfig(); + reloadGiftItemsFromConfig(); + return item; + } + } + return null; + } + + private void reloadGiftItemsFromConfig() { + gifts = new ArrayList<>(); + for (String serializedItem : config.getStringList("xmas.gifts")) { + ItemStack item = deserializeItem(serializedItem); + if (item != null) { + gifts.add(item); + } + } + } + + private String serializeItem(ItemStack item) { + try { + byte[] serializedBytes = item.serializeAsBytes(); + return Base64.getEncoder().encodeToString(serializedBytes); + } catch (Exception e) { + getLogger().severe(LocaleManager.text("console.gifts.serialize-item-failed", "Failed to serialize item: {error}", + "{error}", e.getMessage())); + return null; + } + } + + public static ItemStack deserializeItem(String serializedItem) { + if (serializedItem == null) { + return null; + } + String trimmed = serializedItem.trim(); + if (trimmed.isEmpty()) { + return null; + } + + ItemStack materialItem = deserializeMaterialItem(trimmed); + if (materialItem != null) { + return materialItem; + } + + if (trimmed.length() > MAX_SERIALIZED_GIFT_LENGTH) { + Bukkit.getLogger().severe(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) { + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.invalid-material-or-base64", "Invalid material name or Base64 gift item: {item}", + "{item}", trimmed)); + return null; + } catch (Exception e) { + Bukkit.getLogger().severe(LocaleManager.text("console.gifts.deserialize-error", "Failed to deserialize gift item: {error}", + "{error}", e.getMessage())); + return null; + } + } + + private static ItemStack deserializeMaterialItem(String serializedItem) { + String materialName = serializedItem; + int amount = 1; + if (serializedItem.contains(":")) { + String[] split = serializedItem.split(":", 2); + materialName = split[0].trim(); + try { + amount = Integer.parseInt(split[1].trim()); + } catch (NumberFormatException ignored) { + return null; + } + } + + Material material = Material.matchMaterial(materialName); + if (material == null || material.isLegacy() || !material.isItem()) { + return null; + } + int maxStackSize = Math.max(1, material.getMaxStackSize()); + amount = Math.max(1, Math.min(amount, maxStackSize)); + return new ItemStack(material, amount); + } + @Override public void onDisable() { + if (placeholderExpansion != null && placeholderExpansion.isRegistered()) { + placeholderExpansion.unregister(); + } if (XMas.getAllTrees().size() > 0) for (MagicTree tree : XMas.getAllTrees()) { tree.unbuild(); } } + private void registerPlaceholderApi() { + if (!getServer().getPluginManager().isPluginEnabled("PlaceholderAPI")) { + return; + } + placeholderExpansion = new XMasPlaceholderExpansion(this); + if (placeholderExpansion.register()) { + getLogger().info(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() { @@ -196,7 +498,11 @@ private void defineTreeLevels() { Map smallLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("small_tree.lvlup").getValues(false)); Map treeLevelUp = TreeSerializer.convertRequirementsMap(lvlups.getConfigurationSection("tree.lvlup").getValues(false)); - TreeLevel.MAGIC_TREE = new TreeLevel("magic_tree", Effects.TREE_WHITE_AMBIENT, Effects.TREE_SWAG, null, null, magic_delay, Collections.emptyMap(), new StructureTemplate(new HashMap() { + TreeLevel.MAGIC_TREE = new TreeLevel("magic_tree", + getParticleEffect("magic_tree", "ambient", Effects.TREE_WHITE_AMBIENT), + getParticleEffect("magic_tree", "swag", Effects.TREE_SWAG), + getParticleEffect("magic_tree", "body", null), + null, magic_delay, Collections.emptyMap(), new StructureTemplate(new HashMap() { private static final long serialVersionUID = 1L; { @@ -232,7 +538,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 +575,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 +606,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 +619,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/TreeSerializer.java b/src/main/java/ru/meloncode/xmas/TreeSerializer.java index b4108d9..02eb3cd 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,55 @@ import java.io.File; import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashSet; import java.util.Map; +import java.util.Set; import java.util.UUID; +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 record WorldMigrationReport( + String sourceWorld, + String targetWorld, + int matchedTrees, + boolean applied, + File backupFile + ) { + } public static void loadTrees(JavaPlugin plugin, World world) { try { @@ -31,7 +74,8 @@ public static void loadTrees(JavaPlugin plugin, World world) { if (trees.getConfigurationSection("trees") != null && trees.getConfigurationSection("trees").getKeys(false).size() > 0) { for (String cKey : trees.getConfigurationSection("trees").getKeys(false)) { - if (world.getName().equals(trees.getString("trees." + cKey + ".loc.world"))) { + String savedWorldName = trees.getString("trees." + cKey + ".loc.world"); + if (matchesSavedWorld(world, savedWorldName)) { try { treeUID = UUID.fromString(cKey); owner = UUID.fromString(trees.getString("trees." + cKey + ".owner")); @@ -51,16 +95,15 @@ public static void loadTrees(JavaPlugin plugin, World world) { XMas.addMagicTree(new MagicTree(owner, treeUID, level, loc, requirements, presentCounter, scheduledPresents)); } catch (Exception e) { - plugin.getLogger().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); } } @@ -76,13 +119,13 @@ public static void saveTree(MagicTree tree) { trees.set("trees." + cKey + ".loc.z", tree.getLocation().getZ()); if (tree.getLevelupRequirements() != null && tree.getLevelupRequirements().size() > 0) trees.createSection("trees." + cKey + ".levelup", tree.getLevelupRequirements()); + trees.set("trees." + cKey + ".present_counter", tree.getPresentCounter()); + trees.set("trees." + cKey + ".scheduled_presents", tree.getScheduledPresents()); try { trees.save(treesFile); } catch (IOException e) { - e.printStackTrace(); + Main.getInstance().getLogger().log(Level.SEVERE, 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 +133,96 @@ 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 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 +232,144 @@ 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 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..20bafbb 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; @@ -35,6 +35,10 @@ public static void createMagicTree(Player player, Location loc) { public static void addMagicTree(MagicTree tree) { trees.put(tree.getTreeUID(), tree); + trees_byChunk.computeIfAbsent(LocationUtils.getChunkKey(tree.getLocation()), aLong -> new ArrayList<>()); + List chunkTrees = trees_byChunk.get(LocationUtils.getChunkKey(tree.getLocation())); + chunkTrees.removeIf(existingTree -> existingTree.getTreeUID().equals(tree.getTreeUID())); + chunkTrees.add(tree); tree.build(); } @@ -42,39 +46,58 @@ public static Collection getAllTrees() { return trees.values(); } - @Nullable public static Collection getAllTreesInChunk(Chunk chunk) { return trees_byChunk.get(LocationUtils.getChunkKey(chunk)); } public static void removeTree(MagicTree tree) { - tree.unbuild(); + removeTree(tree, true); + } + + public static void removeTree(MagicTree tree, boolean unbuild) { + if (unbuild) { + tree.unbuild(); + } TreeSerializer.removeTree(tree); trees.remove(tree.getTreeUID()); - trees_byChunk.remove(LocationUtils.getChunkKey(tree.getLocation())); + List chunkTrees = trees_byChunk.get(LocationUtils.getChunkKey(tree.getLocation())); + if (chunkTrees != null) { + chunkTrees.remove(tree); + if (chunkTrees.isEmpty()) { + trees_byChunk.remove(LocationUtils.getChunkKey(tree.getLocation())); + } + } } public static void processPresent(Block block, Player player) { - if (block.getType() == Material.PLAYER_HEAD) { - Skull skull = (Skull) block.getState(); - - 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); - } - } - block.setType(Material.AIR); - } + if (!isPresentHead(block)) { + return; + } + + Location loc = block.getLocation(); + World world = loc.getWorld(); + if (world != null) { + if (RANDOM.nextFloat() < Main.LUCK_CHANCE || !Main.LUCK_CHANCE_ENABLED) { + world.dropItemNaturally(loc, new ItemStack(Main.gifts.get(RANDOM.nextInt(Main.gifts.size())))); + Effects.TREE_SWAG.playEffect(loc); + TextUtils.sendMessage(player, LocaleManager.GIFT_LUCK); + } else { + Effects.SMOKE.playEffect(loc); + world.dropItemNaturally(loc, new ItemStack(Material.COAL)); + TextUtils.sendMessage(player, LocaleManager.GIFT_FAIL); + } + block.setType(Material.AIR); + } + } + + 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) { diff --git a/src/main/java/ru/meloncode/xmas/XMasCommand.java b/src/main/java/ru/meloncode/xmas/XMasCommand.java index 943ee1b..d21333e 100644 --- a/src/main/java/ru/meloncode/xmas/XMasCommand.java +++ b/src/main/java/ru/meloncode/xmas/XMasCommand.java @@ -1,20 +1,62 @@ package ru.meloncode.xmas; import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.Sound; +import org.bukkit.World; +import org.bukkit.block.Block; +import org.bukkit.command.CommandMap; import org.bukkit.command.Command; import org.bukkit.command.CommandExecutor; import org.bukkit.command.CommandSender; +import org.bukkit.command.PluginCommand; +import org.bukkit.command.SimpleCommandMap; +import org.bukkit.command.TabCompleter; import org.bukkit.entity.Player; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer; +import ru.meloncode.xmas.utils.TextUtils; +import java.io.File; +import java.io.IOException; +import java.lang.reflect.Constructor; +import java.lang.reflect.Field; import java.text.SimpleDateFormat; -import java.util.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"; + public static final String LEGACY_COMMAND = "xmas"; + public static final String PERMISSION_ADMIN = "onembxmastree.admin"; + public static final String PERMISSION_STATUS = "onembxmastree.command.status"; + public static final String PERMISSION_HELP = "onembxmastree.command.help"; + public static final String PERMISSION_GIVE = "onembxmastree.command.give"; + public static final String PERMISSION_GIFTS = "onembxmastree.command.gifts"; + public static final String PERMISSION_ADDHAND = "onembxmastree.command.addhand"; + public static final String PERMISSION_RELOAD = "onembxmastree.command.reload"; + public static final String PERMISSION_DEBUG = "onembxmastree.command.debug"; + public static final String PERMISSION_DEBUG_TOGGLE = "onembxmastree.command.debug.toggle"; + public static final String PERMISSION_INSPECT = "onembxmastree.command.inspect"; + public static final String PERMISSION_TEST = "onembxmastree.command.test"; + public static final String PERMISSION_DATA = "onembxmastree.command.data"; + public static final String PERMISSION_END = "onembxmastree.command.end"; + public static final String PERMISSION_TREE_OVERRIDE = "onembxmastree.tree.override"; + private static final List COMMANDS = Arrays.asList("help", "give", "end", "gifts", "reload", "addhand", "debug", "inspect", "test", "data"); + private static final Set DEBUG_TOGGLE_KEYS = new LinkedHashSet<>(Arrays.asList( + "core.commands.legacy-command-enabled", + "core.plugin-enabled", + "core.holiday-ends.enabled", + "core.holiday-ends.resource-back", + "core.particles-enabled", + "xmas.luck.enabled" + )); + private static final Map DEBUG_SECTIONS = createDebugSections(); + private static XMasCommand registeredExecutor; + private static PluginCommand legacyAliasCommand; private final Main plugin; @@ -23,47 +65,134 @@ private XMasCommand(Main plugin) { } public static void register(Main plugin) { - plugin.getCommand("xmas").setExecutor(new XMasCommand(plugin)); + registeredExecutor = new XMasCommand(plugin); + PluginCommand primaryCommand = plugin.getCommand(PRIMARY_COMMAND); + if (primaryCommand == null) { + throw new IllegalStateException("Primary command '/" + PRIMARY_COMMAND + "' is not defined in plugin.yml"); + } + primaryCommand.setExecutor(registeredExecutor); + primaryCommand.setTabCompleter(registeredExecutor); + refreshCommandConfiguration(plugin); + } + + public static void refreshCommandConfiguration(Main plugin) { + if (registeredExecutor == null) { + register(plugin); + return; + } + syncLegacyAlias(plugin, registeredExecutor); } @Override public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { if (args.length > 0) { - String action = args[0].toLowerCase(); + String action = args[0].toLowerCase(Locale.ENGLISH); switch (action) { case "help": { - for (String line : LocaleManager.COMMAND_HELP) { - sender.sendMessage(GREEN + line); + if (!hasPermission(sender, PERMISSION_HELP)) { + sendNoPermission(sender); + break; + } + for (String line : getHelpLines()) { + TextUtils.sendRawMessage(sender, line); } break; } case "give": { + if (!hasPermission(sender, PERMISSION_GIVE)) { + sendNoPermission(sender); + break; + } if (args.length > 1) { String name = args[1]; Player player = Bukkit.getPlayer(name); 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(); - } + if (!hasPermission(sender, PERMISSION_GIFTS)) { + sendNoPermission(sender); + break; } - Bukkit.broadcastMessage(LocaleManager.COMMAND_GIVEAWAY); + 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 "addhand": { + if (!(sender instanceof Player player)) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.player-only", TextUtils.error("Only players can use this command."))); + break; + } + if (!hasPermission(sender, PERMISSION_ADDHAND)) { + sendNoPermission(sender); + break; + } + ItemStack item = player.getInventory().getItemInMainHand(); + if (item.getType().isAir()) { + TextUtils.sendRawMessage(player, replaceToken( + LocaleManager.text("command.hold-item-first", TextUtils.error("Hold an item before running {command}.")), + "command", + commandPath("addhand") + )); + break; + } + plugin.addGiftItem(item.clone()); + TextUtils.sendRawMessage(player, LocaleManager.text("command.gift-added", TextUtils.success("Added the held item to the gift list."))); + break; + } + case "debug": { + handleDebug(sender, args); break; } @@ -71,13 +200,560 @@ 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"), args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("sound")) { + suggestions.addAll(filterStartingWith(Arrays.asList("first", "repeat"), args[2])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("particle")) { + suggestions.addAll(filterStartingWith(treeLevelNames(), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("particle")) { + suggestions.addAll(filterStartingWith(Arrays.asList("all", "ambient", "swag", "body"), args[3])); + } else if ((args.length == 4 && args[1].equalsIgnoreCase("sound")) || (args.length == 5 && args[1].equalsIgnoreCase("particle"))) { + String typed = args[args.length - 1].toLowerCase(Locale.ENGLISH); + for (Player player : Bukkit.getOnlinePlayers()) { + if (player.getName().toLowerCase(Locale.ENGLISH).startsWith(typed)) { + suggestions.add(player.getName()); + } + } + } + } else if (args[0].equalsIgnoreCase("data")) { + if (args.length == 2) { + suggestions.addAll(filterStartingWith(Arrays.asList("backup", "validate", "migrate-world"), args[1])); + } else if (args.length == 3 && args[1].equalsIgnoreCase("migrate-world")) { + suggestions.addAll(filterStartingWith(getWorldNames(), args[2])); + } else if (args.length == 4 && args[1].equalsIgnoreCase("migrate-world")) { + suggestions.addAll(filterStartingWith(getWorldNames(), args[3])); + } else if (args.length == 5 && args[1].equalsIgnoreCase("migrate-world")) { + suggestions.addAll(filterStartingWith(Arrays.asList("dry-run", "apply"), args[4])); + } + } else if (args[0].equalsIgnoreCase("gifts")) { + if (args.length == 2) { + suggestions.addAll(filterStartingWith(Arrays.asList("list", "roll", "remove", "spawn"), args[1])); + } + } else if (args[0].equalsIgnoreCase("debug")) { + if (args.length == 2) { + List debugSuggestions = new ArrayList<>(DEBUG_SECTIONS.keySet()); + 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")) { + spawnPresentsUnderTrees(); + return; + } + if (args[1].equalsIgnoreCase("list")) { + int page = parsePositiveInt(args.length >= 3 ? args[2] : null, 1); + sendGiftList(sender, page); + return; + } + if (args[1].equalsIgnoreCase("roll")) { + sendGiftRoll(sender); + return; + } + if (args[1].equalsIgnoreCase("remove")) { + handleGiftRemove(sender, args); + return; + } + + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.unknown-action", "Unknown Gift Action"), TextUtils.error(args[1]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.usage", "Usage"), TextUtils.command(commandPath("gifts list")))); + } + + private void spawnPresentsUnderTrees() { + Random random = new Random(); + for (MagicTree magicTree : XMas.getAllTrees()) { + for (int i = 0; i < 3 + random.nextInt(4); i++) { + magicTree.spawnPresent(); + } + } + Bukkit.broadcast(TextUtils.parse(LocaleManager.COMMAND_GIVEAWAY)); + } + + private void sendGiftList(CommandSender sender, int page) { + List giftItems = plugin.getGiftItems(); + int pageSize = 8; + int pageCount = Math.max(1, (int) Math.ceil(giftItems.size() / (double) pageSize)); + page = Math.max(1, Math.min(page, pageCount)); + int start = (page - 1) * pageSize; + int end = Math.min(giftItems.size(), start + pageSize); + + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.title", TextUtils.title("Gift Pool")) + " " + TextUtils.muted("(" + page + "/" + pageCount + ")")); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.total", "Total"), TextUtils.text(Integer.toString(giftItems.size())))); + if (giftItems.isEmpty()) { + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.values.empty", TextUtils.warning("No gifts are configured."))); + return; + } + for (int index = start; index < end; index++) { + TextUtils.sendRawMessage(sender, formatStyledListEntry(Integer.toString(index + 1), formatGiftItem(giftItems.get(index)))); + } + if (page < pageCount) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.debug.labels.next", "Next"), TextUtils.command(commandPath("gifts list " + (page + 1))))); + } + } + + private void sendGiftRoll(CommandSender sender) { + ItemStack gift = plugin.rollGiftItem(); + if (gift == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("ui.gifts.values.empty", TextUtils.warning("No gifts are configured."))); + return; + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.rolled", "Rolled"), formatGiftItem(gift))); + } + + private void handleGiftRemove(CommandSender sender, String[] args) { + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.usage", "Usage"), TextUtils.command(commandPath("gifts remove ")))); + return; + } + int index = parsePositiveInt(args[2], -1); + if (index < 1) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.index", "Index"), TextUtils.error(args[2]))); + return; + } + ItemStack removed = plugin.removeGiftItem(index); + if (removed == null) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.index", "Index"), TextUtils.error(Integer.toString(index)))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.try", "Try"), TextUtils.command(commandPath("gifts list")))); + return; + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.gifts.labels.removed", "Removed"), formatGiftItem(removed))); + } + + private void handleTest(CommandSender sender, String[] args) { + if (args.length < 2) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test sound first|repeat [player]")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test particle [all|ambient|swag|body] [player]")))); + return; + } + + if (args[1].equalsIgnoreCase("sound")) { + handleTestSound(sender, args); + return; + } + if (args[1].equalsIgnoreCase("particle")) { + handleTestParticle(sender, args); + return; + } + + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.unknown-test", "Unknown Test"), TextUtils.error(args[1]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.try", "Try"), TextUtils.command(commandPath("test sound first")))); + } + + private void handleTestSound(CommandSender sender, String[] args) { + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test sound first|repeat [player]")))); + return; + } + + boolean repeat; + if (args[2].equalsIgnoreCase("first")) { + repeat = false; + } else if (args[2].equalsIgnoreCase("repeat")) { + repeat = true; + } else { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.sound", "Sound"), TextUtils.error(args[2]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.try", "Try"), TextUtils.command(commandPath("test sound first")))); + return; + } + + Player target = resolveCommandTarget(sender, args, 3, commandPath("test sound first ")); + if (target == null) { + return; + } + + float volume = repeat ? Main.growRepeatSoundVolume : Main.growFirstSoundVolume; + if (volume > 0) { + target.playSound(target.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, volume, 0.8f); + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.sound", "Sound"), TextUtils.text(repeat ? "repeat" : "first"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.target", "Target"), TextUtils.text(target.getName()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.volume", "Volume"), TextUtils.text(Float.toString(volume)))); + } + + private void handleTestParticle(CommandSender sender, String[] args) { + if (args.length < 3) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(commandPath("test particle [all|ambient|swag|body] [player]")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.levels", "Levels"), TextUtils.text(String.join(", ", treeLevelNames())))); + return; + } + + TreeLevel level = treeLevelByName(args[2]); + if (level == null) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.level", "Level"), TextUtils.error(args[2]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.levels", "Levels"), TextUtils.text(String.join(", ", treeLevelNames())))); + return; + } + + String effectName = "all"; + int targetArgIndex = 3; + if (args.length >= 4 && isParticleEffectName(args[3])) { + effectName = args[3].toLowerCase(Locale.ENGLISH); + targetArgIndex = 4; + } + + Player target = resolveCommandTarget(sender, args, targetArgIndex, commandPath("test particle [all|ambient|swag|body] ")); + if (target == null) { + return; + } + + int played = playParticlePreview(level, effectName, target.getLocation()); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.particle", "Particle"), TextUtils.text(level.getLevelName() + " " + effectName))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.target", "Target"), TextUtils.text(target.getName()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.effects-played", "Effects Played"), TextUtils.text(Integer.toString(played)))); + if (played == 0) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.test.no-particles", TextUtils.warning("No enabled particle effects matched that preview."))); + } + } + + private void handleData(CommandSender sender, String[] args) { + if (args.length < 2) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data backup")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data validate")))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data migrate-world [dry-run|apply]")))); + return; + } + + if (args[1].equalsIgnoreCase("backup")) { + handleDataBackup(sender); + return; + } + if (args[1].equalsIgnoreCase("validate")) { + handleDataValidate(sender); + return; + } + if (args[1].equalsIgnoreCase("migrate-world")) { + handleDataMigrateWorld(sender, args); + return; + } + + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.unknown-action", "Unknown Data Action"), TextUtils.error(args[1]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.try", "Try"), TextUtils.command(commandPath("data validate")))); + } + + private void handleDataBackup(CommandSender sender) { + try { + File backupFile = TreeSerializer.backupTreesFile(); + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-created", TextUtils.success("Tree data backup created."))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.file", "File"), TextUtils.text(backupFile.getPath()))); + } catch (IOException exception) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.backup-failed", TextUtils.error("Unable to back up trees.yml."))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.error", "Error"), TextUtils.error(exception.getMessage()))); + } + } + + private void handleDataValidate(CommandSender sender) { + TreeSerializer.TreeDataValidationReport report = TreeSerializer.validateTreesFile(); + TextUtils.sendRawMessage(sender, formatSectionTitle(LocaleManager.text("ui.data.title", "Tree Data Validation"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.file", "File"), TextUtils.text(TreeSerializer.getTreesFile().getPath()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.stored-trees", "Stored Trees"), TextUtils.text(Integer.toString(report.storedTreeCount())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.loaded-trees", "Loaded Trees"), TextUtils.text(Integer.toString(report.loadedTreeCount())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.status", "Status"), report.hasWarnings() + ? LocaleManager.text("ui.data.values.warnings", TextUtils.warning("warnings")) + : LocaleManager.text("ui.data.values.clean", TextUtils.success("clean")))); + sendValidationSet(sender, "invalid-tree-ids", report.invalidTreeIds()); + sendValidationSet(sender, "invalid-owners", report.invalidOwners()); + sendValidationSet(sender, "invalid-levels", report.invalidLevels()); + sendValidationSet(sender, "invalid-locations", report.invalidLocations()); + sendValidationSet(sender, "missing-worlds", report.missingWorlds()); + sendValidationSet(sender, "invalid-requirements", report.invalidRequirements()); + sendValidationSet(sender, "duplicate-locations", report.duplicateLocations()); + } + + private void handleDataMigrateWorld(CommandSender sender, String[] args) { + if (args.length < 4) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data migrate-world [dry-run|apply]")))); + return; + } + + String sourceWorld = args[2]; + String targetWorld = args[3]; + boolean apply = args.length >= 5 && args[4].equalsIgnoreCase("apply"); + if (args.length >= 5 && !args[4].equalsIgnoreCase("apply") && !args[4].equalsIgnoreCase("dry-run")) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.mode", "Mode"), TextUtils.error(args[4]))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.usage", "Usage"), TextUtils.command(commandPath("data migrate-world [dry-run|apply]")))); + return; + } + + try { + TreeSerializer.WorldMigrationReport report = TreeSerializer.migrateWorldName(sourceWorld, targetWorld, apply); + TextUtils.sendRawMessage(sender, formatSectionTitle(LocaleManager.text("ui.data.world-migration-title", "Tree World Migration"))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.source-world", "Source World"), TextUtils.text(report.sourceWorld()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.target-world", "Target World"), TextUtils.text(report.targetWorld()))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.matched-trees", "Matched Trees"), TextUtils.text(Integer.toString(report.matchedTrees())))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.mode", "Mode"), TextUtils.text(report.applied() ? "apply" : "dry-run"))); + if (report.backupFile() != null) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.backup", "Backup"), TextUtils.text(report.backupFile().getPath()))); + } + if (!report.applied()) { + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.apply-command", "Apply Command"), TextUtils.command(commandPath("data migrate-world " + sourceWorld + " " + targetWorld + " apply")))); + } else { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.migration-applied", TextUtils.warning("World migration saved. Restart the server before testing migrated trees."))); + } + } catch (IOException exception) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.data.migration-failed", TextUtils.error("Unable to migrate tree world names."))); + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.data.labels.error", "Error"), TextUtils.error(exception.getMessage()))); + } + } + + private void sendValidationSet(CommandSender sender, String labelKey, Set values) { + if (values == null || values.isEmpty()) { + return; + } + String label = LocaleManager.text("ui.data.labels." + labelKey, labelKey); + TextUtils.sendRawMessage(sender, formatKeyValue(label, TextUtils.warning(joinLimited(values, 8)))); + } + + private String joinLimited(Set values, int limit) { + List limited = new ArrayList<>(); + int index = 0; + for (String value : values) { + if (index >= limit) { + break; + } + limited.add(value); + index++; + } + if (values.size() > limit) { + limited.add("+" + (values.size() - limit) + " more"); + } + return String.join(", ", limited); + } + + private int parsePositiveInt(String rawValue, int fallback) { + if (rawValue == null || rawValue.isBlank()) { + return fallback; + } + try { + return Integer.parseInt(rawValue); + } catch (NumberFormatException exception) { + return fallback; + } + } + + private Player resolveCommandTarget(CommandSender sender, String[] args, int targetArgIndex, String consoleUsage) { + if (args.length > targetArgIndex) { + Player target = Bukkit.getPlayer(args[targetArgIndex]); + if (target == null) { + TextUtils.sendMessage(sender, LocaleManager.COMMAND_PLAYER_OFFLINE); + } + return target; + } + if (sender instanceof Player player) { + return player; + } + TextUtils.sendRawMessage(sender, formatKeyValue(LocaleManager.text("ui.test.labels.usage", "Usage"), TextUtils.command(consoleUsage))); + return null; + } + + private int playParticlePreview(TreeLevel level, String effectName, Location location) { + int played = 0; + if ((effectName.equals("all") || effectName.equals("ambient")) && level.getAmbientEffect() != null) { + level.getAmbientEffect().playEffect(location); + played++; + } + if ((effectName.equals("all") || effectName.equals("swag")) && level.getSwagEffect() != null) { + level.getSwagEffect().playEffect(location); + played++; + } + if ((effectName.equals("all") || effectName.equals("body")) && level.getBodyEffect() != null) { + level.getBodyEffect().playEffect(location); + played++; + } + return played; + } + + private boolean isParticleEffectName(String value) { + return value != null + && (value.equalsIgnoreCase("all") + || value.equalsIgnoreCase("ambient") + || value.equalsIgnoreCase("swag") + || value.equalsIgnoreCase("body")); + } + + private TreeLevel treeLevelByName(String levelName) { + try { + return TreeLevel.fromString(levelName); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private List treeLevelNames() { + return Arrays.asList("sapling", "small_tree", "tree", "magic_tree"); + } + + private void handleInspect(CommandSender sender, String[] args) { + MagicTree tree = null; + if (args.length >= 2 && !args[1].equalsIgnoreCase("nearest")) { + tree = findTreeByUuid(args[1]); + if (tree == null) { + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.requested", "Requested"), + TextUtils.error(args[1]) + )); + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.try", "Try"), + TextUtils.command(commandPath("inspect nearest")) + )); + return; + } + } else if (args.length >= 3 && args[1].equalsIgnoreCase("nearest")) { + Player target = Bukkit.getPlayer(args[2]); + if (target == null) { + TextUtils.sendMessage(sender, LocaleManager.COMMAND_PLAYER_OFFLINE); + return; + } + tree = findNearestTree(target.getLocation(), 16); + } else if (sender instanceof Player player) { + tree = findTargetTree(player); + if (tree == null) { + tree = findNearestTree(player.getLocation(), 8); + } + } else { + TextUtils.sendRawMessage(sender, formatKeyValue( + LocaleManager.text("ui.inspect.labels.usage", "Usage"), + TextUtils.command(commandPath("inspect ")) + )); + return; + } + + if (tree == null) { + TextUtils.sendRawMessage(sender, LocaleManager.text("command.inspect.not-found", TextUtils.warning("No XMas Tree found nearby or in your line of sight."))); + return; + } + for (String line : getInspectLines(tree)) { + TextUtils.sendRawMessage(sender, line); + } + } + + private MagicTree findTreeByUuid(String rawUuid) { + try { + return XMas.getTree(UUID.fromString(rawUuid)); + } catch (IllegalArgumentException exception) { + return null; + } + } + + private MagicTree findTargetTree(Player player) { + Block targetBlock = player.getTargetBlockExact(8); + return MagicTree.getTreeByBlock(targetBlock); + } + + private MagicTree findNearestTree(Location location, double range) { + if (location == null || location.getWorld() == null) { + return null; + } + double maxDistanceSquared = range * range; + MagicTree nearest = null; + double nearestDistanceSquared = Double.MAX_VALUE; + for (MagicTree tree : XMas.getAllTrees()) { + Location treeLocation = tree.getLocation(); + if (treeLocation.getWorld() == null || !treeLocation.getWorld().equals(location.getWorld())) { + continue; + } + double distanceSquared = treeLocation.distanceSquared(location); + if (distanceSquared <= maxDistanceSquared && distanceSquared < nearestDistanceSquared) { + nearest = tree; + nearestDistanceSquared = distanceSquared; + } + } + return nearest; + } + + private List getInspectLines(MagicTree tree) { + List lines = new ArrayList<>(); + OfflinePlayer owner = Bukkit.getOfflinePlayer(tree.getOwner()); + Location location = tree.getLocation(); + String ownerName = owner.getName() != null ? owner.getName() : LocaleManager.text("ui.inspect.values.unknown-owner", "unknown"); + + lines.add(LocaleManager.text("ui.inspect.title", TextUtils.title(TextUtils.displayName() + " Tree Inspect"))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.owner", "Owner"), TextUtils.text(ownerName) + TextUtils.muted(" (" + tree.getOwner() + ")"))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.tree-id", "Tree ID"), TextUtils.text(tree.getTreeUID().toString()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.level", "Level"), TextUtils.text(tree.getLevel().getLevelName()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.location", "Location"), TextUtils.text(formatLocation(location)))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.can-level-up", "Can Level Up"), TextUtils.booleanValue(tree.canLevelUp()))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.present-timer", "Present Timer"), TextUtils.text(Long.toString(tree.getPresentCounter())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.scheduled-presents", "Scheduled Presents"), TextUtils.text(Integer.toString(tree.getScheduledPresents())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.remaining", "Remaining Requirements"), TextUtils.text(formatRequirements(tree.getLevelupRequirements())))); + lines.add(formatKeyValue(LocaleManager.text("ui.inspect.labels.refund-preview", "Refund Preview"), TextUtils.text(formatItems(tree.getRefundPreviewItems())))); + return lines; + } + + private List getReloadSummaryLines(Main.ReloadSummary summary) { + List lines = new ArrayList<>(); + lines.add(formatSectionTitle(LocaleManager.text("ui.reload.title", "Reload Summary"))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.locale", "Locale"), TextUtils.text(summary.locale()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.gifts", "Gifts"), TextUtils.text(Integer.toString(summary.giftCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.present-heads", "Present Heads"), TextUtils.text(Integer.toString(summary.presentHeadCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.trees", "Trees"), TextUtils.text(Integer.toString(summary.treeCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.owners", "Owners"), TextUtils.text(Integer.toString(summary.treeOwnerCount())))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.particles", "Particles"), TextUtils.booleanValue(summary.particlesEnabled()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.resource-back", "Resource Back"), TextUtils.booleanValue(summary.resourceBack()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.legacy-alias", "Legacy Alias"), TextUtils.booleanValue(summary.legacyAliasEnabled()))); + lines.add(formatKeyValue(LocaleManager.text("ui.reload.labels.sounds", "Sounds"), TextUtils.text("first=" + summary.growFirstSoundVolume() + ", repeat=" + summary.growRepeatSoundVolume()))); + return lines; + } + private void sendStatus(CommandSender sender) { + for (String line : getStatusLines()) { + TextUtils.sendRawMessage(sender, line); + } + } + private List getStatusLines() { int treeCount = XMas.getAllTrees().size(); Set owners = new HashSet<>(); for (MagicTree magicTree : XMas.getAllTrees()) { @@ -85,19 +761,562 @@ 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("give "), LocaleManager.text("ui.command-descriptions.give", "give a Christmas Crystal"))); + commandsPage.add(formatListEntry(commandPath("gifts"), LocaleManager.text("ui.command-descriptions.gifts", "spawn presents under all trees"))); + commandsPage.add(formatListEntry(commandPath("gifts list [page]"), LocaleManager.text("ui.command-descriptions.gifts-list", "list configured gifts"))); + commandsPage.add(formatListEntry(commandPath("gifts roll"), LocaleManager.text("ui.command-descriptions.gifts-roll", "preview a random gift roll"))); + commandsPage.add(formatListEntry(commandPath("gifts remove "), LocaleManager.text("ui.command-descriptions.gifts-remove", "remove a configured gift"))); + commandsPage.add(formatListEntry(commandPath("addhand"), LocaleManager.text("ui.command-descriptions.addhand", "add held item to gifts"))); + commandsPage.add(formatListEntry(commandPath("reload"), LocaleManager.text("ui.command-descriptions.reload", "reload config and locale"))); + commandsPage.add(formatListEntry(commandPath("inspect"), LocaleManager.text("ui.command-descriptions.inspect", "inspect the tree you are looking at"))); + commandsPage.add(formatListEntry(commandPath("test sound first|repeat [player]"), LocaleManager.text("ui.command-descriptions.test-sound", "preview grow sounds"))); + commandsPage.add(formatListEntry(commandPath("test particle [effect] [player]"), LocaleManager.text("ui.command-descriptions.test-particle", "preview configured particles"))); + commandsPage.add(formatListEntry(commandPath("data backup"), LocaleManager.text("ui.command-descriptions.data-backup", "back up trees.yml"))); + commandsPage.add(formatListEntry(commandPath("data validate"), LocaleManager.text("ui.command-descriptions.data-validate", "validate trees.yml"))); + commandsPage.add(formatListEntry(commandPath("data migrate-world [dry-run|apply]"), LocaleManager.text("ui.command-descriptions.data-migrate-world", "migrate saved tree world names"))); + commandsPage.add(formatListEntry(commandPath("end"), LocaleManager.text("ui.command-descriptions.end", "end the event"))); + commandsPage.add(formatListEntry(commandPath("debug"), LocaleManager.text("ui.command-descriptions.debug", "open the status debug section"))); + commandsPage.add(formatListEntry(commandPath("debug [section|page]"), LocaleManager.text("ui.command-descriptions.debug-section", "extended debug output by category"))); + commandsPage.add(formatListEntry(commandPath("debug toggle true|false"), LocaleManager.text("ui.command-descriptions.debug-toggle", "toggle global booleans"))); + if (isLegacyAliasEnabled()) { + commandsPage.add(formatKeyValue(LocaleManager.text("ui.status.labels.legacy-alias", "Legacy Alias"), TextUtils.command("/" + LEGACY_COMMAND))); + } + sections.put("commands", commandsPage); + + List permissionsPage = new ArrayList<>(); + permissionsPage.add(""); + permissionsPage.add(formatSectionTitle(debugSectionDisplayName("permissions"))); + for (Map.Entry permission : createPermissionDescriptions().entrySet()) { + permissionsPage.add(formatListEntry(permission.getKey(), permission.getValue())); } - 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"); + 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); + + return sections; + } + + private String normalizeDebugSection(String requested) { + if (requested == null || requested.isBlank()) { + return "status"; + } + return switch (requested.toLowerCase(Locale.ENGLISH)) { + case "status" -> "status"; + case "commands", "command" -> "commands"; + case "permissions", "permission", "perms", "perm" -> "permissions"; + case "placeholders", "placeholder", "papi" -> "placeholders"; + case "config", "configuration", "cfg", "toggles" -> "config"; + default -> null; + }; + } + + 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("/" + LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND)); + } + addMissingBundledHelpLines(lines); + if (isLegacyAliasEnabled()) { + lines.add(LocaleManager.text( + "command.legacy-alias-enabled", + TextUtils.muted("Legacy alias: ") + TextUtils.command("/" + LEGACY_COMMAND) + TextUtils.muted(" still works.") + )); + } + return lines; + } + + private 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("/" + LEGACY_COMMAND + "(?![A-Za-z])", "/" + PRIMARY_COMMAND); + String commandNeedle = findCommandNeedle(normalizedLine); + if (commandNeedle != null && lines.stream().noneMatch(line -> line.contains(commandNeedle))) { + lines.add(normalizedLine); + } + } + } + + private String findCommandNeedle(String line) { + List commandNeedles = List.of( + commandPath("debug toggle"), + commandPath("debug status"), + commandPath("debug"), + commandPath("test particle"), + commandPath("test sound"), + commandPath("data validate"), + commandPath("data migrate-world"), + commandPath("data backup"), + commandPath("gifts remove"), + commandPath("gifts roll"), + commandPath("gifts list"), + commandPath("inspect"), + commandPath("reload"), + commandPath("addhand"), + commandPath("gifts"), + commandPath("give"), + commandPath("help"), + commandPath("end"), + commandPath("") + ); + for (String commandNeedle : commandNeedles) { + if (line.contains(commandNeedle)) { + return commandNeedle; + } + } + return null; + } + + private boolean isLegacyAliasEnabled() { + return plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true); + } + + public static boolean canOverrideTree(CommandSender sender) { + return hasPermission(sender, PERMISSION_TREE_OVERRIDE); + } + + private static void syncLegacyAlias(Main plugin, XMasCommand executor) { + CommandMap commandMap = getCommandMap(); + if (commandMap == null) { + plugin.getLogger().warning(LocaleManager.text("console.alias.command-map-unavailable", "Unable to access the Bukkit command map. Skipping legacy /xmas alias registration.")); + return; + } + if (!plugin.getConfig().getBoolean("core.commands.legacy-command-enabled", true)) { + unregisterLegacyAlias(commandMap); + return; + } + + Command existing = commandMap.getCommand(LEGACY_COMMAND); + if (existing instanceof PluginCommand existingPluginCommand) { + if (existingPluginCommand.getPlugin() == plugin) { + existingPluginCommand.setExecutor(executor); + existingPluginCommand.setTabCompleter(executor); + legacyAliasCommand = existingPluginCommand; + return; + } + plugin.getLogger().warning(LocaleManager.text("console.alias.owned-by-plugin", "Legacy alias '/{alias}' is already owned by plugin '{plugin}'. Skipping alias registration.", + "{alias}", LEGACY_COMMAND, + "{plugin}", existingPluginCommand.getPlugin().getName())); + return; + } + if (existing != null) { + plugin.getLogger().warning(LocaleManager.text("console.alias.registered-by-other", "Legacy alias '/{alias}' is already registered by another command source. Skipping alias registration.", + "{alias}", LEGACY_COMMAND)); + return; + } + + PluginCommand aliasCommand = createPluginCommand(plugin, LEGACY_COMMAND); + if (aliasCommand == null) { + plugin.getLogger().warning(LocaleManager.text("console.alias.create-failed", "Unable to create the legacy /xmas alias command.")); + return; + } + aliasCommand.setDescription("Legacy alias for /" + PRIMARY_COMMAND); + aliasCommand.setUsage("/" + LEGACY_COMMAND + " [help|give|gifts|addhand|reload|inspect|test|data|debug [section|page]|end]"); + aliasCommand.setPermission(null); + aliasCommand.setExecutor(executor); + aliasCommand.setTabCompleter(executor); + commandMap.register(plugin.getPluginMeta().getName().toLowerCase(Locale.ENGLISH), aliasCommand); + legacyAliasCommand = aliasCommand; + plugin.getLogger().info(LocaleManager.text("console.alias.registered", "Registered legacy alias '/{alias}' for '/{primary}'.", + "{alias}", LEGACY_COMMAND, + "{primary}", PRIMARY_COMMAND)); + } + + private static void unregisterLegacyAlias(CommandMap commandMap) { + if (legacyAliasCommand == null) { + Command existing = commandMap.getCommand(LEGACY_COMMAND); + if (existing instanceof PluginCommand existingPluginCommand && existingPluginCommand.getPlugin() == Main.getInstance()) { + legacyAliasCommand = existingPluginCommand; + } + } + if (legacyAliasCommand == null) { + return; + } + legacyAliasCommand.unregister(commandMap); + if (commandMap instanceof SimpleCommandMap simpleCommandMap) { + try { + Field knownCommandsField = SimpleCommandMap.class.getDeclaredField("knownCommands"); + knownCommandsField.setAccessible(true); + Object rawKnownCommands = knownCommandsField.get(simpleCommandMap); + if (rawKnownCommands instanceof Map rawMap) { + List keysToRemove = new ArrayList<>(); + for (Map.Entry entry : rawMap.entrySet()) { + if (entry.getValue() == legacyAliasCommand) { + keysToRemove.add(entry.getKey()); + } + } + for (Object key : keysToRemove) { + removeKnownCommand(rawMap, key); + } + } + } catch (ReflectiveOperationException ignored) { + } + } + legacyAliasCommand = null; + } + + @SuppressWarnings({"rawtypes", "unchecked"}) + private static void removeKnownCommand(Map rawMap, Object key) { + ((Map) rawMap).remove(key); + } + + private static CommandMap getCommandMap() { + try { + Field commandMapField = Bukkit.getServer().getClass().getDeclaredField("commandMap"); + commandMapField.setAccessible(true); + Object rawCommandMap = commandMapField.get(Bukkit.getServer()); + if (rawCommandMap instanceof CommandMap commandMap) { + return commandMap; + } + } catch (ReflectiveOperationException ignored) { + } + return null; + } + + private static PluginCommand createPluginCommand(Main plugin, String name) { + try { + Constructor constructor = PluginCommand.class.getDeclaredConstructor(String.class, org.bukkit.plugin.Plugin.class); + constructor.setAccessible(true); + return constructor.newInstance(name, plugin); + } catch (ReflectiveOperationException e) { + plugin.getLogger().warning(LocaleManager.text("console.alias.construct-failed", "Unable to construct dynamic command '/{command}': {error}", + "{command}", name, + "{error}", e.getMessage())); + return null; + } + } + + private List filterStartingWith(List values, String typed) { + String lower = typed.toLowerCase(Locale.ENGLISH); + List matches = new ArrayList<>(); + 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 "give" -> hasPermission(sender, PERMISSION_GIVE); + case "end" -> hasPermission(sender, PERMISSION_END); + case "gifts" -> hasPermission(sender, PERMISSION_GIFTS); + case "reload" -> hasPermission(sender, PERMISSION_RELOAD); + case "addhand" -> hasPermission(sender, PERMISSION_ADDHAND); + case "inspect" -> hasPermission(sender, PERMISSION_INSPECT); + case "test" -> hasPermission(sender, PERMISSION_TEST); + case "data" -> hasPermission(sender, PERMISSION_DATA); + case "debug" -> hasPermission(sender, PERMISSION_DEBUG) || hasPermission(sender, PERMISSION_DEBUG_TOGGLE); + default -> false; + }; + } + + 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_GIVE, LocaleManager.text("ui.permission-descriptions.give", "allows /xmastree give")); + permissions.put(PERMISSION_GIFTS, LocaleManager.text("ui.permission-descriptions.gifts", "allows /xmastree gifts")); + permissions.put(PERMISSION_ADDHAND, LocaleManager.text("ui.permission-descriptions.addhand", "allows /xmastree addhand")); + permissions.put(PERMISSION_RELOAD, LocaleManager.text("ui.permission-descriptions.reload", "allows /xmastree reload")); + permissions.put(PERMISSION_INSPECT, LocaleManager.text("ui.permission-descriptions.inspect", "allows /xmastree inspect")); + permissions.put(PERMISSION_TEST, LocaleManager.text("ui.permission-descriptions.test", "allows /xmastree test")); + permissions.put(PERMISSION_DATA, LocaleManager.text("ui.permission-descriptions.data", "allows /xmastree data")); + permissions.put(PERMISSION_DEBUG, LocaleManager.text("ui.permission-descriptions.debug", "allows /xmastree debug")); + permissions.put(PERMISSION_DEBUG_TOGGLE, LocaleManager.text("ui.permission-descriptions.debug-toggle", "allows /xmastree debug toggle")); + permissions.put(PERMISSION_END, LocaleManager.text("ui.permission-descriptions.end", "allows /xmastree end")); + permissions.put(PERMISSION_TREE_OVERRIDE, LocaleManager.text("ui.permission-descriptions.tree-override", "allows managing other players' trees")); + return permissions; + } + + private String formatLocation(Location location) { + if (location == null || location.getWorld() == null) { + return LocaleManager.text("ui.inspect.values.unknown-location", "unknown"); + } + return location.getWorld().getName() + + " " + + location.getBlockX() + + ", " + + location.getBlockY() + + ", " + + location.getBlockZ(); + } + + private String formatRequirements(Map requirements) { + if (requirements == null || requirements.isEmpty()) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + List parts = new ArrayList<>(); + for (Map.Entry entry : requirements.entrySet()) { + if (entry.getValue() > 0) { + parts.add(formatMaterial(entry.getKey()) + " x" + entry.getValue()); + } + } + return parts.isEmpty() ? LocaleManager.text("ui.inspect.values.none", "none") : String.join(", ", parts); + } + + private String formatItems(List items) { + if (items == null || items.isEmpty()) { + return LocaleManager.text("ui.inspect.values.none", "none"); + } + Map totals = new LinkedHashMap<>(); + for (ItemStack item : items) { + if (item != null && !item.getType().isAir()) { + totals.merge(item.getType(), item.getAmount(), Integer::sum); + } + } + return formatRequirements(totals); + } + + private String formatGiftItem(ItemStack item) { + if (item == null || item.getType().isAir()) { + return TextUtils.muted(LocaleManager.text("ui.inspect.values.none", "none")); + } + String name = formatMaterial(item.getType()); + ItemMeta meta = item.getItemMeta(); + if (meta != null && meta.hasDisplayName() && meta.displayName() != null) { + name = PLAIN_TEXT.serialize(meta.displayName()); + } + String amount = " x" + item.getAmount(); + if (name.equals(formatMaterial(item.getType()))) { + return TextUtils.text(name + amount); + } + return TextUtils.text(name + amount) + TextUtils.muted(" (" + item.getType().name() + ")"); + } + + private List getWorldNames() { + List worlds = new ArrayList<>(); + for (World world : Bukkit.getWorlds()) { + worlds.add(world.getName()); + } + return worlds; + } + + private String formatMaterial(Material material) { + return Arrays.stream(material.name().toLowerCase(Locale.ENGLISH).split("_")) + .filter(part -> !part.isBlank()) + .map(part -> Character.toUpperCase(part.charAt(0)) + part.substring(1)) + .reduce((left, right) -> left + " " + right) + .orElse(material.name()); + } + + private static Map createDebugSections() { + Map sections = new LinkedHashMap<>(); + sections.put("status", "Status"); + sections.put("commands", "Commands"); + sections.put("permissions", "Permissions"); + sections.put("placeholders", "Placeholders"); + sections.put("config", "Config"); + return sections; + } + + private String debugSectionDisplayName(String sectionKey) { + return LocaleManager.text("ui.debug.sections." + sectionKey, DEBUG_SECTIONS.getOrDefault(sectionKey, sectionKey)); + } + + private String replaceToken(String template, String key, String value) { + if (template == null) { + return null; + } + return template.replace("{" + key + "}", TextUtils.escape(value)); } } diff --git a/src/main/java/ru/meloncode/xmas/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..86feedf --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/XMasPlaceholders.java @@ -0,0 +1,162 @@ +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_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 "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; + } + + 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_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..a3c0063 --- /dev/null +++ b/src/main/java/ru/meloncode/xmas/utils/HeadProfileUtils.java @@ -0,0 +1,98 @@ +package ru.meloncode.xmas.utils; + +import org.bukkit.Bukkit; +import org.bukkit.block.Skull; +import org.bukkit.profile.PlayerTextures; +import ru.meloncode.xmas.LocaleManager; + +import java.lang.reflect.Method; +import java.net.MalformedURLException; +import java.net.URI; +import java.net.URL; +import java.util.UUID; +import java.util.logging.Logger; + +public final class HeadProfileUtils { + + private HeadProfileUtils() { + } + + public static void applyConfiguredHead(Skull skull, String configuredHead, Logger logger) { + Object legacyProfile = createLegacyProfile(configuredHead, logger); + if (legacyProfile == null) { + return; + } + + if (trySetResolvableProfile(skull, legacyProfile)) { + return; + } + if (trySetLegacyProfile(skull, legacyProfile)) { + return; + } + + logger.warning(LocaleManager.text("console.heads.apply-failed", "Unable to apply configured present head profile.")); + } + + private static Object createLegacyProfile(String configuredHead, Logger logger) { + if (configuredHead == null || configuredHead.trim().isEmpty()) { + return null; + } + + String trimmedHead = configuredHead.trim(); + try { + if (!trimmedHead.contains("://")) { + Method createProfile = Bukkit.class.getMethod("createProfile", String.class); + return createProfile.invoke(null, trimmedHead); + } + + URL skinUrl = URI.create(trimmedHead).toURL(); + if (!"textures.minecraft.net".equalsIgnoreCase(skinUrl.getHost())) { + logger.warning(LocaleManager.text("console.heads.non-mojang-url", "Ignoring non-Mojang present skin URL: {url}", + "{url}", trimmedHead)); + return null; + } + + Method createProfile = Bukkit.class.getMethod("createProfile", UUID.class); + Object legacyProfile = createProfile.invoke(null, UUID.randomUUID()); + Method getTextures = legacyProfile.getClass().getMethod("getTextures"); + PlayerTextures textures = (PlayerTextures) getTextures.invoke(legacyProfile); + textures.setSkin(skinUrl); + Method setTextures = legacyProfile.getClass().getMethod("setTextures", PlayerTextures.class); + setTextures.invoke(legacyProfile, textures); + return legacyProfile; + } catch (IllegalArgumentException | MalformedURLException exception) { + logger.warning(LocaleManager.text("console.heads.invalid-url", "Invalid present skin URL: {url}", + "{url}", trimmedHead)); + return null; + } catch (ReflectiveOperationException exception) { + logger.warning(LocaleManager.text("console.heads.build-failed", "Failed to build present head profile: {error}", + "{error}", exception.getMessage())); + return null; + } + } + + private static boolean trySetResolvableProfile(Skull skull, Object legacyProfile) { + try { + Class legacyProfileClass = Class.forName("com.destroystokyo.paper.profile.PlayerProfile"); + Class resolvableProfileClass = Class.forName("io.papermc.paper.datacomponent.item.ResolvableProfile"); + Method factoryMethod = resolvableProfileClass.getMethod("resolvableProfile", legacyProfileClass); + Object resolvableProfile = factoryMethod.invoke(null, legacyProfile); + Method setProfile = skull.getClass().getMethod("setProfile", resolvableProfileClass); + setProfile.invoke(skull, resolvableProfile); + return true; + } catch (ReflectiveOperationException exception) { + return false; + } + } + + private static boolean trySetLegacyProfile(Skull skull, Object legacyProfile) { + try { + Class legacyProfileClass = Class.forName("com.destroystokyo.paper.profile.PlayerProfile"); + Method setPlayerProfile = skull.getClass().getMethod("setPlayerProfile", legacyProfileClass); + setPlayerProfile.invoke(skull, legacyProfile); + return true; + } catch (ReflectiveOperationException exception) { + return false; + } + } +} diff --git a/src/main/java/ru/meloncode/xmas/utils/TextUtils.java b/src/main/java/ru/meloncode/xmas/utils/TextUtils.java index 94eeddf..7114ec2 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)); + 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..ae409db 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -1,38 +1,166 @@ +# 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) + + commands: + # Registers the legacy /xmas alias alongside the primary /xmastree command. + # Default: true + # Safe values: true or false + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree debug toggle core.commands.legacy-command-enabled true|false applies immediately. + legacy-command-enabled: true + + # Automatic end-of-event behavior. holiday-ends: + # Enables time-based event shutdown. + # 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 addhand + # Notes: + # - Use modern Paper/Bukkit material names only. + # - Invalid or legacy material names are skipped safely. + # - /xmastree addhand appends the exact held item and saves it immediately. + # Reload behavior: + # - File edits apply on /xmastree reload. + # - /xmastree addhand applies immediately and persists to disk. gifts: - DIAMOND - EMERALD @@ -50,29 +178,415 @@ xmas: - DIAMOND_HOE - NAME_TAG + # Tree stage settings. + # Notes: + # - gift-cooldown values are written in seconds and converted using core.update-speed. + # - lvlup sections use modern material names and item counts for the next stage. + # - Particle names should come from the Paper Particle enum: + # https://jd.papermc.io/paper/26.1.2/org/bukkit/Particle.html + # - Existing loaded trees may keep their current stage objects until the next full restart. + # /xmastree reload is best for new trees and newly loaded trees, while a restart is recommended + # when you want every existing tree to use the refreshed stage settings. tree-lvl: sapling: - #-1 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: 2 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 2 + swag: + # Enables the ornament-style particle effect for saplings. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: false + body: + # Enables the trunk/body particle effect for saplings. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: false + 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: CHERRY_LEAVES + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + particle: CHERRY_LEAVES + # Horizontal X offset for the small tree ambient particle spread. + # Default: 1.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-x: 1.0 + # Vertical Y offset for the small tree ambient particle spread. + # Default: 1.5 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-y: 1.5 + # Horizontal Z offset for the small tree ambient particle spread. + # Default: 1.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-z: 1.0 + # Particle speed value passed to the Paper particle API. + # Default: 0.01 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + speed: 0.01 + # Number of ambient particles spawned per effect tick. + # Default: 3 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 3 + swag: + # Enables the ornament-style particle effect for small trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: true + # Particle type for the small tree ornament effect. + # Default: DUST + # Safe values: a valid Particle enum name supported by the plugin's simple particle loader + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + particle: DUST + # Horizontal X offset for the small tree ornament particle spread. + # Default: 0.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-x: 0.25 + # Vertical Y offset for the small tree ornament particle spread. + # Default: 0.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-y: 0.25 + # Horizontal Z offset for the small tree ornament particle spread. + # Default: 0.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-z: 0.25 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + speed: 0.0 + # Number of ornament particles spawned per effect tick. + # Default: 8 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 8 + body: + # Enables the trunk/body particle effect for small trees. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: false + 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 max-level magic_tree stage. + # Default: 120 + # Units: seconds before scaling by core.update-speed + # Safe values: 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. gift-cooldown: 120 - lvlup: + + particles: + ambient: + # Enables the ambient particle effect for magic trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: true + # Particle type for the magic tree ambient effect. + # Default: FIREFLY + # Safe values: a valid Particle enum name + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + particle: FIREFLY + # Horizontal X offset for the magic tree ambient particle spread. + # Default: 2.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-x: 2.25 + # Vertical Y offset for the magic tree ambient particle spread. + # Default: 2.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-y: 2.25 + # Horizontal Z offset for the magic tree ambient particle spread. + # Default: 2.25 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-z: 2.25 + # Particle speed value passed to the Paper particle API. + # Default: 0.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + speed: 0.0 + # Number of ambient particles spawned per effect tick. + # Default: 8 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 8 + swag: + # Enables the ornament-style particle effect for magic trees. + # Default: true + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: true + # Particle type for the magic tree ornament effect. + # Default: DUST + # Safe values: a valid Particle enum name supported by the plugin's simple particle loader + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + particle: DUST + # Horizontal X offset for the magic tree ornament particle spread. + # Default: 0.3 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-x: 0.3 + # Vertical Y offset for the magic tree ornament particle spread. + # Default: 0.3 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-y: 0.3 + # Horizontal Z offset for the magic tree ornament particle spread. + # Default: 0.3 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + offset-z: 0.3 + # Particle speed value passed to the Paper particle API. + # Default: 10.0 + # Safe values: decimal numbers, usually 0.0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + speed: 10.0 + # Number of ornament particles spawned per effect tick. + # Default: 16 + # Safe values: whole numbers 0 or higher + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + count: 16 + body: + # Enables the trunk/body particle effect for magic trees. + # Default: false + # Safe values: true or false + # Reload behavior: /xmastree reload for new trees; restart recommended for fully consistent existing-tree behavior. + enabled: false + + # Max-level trees have no next-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..9c47602 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,10 +1,67 @@ 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|give|gifts [list|roll|remove|spawn]|addhand|reload|inspect|test|data|debug [section|page]|end] +permissions: + onembxmastree.admin: + description: Allows all XMas Tree commands and overrides + default: op + children: + onembxmastree.command.status: true + onembxmastree.command.help: true + onembxmastree.command.give: true + onembxmastree.command.gifts: true + onembxmastree.command.addhand: true + onembxmastree.command.reload: true + onembxmastree.command.inspect: true + onembxmastree.command.test: true + onembxmastree.command.data: true + onembxmastree.command.debug: true + onembxmastree.command.debug.toggle: true + onembxmastree.command.end: true + onembxmastree.tree.override: true + onembxmastree.command.status: + description: Allows viewing /xmastree status + default: true + onembxmastree.command.help: + description: Allows viewing /xmastree help + default: true + onembxmastree.command.give: + description: Allows /xmastree give + default: op + onembxmastree.command.gifts: + description: Allows /xmastree gifts + default: op + onembxmastree.command.addhand: + description: Allows /xmastree addhand + default: op + onembxmastree.command.reload: + description: Allows /xmastree reload + default: op + onembxmastree.command.inspect: + description: Allows /xmastree inspect + default: op + onembxmastree.command.test: + description: Allows /xmastree test + default: op + onembxmastree.command.data: + description: Allows /xmastree data + default: op + onembxmastree.command.debug: + description: Allows /xmastree debug + default: op + 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..97c6f54 --- /dev/null +++ b/src/main/resources/translations/locale_en.yml @@ -0,0 +1,371 @@ +# XMas Tree translations +# File: plugins/X-Mas/translations/locale_en.yml +# +# This file is the primary source of truth for: +# - player-facing text +# - admin command/debug wording +# - the chat/console prefix +# - the MiniMessage pastel theme aliases +# +# The plugin loads this file by default when core.locale is set to en. +# Custom locales can be added as: +# - plugins/X-Mas/translations/locale_fr.yml +# - plugins/X-Mas/translations/locale_de.yml +# and so on. + +plugin-name: XMas Tree + +theme: + text: '#f7f1e8' + muted: '#c7c0bb' + accent: '#9fe3d6' + accent-secondary: '#f4c2d7' + label: '#f3d38f' + success: '#b9e8b5' + warning: '#f6d58b' + error: '#f3a7a7' + info: '#a9d4ff' + command: '#f4c2d7' + +format: + prefix: '[{plugin_name}] ' + console-prefix: '[{plugin_name}] ' + +messages: + plugin-enabled: Merry Christmas and Happy New Year! + timeout: The holidays are over. See you next year! + final-wish: Happy New Year to all of you, my friends! + + tree: + grow-lvl-ready: Woosh! Right-click the tree to let it grow. + grow-lvl-progress: _UNUSED + grow-req-list-title: Still needed + grow-req-list-hint: Right-click the tree while holding the listed ingredient items to feed them into the tree. + grow-not-enough-place: There is not enough room for the tree to grow. + grow-lvl-max: This tree has reached its maximum level. + tree-limit: Hey, do not be greedy. You have reached your tree limit. + destroy-sapling: You can cut down this tree. + destroy-leaves-santa: Santa Claus sees everything. + destroy-leaves-tut: To destroy a tree, cut the log. + destroy-warning: You can cut down the tree. Hit it again to confirm. + destroy-resource-back: Your used upgrade items will be returned for collection. + destroy-tut: Hit it again to confirm. + destroy-fail-owner: It is not your tree. + destroy-complete: Your tree has been packed away for the season. + refund: + chest: Your tree resources were returned in a chest. + barrel: Your tree resources were returned in a barrel. + inventory: Your tree resources were returned to your inventory. + inventory-overflow: Your tree resources were returned. Inventory overflow dropped at the tree. + + gift: + luck-message: _UNUSED + unluck-message: Probably Santa has put you on the blacklist. Better luck next time. + +crystal: + name: Christmas Crystal + lore: + - Concentrated Christmas Spirit + - Use it on a spruce sapling to fill it with magic. + +command: + help: + - 'Use /xmastree to show plugin version and status.' + - 'Use /xmastree help to show this command list.' + - 'Use /xmastree give \ to give a player a Christmas Crystal.' + - 'Use /xmastree gifts to spawn presents under all trees.' + - 'Use /xmastree gifts list [page] to list configured gift rewards.' + - 'Use /xmastree gifts roll to preview one random gift roll.' + - 'Use /xmastree gifts remove \ to remove a configured gift reward.' + - 'Use /xmastree addhand to add your held item as a gift.' + - 'Use /xmastree reload to reload config, translations, theme, and crystal item text.' + - 'Use /xmastree inspect to inspect the tree you are looking at.' + - 'Use /xmastree test sound first|repeat [player] to preview grow sound volume.' + - 'Use /xmastree test particle \ [effect] [player] to preview configured particles.' + - 'Use /xmastree data backup to back up trees.yml.' + - 'Use /xmastree data validate to check saved tree data.' + - 'Use /xmastree data migrate-world \ \ [dry-run|apply] to migrate saved tree world names.' + - 'Use /xmastree debug to open the status debug section.' + - 'Use /xmastree debug status and other categories for status, commands, permissions, placeholders, and toggles.' + - 'Use /xmastree debug toggle \ true|false to change supported global toggles.' + - 'Use /xmastree end to end the event.' + player-offline: 'Player not found.' + no-player-name: 'Missing player name.' + giveaway: 'Ho! Ho! Ho! Looks like it is time to check presents.' + no-permission: 'You do not have permission to use this command.' + player-only: 'Only players can use this command.' + hold-item-first: 'Hold an item before running {command}.' + gift-added: 'Added the held item to the gift list.' + reload-success: '{plugin_name} configuration reloaded.' + inspect: + not-found: 'No XMas Tree found nearby or in your line of sight.' + test: + no-particles: 'No enabled particle effects matched that preview.' + data: + backup-created: 'Tree data backup created.' + backup-failed: 'Unable to back up trees.yml.' + migration-applied: 'World migration saved. Restart the server before testing migrated trees.' + migration-failed: 'Unable to migrate tree world names.' + legacy-alias-enabled: 'Legacy alias: /xmas still works.' + +ui: + status: + title: '{plugin_name} {version} Plugin Status' + labels: + event-status: Event Status + current-time: Current Time + holidays-end: Holidays End + auto-end: Auto-End + resource-back: Resource Back + particles: Particles + loaded-trees: Loaded Trees + tree-owners: Tree Owners + help: Help + legacy-alias: Legacy Alias + values: + in-progress: 'In Progress' + holidays-end: 'Holidays End' + + command-descriptions: + status: status + help: command list + give: give a Christmas Crystal + gifts: spawn presents under all trees + gifts-list: list configured gifts + gifts-roll: preview a random gift roll + gifts-remove: remove a configured gift + addhand: add held item to gifts + reload: reload config and translations + inspect: inspect the tree you are looking at + test-sound: preview grow sounds + test-particle: preview configured particles + data-backup: back up trees.yml + data-validate: validate trees.yml + data-migrate-world: migrate saved tree world names + end: end the event + debug: open the status debug section + debug-section: extended debug output by category + debug-toggle: toggle global booleans + + permission-descriptions: + admin: allows all {plugin_name} commands and overrides + status: shows /xmastree status output + help: shows /xmastree help output + give: allows /xmastree give + gifts: allows /xmastree gifts + addhand: allows /xmastree addhand + reload: allows /xmastree reload + inspect: allows /xmastree inspect + test: allows /xmastree test + data: allows /xmastree data + debug: allows /xmastree debug + debug-toggle: allows /xmastree debug toggle + end: allows /xmastree end + tree-override: allows managing other players' trees + + debug: + section-title: '{plugin_name} Debug' + sections: + status: Status + commands: Commands + permissions: Permissions + placeholders: Placeholders + config: Config + labels: + usage: Usage + keys: Keys + unknown-toggle-key: Unknown Toggle Key + value: Value + updated: Updated + next: Next + sections: Debug Sections + pages: Debug Pages + requested: Requested + try: Try + notes: Notes + invalid-boolean: 'must be true or false' + notes: + placeholders: 'Requires PlaceholderAPI. Use ''_'' after prefix, then dotted keys.' + placeholder-default-description: registered placeholder + + inspect: + title: '{plugin_name} Tree Inspect' + labels: + owner: Owner + tree-id: Tree ID + level: Level + location: Location + can-level-up: Can Level Up + present-timer: Present Timer + scheduled-presents: Scheduled Presents + remaining: Remaining Requirements + refund-preview: Refund Preview + requested: Requested + try: Try + usage: Usage + values: + none: none + unknown-owner: unknown + unknown-location: unknown + + reload: + title: Reload Summary + labels: + locale: Locale + gifts: Gifts + present-heads: Present Heads + trees: Trees + owners: Owners + particles: Particles + resource-back: Resource Back + legacy-alias: Legacy Alias + sounds: Sounds + + test: + labels: + usage: Usage + unknown-test: Unknown Test + try: Try + sound: Sound + particle: Particle + target: Target + volume: Volume + level: Level + levels: Levels + effects-played: Effects Played + + gifts: + title: Gift Pool + labels: + usage: Usage + unknown-action: Unknown Gift Action + total: Total + rolled: Rolled + removed: Removed + index: Index + try: Try + values: + empty: 'No gifts are configured.' + + data: + title: Tree Data Validation + world-migration-title: Tree World Migration + labels: + usage: Usage + unknown-action: Unknown Data Action + try: Try + file: File + error: Error + stored-trees: Stored Trees + loaded-trees: Loaded Trees + status: Status + invalid-tree-ids: Invalid Tree IDs + invalid-owners: Invalid Owners + invalid-levels: Invalid Levels + invalid-locations: Invalid Locations + missing-worlds: Missing Worlds + invalid-requirements: Invalid Requirements + duplicate-locations: Duplicate Locations + source-world: Source World + target-world: Target World + matched-trees: Matched Trees + mode: Mode + backup: Backup + apply-command: Apply Command + values: + clean: 'clean' + warnings: 'warnings' + +placeholders: + values: + active: Active + inactive: Inactive + in-progress: In Progress + holidays-end: Holidays End + manual: manual + yes: Yes + no: No + unknown: unknown + disabled: disabled + ended: ended + duration-days-hours: '{days}d {hours}h' + duration-hours-minutes: '{hours}h {minutes}m' + duration-minutes: '{minutes}m' + descriptions: + event-active: whether the event is currently active + event-active-text: human-readable active state + event-status: current event status text + event-starts-at: event start mode + event-ends-at: configured event end date + event-ends-in: time remaining until the event ends + event-ends-timestamp: event end timestamp in milliseconds + event-auto-end: whether automatic ending is enabled + resource-back: whether resource refunds are enabled + resource-back-text: human-readable refund state + particles-enabled: whether XMas Tree particles are enabled + luck-enabled: whether gift luck chance is enabled + luck-chance: gift luck chance as a percent + trees-total: total loaded tree count + trees-owners: number of unique tree owners + player-trees: loaded trees owned by the placeholder player + version: loaded plugin version + +console: + translation: + loaded: Loaded translation '{file}'. + missing: Translation '{file}' was not found. + fallback: Falling back to locale_en.yml. + create-directory-failed: Unable to create translations directory at {directory} + migrated-legacy: Migrated legacy locale '{source}' to '{target}'. + migrate-legacy-failed: 'Unable to migrate legacy locale ''{source}'' to ''{target}'': {error}' + config: + update-speed-invalid: Update speed must be > 0 + update-speed-reset: Setting value to default + unable-load-holiday-end-date: Unable to load holiday end date + invalid-holiday-end-date: Invalid holiday end date in config.yml + load-yaml-failed: Failed to load YAML configuration from {file} + create-directory-failed: Unable to create configuration directory {directory} + save-yaml-failed: Failed to save YAML configuration to {file} + missing-bundled-resource: Missing bundled configuration resource {resource} + load-bundled-resource-failed: Failed to load bundled configuration resource {resource} + gifts: + no-heads: Warning! No heads loaded. Presents cannot spawn without a box head. + load-item-failed: 'Failed to load gift item: {item}' + no-gifts: Warning! No gifts loaded. No X-Mas without gifts. + deserialize-item-failed: 'Failed to deserialize gift item: {item}' + serialize-list-item-failed: 'Failed to serialize item for saving to the gift list. Item: {item}' + serialize-item-failed: 'Failed to serialize item: {error}' + too-large: 'Gift item is too large to deserialize safely: {length} characters' + invalid-material-or-base64: 'Invalid material name or Base64 gift item: {item}' + deserialize-error: 'Failed to deserialize gift item: {error}' + placeholder: + registered: 'Registered PlaceholderAPI expansion: {identifier}' + registration-failed: PlaceholderAPI is present, but placeholder registration failed. + particles: + unknown: Unknown particle '{particle}' at {path}. Using fallback. + extra-data-unsupported: Particle '{particle}' needs extra data and is not supported in config yet. Using fallback. + spawn-failed: 'Failed to spawn particle {particle}: {error}' + trees: + load-tree-error: Error while loading tree {tree} + load-error: Error while loading trees + unable-load: Unable to load X-Mas trees + unable-save: Unable to save X-Mas tree data + unable-remove: Unable to remove X-Mas tree data + material-missing: Cannot find modern material '{material}' for tree level. + material-numeric-required: Tree level material '{material}' must use a numeric amount. + material-load-failed: Cannot load material '{material}' for tree level. + world-alias: Loading legacy X-Mas trees from saved world '{source}' into '{target}' via migration.world-aliases. + alias: + command-map-unavailable: Unable to access the Bukkit command map. Skipping legacy /xmas alias registration. + owned-by-plugin: Legacy alias '/{alias}' is already owned by plugin '{plugin}'. Skipping alias registration. + registered-by-other: Legacy alias '/{alias}' is already registered by another command source. Skipping alias registration. + create-failed: Unable to create the legacy /xmas alias command. + registered: Registered legacy alias '/{alias}' for '/{primary}'. + construct-failed: 'Unable to construct dynamic command ''/{command}'': {error}' + heads: + apply-failed: Unable to apply configured present head profile. + non-mojang-url: 'Ignoring non-Mojang present skin URL: {url}' + invalid-url: 'Invalid present skin URL: {url}' + build-failed: 'Failed to build present head profile: {error}' + refund: + place-container-failed: 'Failed to place refund {container}: {error}' diff --git a/todo.log b/todo.log new file mode 100644 index 0000000..7ebdcc5 --- /dev/null +++ b/todo.log @@ -0,0 +1,95 @@ +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: + - move any remaining visible colors, labels, and wording into `translations/locale_en.yml` + - tune the soft 1MB pastel theme for chat, debug pages, crystal text, requirement lists, and refund messages + +- Add admin preview and test commands: + - done: inspect tree state + - done: preview particles by tree stage with `/xmastree test particle` + - done: test first/repeat grow sounds with `/xmastree test sound` + - later: test refund delivery safely + +- Improve tree inspection: + - show owner, tree UUID, level, world/location, remaining requirements, spent/refundable resources, present timer, scheduled presents, and whether it can level up + - policy-must: existing trees from previous years must keep working; returning players waited for their legacy tree and should be able to keep it while receiving another crystal this year, letting them have two trees and earn more gifts + +- Add gift management UI: + - done: view configured gifts with `/xmastree gifts list` + - add held item + - done: remove gifts with `/xmastree gifts remove ` + - done: test a gift roll with `/xmastree gifts roll` + - eventually support weighted gift pools + +- Modernize PlaceholderAPI output: + - player tree count + - player highest tree level + - total active trees + - event status text + - future timer/status values useful for CMI holograms and ajLeaderboards + +- Add safer data tools: + - done: backup `trees.yml` with `/xmastree data backup` + - done: validate saved tree data with `/xmastree data validate` + - done: dry-run/apply saved world-name migration with `/xmastree data migrate-world [dry-run|apply]` + - report missing worlds, invalid owners, invalid levels, and duplicate/overlapping tree data + +- Continue present head and texture cleanup: + - verify Paper 26.2 profile behavior for player names and texture URLs + - improve diagnostics around invalid head entries + +- Add a reload report: + - show locale, gift count, present head count, tree count, tree owner count, particles setting, sound volumes, legacy alias state, and any warnings + +Future feature ideas to review +------------------------------ + +- Add event streaks: + - 6 gift claims in a day for a "hardcore elf" style daily milestone + - 7 days in a row + - every day of the event/month + - add a player command or GUI to view streak progress and claim milestone rewards + - possible milestone reward: trigger extra gifts for all online players + +- Add one extra progression level after the current max tree: + - "super tree" or similarly festive final stage + - modern Paper 26.2 particles/effects + - distinctive design for returning players who can upgrade their old max tree once more + +- Add seasonal admin presets: + - save/load named config presets for gift pools, particle styles, sound tuning, and event dates + +- Add player tree journal: + - command or GUI showing a player's tree history, current tree locations, old legacy tree, current-year tree, upgrade progress, and collected milestone rewards + +- Add community milestones: + - total server-wide gifts opened + - total trees grown + - unlock server-wide bonus gift waves when thresholds are reached + +- Add configurable tree protection helpers: + - staff-only teleport to a tree + - flag trees in unsafe/unloaded/missing worlds + - optional highlight particles around a selected tree for support checks + +- Add event wrap-up report: + - final tree count + - active owners + - gifts spawned/opened + - legacy trees preserved + - players eligible for returning-player rewards