Skip to content

2 ‐ Developer Guide

Basti edited this page Sep 27, 2026 · 2 revisions

This document targets contributors who need to build UnderwaterTrees from source, extend functionality, or troubleshoot code-level issues. For installing and configuring the plugin, see Home.

1. Repository Layout & Tooling

plugins/underwatertrees-plugin/
├─ build.gradle              # Paper plugin build script (shadowJar enabled)
├─ settings.gradle           # Opt-in ../turtle-lib checkout (-PuseLocalTurtleLib=true)
├─ gradle/libs.versions.toml # Version catalog, a synced copy of the workspace root's catalog
├─ version.properties        # Plugin version
├─ config/                   # Spotless (Eclipse JDT) formatter settings
├─ src/main/java/...         # Plugin source
├─ src/main/resources/       # Config, locale templates, bundled licenses/, legacy/ upgrade baselines (1.x, 1.0.x)
├─ src/test/java/...         # Unit tests
├─ dist/                     # Build output (wiped on every build)
├─ LICENSE                   # MIT license text, copied into the jar
└─ THIRD_PARTY_LICENSES.md
  • Java 25 (Temurin or equivalent). Configure JAVA_HOME accordingly.
  • Gradle Wrapper (Gradle 9.8.0; do not install system Gradle; use ./gradlew / gradlew.bat). The configuration cache is enabled in gradle.properties, so repeated builds reuse the configuration phase.
  • Git for version control, and an IDE of choice (IntelliJ IDEA, VS Code with Java extensions).

Dependency and Paper versions come from the repo's own version catalog gradle/libs.versions.toml, which Gradle loads automatically; it is a copy of the workspace root's catalog, kept in sync by sync-gradle-wrapper.ps1. Always make version bumps (including those from Dependabot PRs) in the root catalog first, then run sync-gradle-wrapper.ps1; the script aborts without copying anything if a repo's copy was edited directly since the last sync, and lists the differing entries (-Force overwrites them). Adventure and Gson are not in the catalog: they come in through paper-api, which fixes their versions.

2. Building & Testing

./gradlew clean build        # compiles + checks formatting + runs tests + produces jars and checksums
./gradlew shadowJar          # only build the shaded Paper artifact (with its .sha256)
./gradlew test               # run unit tests (no running server needed)
./gradlew spotlessApply      # fix formatting (spotlessCheck runs as part of build)

Artifacts land in dist/underwatertrees-paper-<version>.jar (sources jar in dist/sources/), each with a .sha256 file next to it (verifiable with sha256sum -c). Every jar task writes the checksums itself, so a partial build like shadowJar never leaves a jar without one. The cleanDist task wipes dist/ before every jar build (a lone shadowJar removes the sources jar too), so run the full build for a release and move releases you want to keep elsewhere first.

The build compiles against Paper 26.3.build.49-alpha (pinned in the version catalog).

  • Unit tests: Keep them deterministic and focused on pure logic (e.g., packed sapling coordinates, the tracked-sapling queue, spawn protection, 1.x key mappings, locale files).
  • Manual QA: Deploy the shaded jar to a Paper test server, enable log_stats/log_detail, and exercise config reloads, update notifications, and sapling placement.
  • Performance: Watch timings when enabling light override or mass sapling placement; adjust intervals/batch sizes before release if necessary.

3. Coding Guidelines

  • Target Java 25 features conservatively (avoid exotic APIs that inflate class files).
  • Player-facing text must live in lang/*.yml and be rendered via MessageService.
  • Prefer dependency injection via constructors; avoid static singletons where possible.
  • Keep placement/protection logic thread-safe. Event handlers, commands and config watcher callbacks all run on the main thread (ConfigWatcher polls in the background but hands the callback over via the scheduler). Only the first-time chunk scans run on a background pool; they read a ChunkSnapshot and hand their results back to the main thread, so keep Bukkit API calls out of that code.
  • /underwatertrees reload and the config watcher both run reloadAndMergeConfig(), which re-applies the config to the existing listener and refreshes locales, metrics, update checker, and watcher baseline on the main thread. File writes happen synchronously there, and only when something changed: ConfigKeyMigrator renames old keys, ConfigDefaultsInserter adds missing keys to config.yml as text (entries admins deleted from soils/saplings stay deleted, tracked in .seen-defaults.yml), and LangLoader adds missing keys to the active lang file. If ConfigWatcher.describeYamlError() finds that config.yml isn't valid YAML, it changes nothing and returns the error for the command's reply; at startup, the plugin then runs on the bundled config.yml without bStats and update checks until a reload succeeds. The watcher never reloads a deleted or emptied config.yml; its onSkipped hook logs log.config_missing_external or log.config_empty_external instead, and only the command or a restart writes the file again. Keep expensive I/O off the main thread and profile changes before shipping.
  • Check Paper internals on every Paper build update. Underwater planting fires its own BlockPlaceEvent so protection plugins can veto it (UnderwaterSaplingsListener#onPlaceUnderwater), and Paper marks that constructor @ApiStatus.Internal. After bumping the Paper build, compile against it and plant a sapling underwater on a test server. If a Paper update breaks the constructor at runtime, InternalEventGuard denies the planting and logs one warning per server run instead of placing the sapling unprotected.
  • Underwater dark and pale oaks: vanilla's DarkOakTrunkPlacer only grows the trunk into air or leaves, so UnderwaterTwoByTwoGrowth cancels the trunkless StructureGrowEvent, drains the cells the trunk can take (their bounds mirror DarkOakTrunkPlacer(6, 2, 1): up to 9 layers, a lean of up to 2 blocks), grows the tree again with World#generateTree, only collecting its blocks, and fires them as its own StructureGrowEvent (location, player and bone meal flag of the dropped one; InternalEventGuard covers its internal constructor). Only the blocks that event leaves are set, and the water is refilled in the same tick; a cancelled or emptied event restores everything and gives the bone meal back. UnderwaterTrees skips its own event by identity. Recheck those bounds, and that the pale oak sapling still grows the world-gen pale oak minus its pale moss, when Minecraft changes these trees.

4. Working with TurtleLib

  • UnderwaterTrees pulls MessageService, LangLoader, ConfigWatcher, UpdateChecker, ServerMatcher, StartupBanner, ConfigKeyMigrator, ConfigDefaultsInserter, FileChangeTracker, and LegacyDataUpgrade from TurtleLib. TurtleLib (MIT, by kroet.net) is shaded into the plugin jar; its license text ships as src/main/resources/licenses/turtle-lib-MIT.txt. The tests also use LocaleFileChecker, which shadowJar leaves out of the plugin jar.
  • By default, TurtleLib is resolved as turtle-lib:turtle-lib-paper:<version> from its tagged GitHub Release assets, so no checkout is needed (see gradle/libs.versions.toml for the pinned version and build.gradle's ivy repository for how it's fetched; exclusiveContent makes that the only source for TurtleLib, and the Gradle wrapper checks its download against the official SHA-256). If Gradle can't find it, check that the pinned version has a matching tag and release asset on TurtleLib.
  • Editing TurtleLib alongside this plugin: clone it as a sibling (git clone https://github.com/hrobasti/turtle-lib ../turtle-lib) and build with -PuseLocalTurtleLib=true — this switches settings.gradle/build.gradle to a local project dependency that picks up source changes automatically, no publish step needed. ../turtle-lib must then be checked out relative to this repo.

5. Localization Workflow

  1. Add keys to src/main/resources/lang/en_US.yml first.
  2. Mirror keys across other locales (machine translations acceptable, but mark TODOs if manual review is pending).
  3. Run the plugin once; MessageService#load adds new keys to the active locale's file in plugins/UnderwaterTrees/lang/ without overwriting user customizations (other locales get theirs once they are loaded). To debug the sync, delete a locale from plugins/UnderwaterTrees/lang/ to force a fresh copy, or add temporary logging around MessageService#load, which syncs through LangLoader.loadActiveLocale.
  4. Keep placeholders consistent (<prefix>, <prefix_label>, <code>, etc.). Document any new placeholders on the Home page and add them to LocaleConsistencyTest. That test (TurtleLib's LocaleFileChecker) fails the build on missing or extra keys, changed tags, invalid MiniMessage, values left identical to en_US (outside a short allowlist), and keys the code uses but en_US.yml lacks or vice versa.
  5. Renaming a config or lang key? Add an old → new entry to LEGACY_CONFIG_KEY_MIGRATIONS / LEGACY_LANG_KEY_MIGRATIONS in UnderwaterTreesPlugin so existing files are rewritten in place; LegacyConfigMappingTest and LegacyLangMigrationTest check both tables against the 1.x files in legacy/1.x/.

6. Releasing & Contributing

  • version.properties defines the release version. If missing, Gradle falls back to gradle.properties or -Pversion. Update it when cutting a release so paper-plugin.yml and artifact names stay consistent.
  • Tag releases with exactly the version from version.properties, without a v prefix (e.g. 2.0.1; pre-releases as 2.1.0-beta2, by convention without a dot before the number). Use the same string as the version number on Modrinth and Hangar. The update checker also reads common variants (v2.1.0, 2.1.0-beta.2), but stick to the format under "Version Format" on the TurtleLib wiki's VersionComparator page, as some spellings still sort unexpectedly (e.g. dates). Existing tags stay as they are.

Release checklist:

  1. Update version.properties and any changelog notes.
  2. Run ./gradlew clean build and ensure tests pass; archive any older jars from dist/ first (e.g. into dist-archive/), since the build wipes it.
  3. Verify that THIRD_PARTY_LICENSES.md and src/main/resources/licenses/ reflect dependency changes; a new bundled library needs a license compatible with MIT distribution.
  4. Publish the jar from dist/ together with its .sha256 file (Modrinth, Hangar as applicable).
  5. Announce new locales or config keys in the README/wiki if user-facing behavior changed.

Contributing:

  • Fork the repository, create feature branches (feature/<topic>), and open pull requests against default.
  • Keep commits focused; reference issue numbers in commit messages when relevant.
  • Expect code review focusing on API stability, permissions security, and localization.
  • Contributions are accepted under the MIT License.
  • New third-party libraries: update build.gradle and THIRD_PARTY_LICENSES.md, ship the library's license text under src/main/resources/licenses/ if it is bundled, and confirm the license is compatible with distribution under the MIT License.
  • Parts of this plugin and its documentation were produced with AI assistance and reviewed by the maintainer before release. Review every patch, generated or not, for Paper compliance and licensing.