diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..21dbc04 --- /dev/null +++ b/.github/workflows/gradle.yml @@ -0,0 +1,137 @@ +name: BetterStructures Albion CI + +on: + push: + branches: + - master + - 'agent/**' + pull_request: + branches: [ master ] + +permissions: + contents: write + +concurrency: + group: betterstructures-ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make Gradle wrapper executable + run: chmod +x gradlew + + - name: FAWE-native block-write audit + shell: bash + run: | + set -euo pipefail + MATCHES="$(grep -RInE '\.(setType|setBlockData)\(|setBlockInNativeDataPalette\(' src/main/java || true)" + if [[ -n "$MATCHES" ]]; then + echo "Direct Bukkit/NMS block writes remain; Albion 1.1.1 must route block changes through FAWE:" + echo "$MATCHES" + exit 1 + fi + echo "FAWE-native audit passed: no direct Bukkit/NMS block writes found." + + - name: Build and verify raw JAR + shell: bash + run: | + set -euo pipefail + ./gradlew clean test shadowJar + VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" + JAR="build/libs/BetterStructures-${VERSION}.jar" + test -f "$JAR" + SHA256="$(sha256sum "$JAR" | awk '{print $1}')" + echo "Built raw JAR: $JAR" + echo "SHA-256: $SHA256" + echo "BS_VERSION=$VERSION" >> "$GITHUB_ENV" + echo "BS_JAR=$JAR" >> "$GITHUB_ENV" + echo "BS_SHA256=$SHA256" >> "$GITHUB_ENV" + + - name: Publish raw JAR milestone release + if: github.event_name == 'push' && startsWith(github.ref, 'refs/heads/agent/') + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + VERSION="$BS_VERSION" + JAR="$BS_JAR" + TAG="albion-${VERSION}-test" + NOTES="release-notes-${VERSION}.md" + test -f "$JAR" + + PREVIOUS_SHA="" + if git rev-parse -q --verify "refs/tags/${TAG}^{commit}" >/dev/null 2>&1; then + PREVIOUS_SHA="$(git rev-parse "refs/tags/${TAG}^{commit}")" + fi + + { + echo "## BetterStructures Albion ${VERSION} — Milestone Build" + echo + echo "AlbionMC development build from commit \`${GITHUB_SHA}\`." + echo + echo "- **Raw JAR:** \`BetterStructures-${VERSION}.jar\`" + echo "- **SHA-256:** \`${BS_SHA256}\`" + echo "- **CI:** ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo "- **Runtime target:** Paper 1.21.11+ with FastAsyncWorldEdit" + echo + echo "### Changes in this milestone build" + if [[ -n "$PREVIOUS_SHA" && "$PREVIOUS_SHA" != "$GITHUB_SHA" ]]; then + git log --no-merges --max-count=25 --pretty='- %s (`%h`)' "${PREVIOUS_SHA}..${GITHUB_SHA}" + else + git log --no-merges --max-count=25 --pretty='- %s (`%h`)' "origin/master..${GITHUB_SHA}" + fi + echo + echo "This prerelease contains the raw server-ready JAR. No Actions artifact ZIP is created." + } > "$NOTES" + + git tag -f "$TAG" "$GITHUB_SHA" + git push origin "refs/tags/$TAG" --force + + if gh release view "$TAG" >/dev/null 2>&1; then + gh release upload "$TAG" "$JAR" --clobber + gh release edit "$TAG" \ + --title "BetterStructures Albion ${VERSION} Milestone Build" \ + --notes-file "$NOTES" \ + --prerelease + else + gh release create "$TAG" "$JAR" \ + --title "BetterStructures Albion ${VERSION} Milestone Build" \ + --notes-file "$NOTES" \ + --prerelease + fi + + - name: Add raw JAR link to workflow summary + if: always() && env.BS_VERSION != '' + shell: bash + run: | + { + echo "## BetterStructures ${BS_VERSION}" + echo + echo "**SHA-256:** \`${BS_SHA256}\`" + echo + if [[ "${GITHUB_EVENT_NAME}" == "push" && "${GITHUB_REF}" == refs/heads/agent/* ]]; then + echo "### [Download raw BetterStructures-${BS_VERSION}.jar](https://github.com/${GITHUB_REPOSITORY}/releases/download/albion-${BS_VERSION}-test/BetterStructures-${BS_VERSION}.jar)" + echo + echo "[Milestone release notes](https://github.com/${GITHUB_REPOSITORY}/releases/tag/albion-${BS_VERSION}-test)" + echo + echo "This is the actual .jar file. No extraction is required." + else + echo "Raw JAR built successfully at \`${BS_JAR}\`." + fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..60ffec1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,56 @@ +name: Publish BetterStructures JAR + +on: + push: + tags: + - 'v1.1.*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Set up Java 21 + uses: actions/setup-java@v5 + with: + distribution: temurin + java-version: '21' + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v6 + + - name: Make Gradle wrapper executable + run: chmod +x gradlew + + - name: Verify tag matches project version + shell: bash + run: | + VERSION="$(sed -n "s/^version = '\([^']*\)'.*/\1/p" build.gradle)" + TAG_VERSION="${GITHUB_REF_NAME#v}" + if [ "$VERSION" != "$TAG_VERSION" ]; then + echo "Tag $GITHUB_REF_NAME does not match build.gradle version $VERSION" >&2 + exit 1 + fi + + - name: Build release JAR + run: ./gradlew clean test shadowJar + + - name: Publish raw JAR release asset + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + JAR="build/libs/BetterStructures-${VERSION}.jar" + test -f "$JAR" + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + gh release upload "$GITHUB_REF_NAME" "$JAR" --clobber + else + gh release create "$GITHUB_REF_NAME" "$JAR" \ + --title "BetterStructures Albion ${VERSION}" \ + --generate-notes + fi diff --git a/README.md b/README.md new file mode 100644 index 0000000..c62c462 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# BetterStructures Performance / Albion + +An **AlbionMC.com-only performance and maintenance fork** of BetterStructures, based on upstream **BetterStructures 2.6.3**. + +## Homage and upstream credit + +BetterStructures was created by **MagmaGuy**. The original concept, structure system, content format, and the overwhelming majority of this codebase are his work and the work of upstream contributors. This fork exists because AlbionMC depends heavily on BetterStructures and needs a performance profile tailored to a large, plugin-heavy survival server and frequently regenerated resource worlds. + +This project is **not intended to replace, rebrand, or compete with the original BetterStructures project**. Credit for the plugin belongs with MagmaGuy and the upstream contributors. The original GPLv3 license is retained. + +## Albion 1.1.1 — FAWE Native + +**FastAsyncWorldEdit is the required world-edit engine for this fork.** Standard WorldEdit is not a supported runtime backend. + +FAWE intentionally implements the WorldEdit API, so source imports such as `com.sk89q.worldedit.*` remain normal and expected. Those packages are the API surface used by FAWE; Albion builds require FastAsyncWorldEdit at runtime. + +The 1.1.1 goal is simple: **BetterStructures itself does not perform direct Bukkit/NMS block mutations.** Structure, module, cleanup, marker, and debug block writes are routed through FAWE. + +### FAWE-native block paths + +- natural surface, underground, sky, liquid, and other schematic structures; +- schematic air carving and NBT-rich `BaseBlock` placement; +- BetterStructures bedrock/pedestal filler behavior; +- pedestal construction below natural structures; +- tree/foliage cleanup above surface structures; +- vanilla, EliteMobs, and MythicMobs marker-block removal; +- modular/WFC dungeon batches; +- module directional/light blocks and NBT blocks without a Bukkit slow path; +- modular chest/barrel block placement; +- WFC debug lattice block placement; +- component/elevator schematic pastes. + +CI enforces this rule by scanning production Java sources and failing if direct Bukkit `setType`, `setBlockData`, or the old native-palette block-write path is reintroduced. + +## Resource-world performance design + +This fork is maintained specifically for **AlbionMC.com** with these goals: + +- preserve the gameplay, structures, content packages, and visual identity of BetterStructures 2.6.3; +- reduce MSPT/TPS spikes while players explore and generate new chunks, especially in resource worlds; +- queue structure-generation work instead of allowing bursts of expensive fitting work during new-chunk events; +- pause ordinary player-driven structure generation when server MSPT/TPS indicates the main thread is under pressure; +- serialize heavy BetterStructures FAWE edits through one global edit lane instead of allowing several large structures or dungeon batches to compete at once; +- prepare required natural-structure chunks through Paper's async chunk API in small batches; +- keep structure chunk tickets until the FAWE structure and FAWE cleanup phases are complete; +- prevent BetterStructures' own internal chunk loads from recursively generating more BetterStructures structures; +- keep Bukkit primary-thread work for APIs that require it, such as terrain sampling reads, inventory/loot handling, plugin events, and entity spawning; +- target **Paper 1.21.11 and newer only**. Older Minecraft/Paper compatibility is intentionally out of scope. + +## Performance defaults + +Player-driven structure generation is guarded by conservative load thresholds for new configs: + +- pause around **42 MSPT** or **18.5 TPS**; +- resume after recovery around **32 MSPT** and **19.5 TPS**; +- admit expensive fit jobs separately instead of allowing a burst from fast resource-world exploration; +- load structure chunks in small batches before starting the FAWE edit; +- allow only one heavy BetterStructures FAWE edit at a time. + +Existing configuration values are preserved when upgrading. These defaults are intended as a safe starting point and can be tuned from real AlbionMC spark profiles after runtime testing. + +## Requirements + +- Paper **1.21.11+** +- Java **21+** +- FastAsyncWorldEdit **2.14.3+** + - For newer Minecraft versions, use a FAWE release that explicitly supports that server version. + +Do **not** install a separate WorldEdit JAR alongside FAWE for this fork. FastAsyncWorldEdit supplies the WorldEdit API/runtime provider BetterStructures uses. + +## Fork versioning + +Albion fork releases use their own version line beginning at **1.1.0**. The current FAWE-native milestone is **1.1.1**. The upstream source baseline remains BetterStructures **2.6.3**. + +Release JARs are named: + +```text +BetterStructures-1.1.x.jar +``` + +The 1.1.1 test build is: + +```text +BetterStructures-1.1.1.jar +``` + +## Building + +```bash +./gradlew clean test shadowJar +``` + +The shaded plugin JAR is written to: + +```text +build/libs/BetterStructures-1.1.x.jar +``` + +GitHub branch test builds publish the JAR directly as a prerelease asset. Tagged releases (`v1.1.x`) also publish the JAR directly as a **raw release asset**, rather than requiring server owners to download an Actions ZIP. + +## Upstream updates + +The upstream BetterStructures self-update path is intentionally disabled in this fork so an Albion performance build cannot be silently replaced by an upstream plugin JAR. Upstream changes can still be reviewed and selectively merged into this fork. + +## License + +GPLv3, inherited from BetterStructures. See [LICENSE](LICENSE). diff --git a/build.gradle b/build.gradle index 51b961a..9a8c6bc 100644 --- a/build.gradle +++ b/build.gradle @@ -1,12 +1,11 @@ plugins { id 'java-library' id 'idea' - id 'maven-publish' id "com.gradleup.shadow" version "9.0.0-beta12" } group = 'com.magmaguy' -version = '2.6.3' +version = '1.1.1' repositories { mavenCentral() @@ -28,63 +27,57 @@ dependencies { annotationProcessor 'org.projectlombok:lombok:1.18.30' compileOnly 'org.projectlombok:lombok:1.18.30' - implementation (group: 'com.magmaguy', name: 'MagmaCore', version: '2.2.0-SNAPSHOT'){ + implementation (group: 'com.magmaguy', name: 'MagmaCore', version: '2.2.0-SNAPSHOT') { changing = true } implementation group: 'org.bstats', name: 'bstats-bukkit', version: '2.2.1' implementation group: 'org.joml', name: 'joml', version: '1.10.8' + + // Albion fork runtime baseline: Paper 1.21.11+ with FastAsyncWorldEdit. + compileOnly 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' + compileOnly 'com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit:2.14.3' + compileOnly group: 'com.magmaguy', name: 'EliteMobs', version: '9.3.3-SNAPSHOT' compileOnly group: 'io.lumine', name: 'Mythic-Dist', version: '5.2.1' compileOnly group: 'io.lumine', name: 'MythicLib-dist', version: '1.5.2-SNAPSHOT' compileOnly group: 'net.Indyuce', name: 'MMOItems-API', version: '6.9.2-SNAPSHOT' compileOnly group: 'com.sk89q.worldguard', name: 'worldguard-bukkit', version: '7.0.7' - compileOnly group: 'com.sk89q.worldedit', name: 'worldedit-bukkit', version: '7.3.0' - compileOnly 'org.spigotmc:spigot-api:26.2-R0.1-SNAPSHOT' testImplementation 'org.junit.jupiter:junit-jupiter:5.10.2' testImplementation 'io.papermc.paper:paper-api:1.21.11-R0.1-SNAPSHOT' testImplementation 'org.mockbukkit.mockbukkit:mockbukkit-v1.21:4.110.0' testImplementation 'org.mockito:mockito-core:5.12.0' - testImplementation group: 'com.sk89q.worldedit', name: 'worldedit-bukkit', version: '7.3.0' + // Tests exercise FAWE-specific clipboard/runtime code, so use the same provider + // BetterStructures requires in production instead of a separate WorldEdit runtime. + testImplementation 'com.fastasyncworldedit:FastAsyncWorldEdit-Bukkit:2.14.3' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } -//This allows any modules tagged as changing to be updated every time, which is extremely convenient for some of the shaded libs configurations.all { resolutionStrategy.cacheChangingModulesFor 0, 'seconds' } -artifacts { // task 'build' runs generates uberjar +artifacts { archives shadowJar } jar { - archiveClassifier.set('min') // we want the Uberjar to be distributed, this is the minified version + archiveClassifier.set('min') } String packagePath = 'com.magmaguy.shaded' -// Relocating a Package shadowJar { dependencies { relocate('org.bstats', packagePath + '.bstats') } relocate 'com.magmaguy.easyminecraftgoals', 'com.magmaguy.betterstructures.easyminecraftgoals' - // Relocate the shaded MagmaCore so its static singleton (and config-folder resolution via - // MagmaCore.getInstance().getRequestingPlugin()) is isolated per plugin. Without this, every - // plugin that ships MagmaCore unrelocated shares one instance/requestingPlugin, so their - // configs (e.g. setupDone) collide in a single data folder and never persist per-plugin. relocate 'com.magmaguy.magmacore', 'com.magmaguy.betterstructures.magmacore' duplicatesStrategy = DuplicatesStrategy.EXCLUDE archiveClassifier.set(null) - archiveFileName.set(project.name + ".jar") + archiveFileName.set("BetterStructures-${project.version}.jar") } -// --- Shared dist mirror ----------------------------------------------------- -// Copy the finished shaded jar into the top-level dist/ folder so the testbeds -// and the release tool pick every build up from one place. Gated on MC_DIST_DIR -// (or -PmcDistDir) so clones/CI that don't set it just build to build/libs and -// no machine-specific path is ever committed. def mcDistDir = System.getenv('MC_DIST_DIR') ?: project.findProperty('mcDistDir') if (mcDistDir) { tasks.register('mirrorToDist', Copy) { @@ -94,8 +87,9 @@ if (mcDistDir) { tasks.named('shadowJar') { finalizedBy('mirrorToDist') } } -tasks.withType(JavaCompile) { +tasks.withType(JavaCompile).configureEach { options.encoding = 'UTF-8' + options.release = 21 } test { @@ -110,41 +104,10 @@ ext { java { toolchain { - languageVersion = JavaLanguageVersion.of(21) // or your target version + languageVersion = JavaLanguageVersion.of(21) } } processResources { filter org.apache.tools.ant.filters.ReplaceTokens, tokens: resourceTokens } - -publishing { - repositories { - maven { - name = "BetterStructures" - url = "https://repo.magmaguy.com/releases" - credentials { - username = project.hasProperty('ossrhUsername') ? ossrhUsername : "Unknown user" - password = project.hasProperty('ossrhPassword') ? ossrhPassword : "Unknown password" - } - } - } - - publications { - mavenJava(MavenPublication) { - pom { - groupId = 'com.magmaguy' - name = 'betterstructures' - description = 'BetterStructures repo' - url = 'https://magmaguy.com/' - from components.java - licenses { - license { - name = 'GPLV3' - url = 'https://www.gnu.org/licenses/gpl-3.0.en.html' - } - } - } - } - } -} diff --git a/gradle.properties b/gradle.properties index 38e4051..1910e57 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,2 +1,2 @@ -ossrhUsername=magmaguy -ossrhPassword=yw44tDEksRoDdstrP7iDmITzPUJO/jRnmtE4iraRf28o1uRTIHMe0pGnqcj5FL+q +# AlbionMC performance fork. +# Upstream publishing credentials are intentionally not carried in this fork. diff --git a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java index 402b252..0eab387 100644 --- a/src/main/java/com/magmaguy/betterstructures/BetterStructures.java +++ b/src/main/java/com/magmaguy/betterstructures/BetterStructures.java @@ -16,23 +16,19 @@ import com.magmaguy.betterstructures.listeners.NewChunkLoadEvent; import com.magmaguy.betterstructures.modules.ModulesContainer; import com.magmaguy.betterstructures.modules.WFCGenerator; +import com.magmaguy.betterstructures.performance.GenerationScheduler; import com.magmaguy.betterstructures.schematics.SchematicContainer; import com.magmaguy.betterstructures.thirdparty.EliteMobs; import com.magmaguy.betterstructures.thirdparty.WorldGuard; +import com.magmaguy.betterstructures.worldedit.Schematic; import com.magmaguy.easyminecraftgoals.NMSManager; import com.magmaguy.magmacore.MagmaCore; import com.magmaguy.magmacore.command.CommandManager; -import com.magmaguy.magmacore.dlc.ConfigurationImporter; import com.magmaguy.magmacore.initialization.PluginInitializationConfig; import com.magmaguy.magmacore.initialization.PluginInitializationContext; import com.magmaguy.magmacore.initialization.PluginInitializationState; -import com.magmaguy.magmacore.nightbreak.NightbreakDownloadContentCommand; -import com.magmaguy.magmacore.nightbreak.NightbreakDownloadEverythingCommand; -import com.magmaguy.magmacore.nightbreak.NightbreakDownloadPluginUpdateCommand; import com.magmaguy.magmacore.nightbreak.NightbreakPluginSpec; -import com.magmaguy.magmacore.nightbreak.NightbreakPluginUpdater; import com.magmaguy.magmacore.nightbreak.NightbreakPluginStateRegistry; -import com.magmaguy.magmacore.nightbreak.NightbreakRecommendedPluginsCommand; import com.magmaguy.magmacore.util.Logger; import org.bstats.bukkit.Metrics; import org.bukkit.Bukkit; @@ -40,8 +36,8 @@ import org.bukkit.event.HandlerList; import org.bukkit.plugin.java.JavaPlugin; +import java.io.File; import java.io.IOException; -import java.util.ArrayList; public final class BetterStructures extends JavaPlugin { public static final NightbreakPluginSpec NIGHTBREAK_PLUGIN_SPEC = new NightbreakPluginSpec( @@ -61,7 +57,6 @@ public void onEnable() { Bukkit.getLogger().info(" / __ / _ \\/ __/ __/ _ \\/ ___/\\__ \\/ __/ ___/ / / / ___/ __/ / / / ___/ _ \\/ ___/"); Bukkit.getLogger().info(" / /_/ / __/ /_/ /_/ __/ / ___/ / /_/ / / /_/ / /__/ /_/ /_/ / / / __(__ ) "); Bukkit.getLogger().info("/_____/\\___/\\__/\\__/\\___/_/ /____/\\__/_/ \\__,_/\\___/\\__/\\__,_/_/ \\___/____/"); - // Plugin startup logic Bukkit.getLogger().info("[BetterStructures] Initialized version " + this.getDescription().getVersion() + "!"); try { this.getConfig().save("config.yml"); @@ -76,7 +71,6 @@ public void onEnable() { this::syncInitialization, () -> { Logger.info("BetterStructures fully initialized!"); - NightbreakPluginUpdater.autoDownloadPluginUpdateIfEnabled(this, NIGHTBREAK_PLUGIN_SPEC); CommandSender pendingReloadSender = NightbreakPluginStateRegistry.consumePendingReloadSender(this); if (pendingReloadSender == null) { pendingReloadSender = MetadataHandler.pendingReloadSender; @@ -110,12 +104,15 @@ public void onLoad() { public void onDisable() { MagmaCore.requestInitializationShutdown(this); if (MagmaCore.getInitializationState(this.getName()) == PluginInitializationState.INITIALIZING) { + GenerationScheduler.shutdown(); + Schematic.shutdown(); Bukkit.getServer().getScheduler().cancelTasks(MetadataHandler.PLUGIN); MagmaCore.shutdown(this); Bukkit.getLogger().info("[BetterStructures] Shutdown during initialization."); return; } - // Plugin shutdown logic + GenerationScheduler.shutdown(); + Schematic.shutdown(); SchematicContainer.shutdown(); Bukkit.getServer().getScheduler().cancelTasks(MetadataHandler.PLUGIN); MagmaCore.shutdown(this); @@ -132,9 +129,7 @@ private void asyncInitialization(PluginInitializationContext initializationConte new ValidWorldsConfig(); initializationContext.step("Content Importer"); - ConfigurationImporter importer = MagmaCore.initializeImporter(this); - if (importer != null && importer.isEliteMobsContentImported()) - EliteMobs.reloadAfterContentImport(); + importPendingContent(); initializationContext.step("Treasure Config"); new TreasureConfig(); @@ -176,28 +171,13 @@ private void syncInitialization(PluginInitializationContext initializationContex commandManager.registerCommand(new VersionCommand()); commandManager.registerCommand(new SetupCommand()); commandManager.registerCommand(new FirstTimeSetupCommand()); - commandManager.registerCommand(new NightbreakRecommendedPluginsCommand(this, NIGHTBREAK_PLUGIN_SPEC)); - commandManager.registerCommand(new NightbreakDownloadPluginUpdateCommand(this, NIGHTBREAK_PLUGIN_SPEC)); - commandManager.registerCommand(new NightbreakDownloadEverythingCommand<>(this, - NIGHTBREAK_PLUGIN_SPEC, - () -> new ArrayList<>(BSPackage.getBsPackages().values()), - ReloadCommand::reload)); - commandManager.registerCommand(new NightbreakDownloadContentCommand<>(this, - NIGHTBREAK_PLUGIN_SPEC, - () -> new ArrayList<>(BSPackage.getBsPackages().values()), - ReloadCommand::reload, - false)); - commandManager.registerCommand(new NightbreakDownloadContentCommand<>(this, - NIGHTBREAK_PLUGIN_SPEC, - () -> new ArrayList<>(BSPackage.getBsPackages().values()), - ReloadCommand::reload, - true)); + // Albion fork intentionally omits Nightbreak plugin/content-download convenience + // commands whose API is not present in the published MagmaCore snapshot. Existing + // structure packs/configuration continue to load normally, and setup package + // browsing remains available through the BetterStructures setup menu. commandManager.registerCommand(new GenerateModulesCommand()); commandManager.registerCommand(new BetterStructuresCommand()); - initializationContext.step("Version Check"); - MagmaCore.checkVersionUpdate("103241", "https://nightbreak.io/plugin/betterstructures/"); - initializationContext.step("WorldGuard Integration"); if (Bukkit.getPluginManager().getPlugin("WorldGuard") != null && Bukkit.getPluginManager().getPlugin("EliteMobs") != null) { @@ -209,6 +189,8 @@ private void syncInitialization(PluginInitializationContext initializationContex } public void reloadImportedContent(CommandSender commandSender) { + GenerationScheduler.shutdown(); + Schematic.shutdown(); SchematicContainer.shutdown(); Bukkit.getServer().getScheduler().cancelTasks(MetadataHandler.PLUGIN); BSPackage.shutdown(); @@ -217,9 +199,7 @@ public void reloadImportedContent(CommandSender commandSender) { Bukkit.getScheduler().runTaskAsynchronously(this, () -> { try { - ConfigurationImporter importer = MagmaCore.initializeImporter(this); - if (importer != null && importer.isEliteMobsContentImported()) - EliteMobs.reloadAfterContentImport(); + importPendingContent(); new TreasureConfig(); new GeneratorConfig(); new ModuleGeneratorsConfig(); @@ -230,6 +210,7 @@ public void reloadImportedContent(CommandSender commandSender) { ComponentsConfigFolder.initialize(); Bukkit.getScheduler().runTask(this, () -> { + GenerationScheduler.start(); if (commandSender != null) { Logger.sendMessage(commandSender, "Reloaded BetterStructures content."); } @@ -238,6 +219,7 @@ public void reloadImportedContent(CommandSender commandSender) { Logger.warn("Failed to reload BetterStructures content asynchronously."); exception.printStackTrace(); Bukkit.getScheduler().runTask(this, () -> { + GenerationScheduler.start(); if (commandSender != null) { Logger.sendMessage(commandSender, "&cFailed to reload BetterStructures content. Check the console."); } @@ -245,4 +227,23 @@ public void reloadImportedContent(CommandSender commandSender) { } }); } + + /** + * MagmaCore's published importer API is fire-and-forget. Record whether the imports + * directory had work before invoking it so EliteMobs is refreshed only when content + * may actually have been deposited into its folders. + */ + private void importPendingContent() { + boolean hadPendingImports = hasPendingImports(); + MagmaCore.initializeImporter(this); + if (hadPendingImports) { + EliteMobs.reloadAfterContentImport(); + } + } + + private boolean hasPendingImports() { + File importsDirectory = new File(getDataFolder(), "imports"); + File[] entries = importsDirectory.listFiles(); + return entries != null && entries.length > 0; + } } diff --git a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java index 54fa5bc..e510625 100644 --- a/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java +++ b/src/main/java/com/magmaguy/betterstructures/buildingfitter/FitAnything.java @@ -18,17 +18,20 @@ import com.magmaguy.magmacore.util.Logger; import com.magmaguy.magmacore.util.SpigotMessage; import com.magmaguy.magmacore.util.VersionChecker; +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.bukkit.BukkitAdapter; import com.sk89q.worldedit.extent.clipboard.Clipboard; +import com.sk89q.worldedit.math.BlockVector3; import lombok.Getter; import org.bukkit.Bukkit; import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.Material; +import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.BlockFace; import org.bukkit.block.Container; import org.bukkit.entity.*; -import org.bukkit.scheduler.BukkitRunnable; import org.bukkit.util.Vector; import java.util.HashMap; @@ -51,7 +54,6 @@ public class FitAnything { @Getter protected Vector schematicOffset; protected int verticalOffset = 0; - //At 10% it is assumed a fit is so bad it's better just to skip protected double highestScore = 10; @Getter protected Location location = null; @@ -103,88 +105,84 @@ protected void paste(Location location) { FitAnything fitAnything = this; - // Set pedestal material before the paste so bedrock blocks get replaced correctly - assignPedestalMaterial(location); - if (pedestalMaterial == null) - switch (location.getWorld().getEnvironment()) { - case NETHER: - pedestalMaterial = Material.NETHERRACK; - break; - case THE_END: - pedestalMaterial = Material.END_STONE; - break; - default: - pedestalMaterial = Material.STONE; - } + // Terrain sampling is a Bukkit read and therefore remains on the primary thread, + // after every structure chunk has been prepared/ticketed by Schematic. + Runnable prePasteCallback = () -> { + assignPedestalMaterial(location); + if (pedestalMaterial == null) + switch (location.getWorld().getEnvironment()) { + case NETHER: + pedestalMaterial = Material.NETHERRACK; + break; + case THE_END: + pedestalMaterial = Material.END_STONE; + break; + default: + pedestalMaterial = Material.STONE; + } + }; - // Create a function to provide pedestal material Function pedestalMaterialProvider = this::getPedestalMaterial; + Schematic.FawePostProcessor fawePostProcessor = this::applyFawePostProcessing; - // Paste the schematic with the moved logic Schematic.pasteSchematic( schematicClipboard, location, schematicOffset, + prePasteCallback, pedestalMaterialProvider, + fawePostProcessor, onPasteComplete(fitAnything, location) ); } - private BukkitRunnable onPasteComplete(FitAnything fitAnything, Location location) { - return new BukkitRunnable() { - @Override - public void run() { - if (DefaultConfig.isNewBuildingWarn()) { - String structureTypeString = fitAnything.structureType.toString().toLowerCase(Locale.ROOT).replace("_", " "); - for (Player player : Bukkit.getOnlinePlayers()) - if (player.hasPermission("betterstructures.warn")) - player.spigot().sendMessage( - SpigotMessage.commandHoverMessage("[BetterStructures] New " + structureTypeString + " building generated! Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", - "Click to teleport to " + location.getWorld().getName() + ", " + location.getBlockX() + ", " + location.getBlockY() + ", " + location.getBlockZ() + "\n Schem name: " + schematicContainer.getConfigFilename(), - "/betterstructures teleport " + location.getWorld().getName() + " " + location.getBlockX() + " " + location.getBlockY() + " " + location.getBlockZ()) - ); - } - - if (!(fitAnything instanceof FitAirBuilding)) { - try { - addPedestal(location); - } catch (Exception exception) { - Logger.warn("Failed to correctly assign pedestal material!"); - exception.printStackTrace(); - } - try { - if (fitAnything instanceof FitSurfaceBuilding) - clearTrees(location); - } catch (Exception exception) { - Logger.warn("Failed to correctly clear trees!"); - exception.printStackTrace(); - } - } - try { - fillChests(); - } catch (Exception exception) { - Logger.warn("Failed to correctly fill chests!"); - exception.printStackTrace(); - } - try { - spawnEntities(); - } catch (Exception exception) { - Logger.warn("Failed to correctly spawn entities!"); - exception.printStackTrace(); - } - try{ - spawnProps(fitAnything.schematicClipboard); - } catch (Exception exception) { - Logger.warn("Failed to correctly spawn props!"); - exception.printStackTrace(); - } + /** + * Runs after the schematic blocks are in the same async FAWE EditSession, while the + * structure's chunks are still ticketed. No Bukkit block mutations are allowed here. + */ + private void applyFawePostProcessing(EditSession editSession, Location adjustedLocation) throws Exception { + if (!(this instanceof FitAirBuilding)) { + if (!(this instanceof FitLiquidBuilding)) { + addPedestalFawe(editSession, adjustedLocation); } - }; + if (this instanceof FitSurfaceBuilding) { + clearTreesFawe(editSession, adjustedLocation); + } + } + + clearEntityMarkersFawe(editSession, adjustedLocation); + + // Entity clipboard placement is also a FAWE operation and no longer consumes the + // primary-thread completion phase. + WorldEditUtils.pasteArmorStandsOnlyFromTransformed(schematicClipboard, adjustedLocation); } - private void spawnProps(Clipboard clipboard) { - // Don't add schematicOffset here - let pasteArmorStandsOnlyFromTransformed handle the alignment - WorldEditUtils.pasteArmorStandsOnlyFromTransformed(clipboard, location.clone().add(schematicOffset)); + private Runnable onPasteComplete(FitAnything fitAnything, Location location) { + return () -> { + if (DefaultConfig.isNewBuildingWarn()) { + String structureTypeString = fitAnything.structureType.toString().toLowerCase(Locale.ROOT).replace("_", " "); + for (Player player : Bukkit.getOnlinePlayers()) + if (player.hasPermission("betterstructures.warn")) + player.spigot().sendMessage( + SpigotMessage.commandHoverMessage("[BetterStructures] New " + structureTypeString + " building generated! Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", + "Click to teleport to " + location.getWorld().getName() + ", " + location.getBlockX() + ", " + location.getBlockY() + ", " + location.getBlockZ() + "\n Schem name: " + schematicContainer.getConfigFilename(), + "/betterstructures teleport " + location.getWorld().getName() + " " + location.getBlockX() + " " + location.getBlockY() + " " + location.getBlockZ()) + ); + } + + try { + fillChests(); + } catch (Exception exception) { + Logger.warn("Failed to correctly fill chests!"); + exception.printStackTrace(); + } + try { + spawnEntities(); + } catch (Exception exception) { + Logger.warn("Failed to correctly spawn entities!"); + exception.printStackTrace(); + } + }; } private void assignPedestalMaterial(Location location) { @@ -192,27 +190,37 @@ private void assignPedestalMaterial(Location location) { pedestalMaterial = schematicContainer.getSchematicConfigField().getPedestalMaterial(); Location lowestCorner = location.clone().add(schematicOffset); - int maxSurfaceHeightScan = 20; - - //get underground pedestal blocks - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) - for (int y = 0; y < schematicClipboard.getDimensions().y(); y++) { - Block groundBlock = lowestCorner.clone().add(new Vector(x, y, z)).getBlock(); + int sizeX = schematicClipboard.getDimensions().x(); + int sizeY = schematicClipboard.getDimensions().y(); + int sizeZ = schematicClipboard.getDimensions().z(); + int baseX = lowestCorner.getBlockX(); + int baseY = lowestCorner.getBlockY(); + int baseZ = lowestCorner.getBlockZ(); + World world = lowestCorner.getWorld(); + + int xStep = Math.max(1, Math.ceilDiv(sizeX, 24)); + int yStep = Math.max(1, Math.ceilDiv(sizeY, 12)); + int zStep = Math.max(1, Math.ceilDiv(sizeZ, 24)); + + for (int x = 0; x < sizeX; x += xStep) + for (int z = 0; z < sizeZ; z += zStep) + for (int y = 0; y < sizeY; y += yStep) { + Block groundBlock = world.getBlockAt(baseX + x, baseY + y, baseZ + z); Block aboveBlock = groundBlock.getRelative(BlockFace.UP); - - if (aboveBlock.getType().isSolid() && groundBlock.getType().isSolid() && !SurfaceMaterials.ignorable(groundBlock.getType())) + if (aboveBlock.getType().isSolid() + && groundBlock.getType().isSolid() + && !SurfaceMaterials.ignorable(groundBlock.getType())) { undergroundPedestalMaterials.merge(groundBlock.getType(), 1, Integer::sum); + } } - //get above ground pedestal blocks, if any - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { - boolean scanUp = lowestCorner.clone().add(new Vector(x, schematicClipboard.getDimensions().y(), z)).getBlock().getType().isSolid(); + int maxSurfaceHeightScan = 20; + for (int x = 0; x < sizeX; x += xStep) + for (int z = 0; z < sizeZ; z += zStep) { + boolean scanUp = world.getBlockAt(baseX + x, baseY + sizeY, baseZ + z).getType().isSolid(); for (int y = 0; y < maxSurfaceHeightScan; y++) { - Block groundBlock = lowestCorner.clone().add(new Vector(x, scanUp ? y : -y, z)).getBlock(); + Block groundBlock = world.getBlockAt(baseX + x, baseY + (scanUp ? y : -y), baseZ + z); Block aboveBlock = groundBlock.getRelative(BlockFace.UP); - if (!aboveBlock.getType().isSolid() && groundBlock.getType().isSolid()) { surfacePedestalMaterials.merge(groundBlock.getType(), 1, Integer::sum); break; @@ -232,60 +240,84 @@ private Material getPedestalMaterial(boolean isPedestalSurface) { } public Material getRandomMaterialBasedOnWeight(HashMap weightedMaterials) { - // Calculate the total weight int totalWeight = weightedMaterials.values().stream().mapToInt(Integer::intValue).sum(); - - // Generate a random number in the range of 0 (inclusive) to totalWeight (exclusive) int randomNumber = ThreadLocalRandom.current().nextInt(totalWeight); - - // Iterate through the materials and pick one based on the random number int cumulativeWeight = 0; for (Map.Entry entry : weightedMaterials.entrySet()) { cumulativeWeight += entry.getValue(); - if (randomNumber < cumulativeWeight) { - return entry.getKey(); - } + if (randomNumber < cumulativeWeight) return entry.getKey(); } - - // Fallback return, should not occur if the map is not empty and weights are positive throw new IllegalStateException("Weighted random selection failed."); } - private void addPedestal(Location location) { - if (this instanceof FitAirBuilding || this instanceof FitLiquidBuilding) return; - Location lowestCorner = location.clone().add(schematicOffset); - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { - //Only add pedestals for areas with a solid floor, some schematics can have rounded air edges to better fit terrain - Block groundBlock = lowestCorner.clone().add(new Vector(x, 0, z)).getBlock(); - if (groundBlock.getType().isAir()) continue; + private void addPedestalFawe(EditSession editSession, Location adjustedLocation) throws Exception { + int sizeX = schematicClipboard.getDimensions().x(); + int sizeZ = schematicClipboard.getDimensions().z(); + int baseX = adjustedLocation.getBlockX(); + int baseY = adjustedLocation.getBlockY(); + int baseZ = adjustedLocation.getBlockZ(); + + for (int x = 0; x < sizeX; x++) { + for (int z = 0; z < sizeZ; z++) { + BlockVector3 ground = BlockVector3.at(baseX + x, baseY, baseZ + z); + if (editSession.getBlock(ground).getBlockType().getMaterial().isAir()) continue; + for (int y = -1; y > -11; y--) { - Block block = lowestCorner.clone().add(new Vector(x, y, z)).getBlock(); - if (SurfaceMaterials.ignorable(block.getType())) - block.setType(getPedestalMaterial(!block.getRelative(BlockFace.UP).getType().isSolid())); - else { - //Pedestal only fills until it hits the first solid block - break; + BlockVector3 position = BlockVector3.at(baseX + x, baseY + y, baseZ + z); + Material existing = WorldEditUtils.adaptMaterial(editSession.getBlock(position)); + if (existing == null || !SurfaceMaterials.ignorable(existing)) break; + + boolean surface = !editSession.getBlock(position.add(0, 1, 0)) + .getBlockType().getMaterial().isSolid(); + Material replacement = getPedestalMaterial(surface); + if (replacement != null) { + editSession.setBlock(position, BukkitAdapter.adapt(replacement.createBlockData())); } } } + } } - private void clearTrees(Location location) { - Location highestCorner = location.clone().add(schematicOffset).add(new Vector(0, schematicClipboard.getDimensions().y() + 1, 0)); - boolean detectedTreeElement = true; - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { + private void clearTreesFawe(EditSession editSession, Location adjustedLocation) throws Exception { + int sizeX = schematicClipboard.getDimensions().x(); + int sizeZ = schematicClipboard.getDimensions().z(); + int baseX = adjustedLocation.getBlockX(); + int baseY = adjustedLocation.getBlockY() + schematicClipboard.getDimensions().y() + 1; + int baseZ = adjustedLocation.getBlockZ(); + + for (int x = 0; x < sizeX; x++) { + for (int z = 0; z < sizeZ; z++) { for (int y = 0; y < 31; y++) { - if (!detectedTreeElement) break; - detectedTreeElement = false; - Block block = highestCorner.clone().add(new Vector(x, y, z)).getBlock(); - if (SurfaceMaterials.ignorable(block.getType()) && !block.getType().isAir()) { - detectedTreeElement = true; - block.setType(Material.AIR); + BlockVector3 position = BlockVector3.at(baseX + x, baseY + y, baseZ + z); + Material existing = WorldEditUtils.adaptMaterial(editSession.getBlock(position)); + if (existing != null && !existing.isAir() && SurfaceMaterials.ignorable(existing)) { + editSession.setBlock(position, BukkitAdapter.adapt(Material.AIR.createBlockData())); + } else { + break; } } } + } + } + + private void clearEntityMarkersFawe(EditSession editSession, Location adjustedLocation) throws Exception { + for (Vector position : schematicContainer.getVanillaSpawns().keySet()) { + clearMarker(editSession, adjustedLocation, position); + } + for (Vector position : schematicContainer.getEliteMobsSpawns().keySet()) { + clearMarker(editSession, adjustedLocation, position); + } + for (Vector position : schematicContainer.getMythicMobsSpawns().keySet()) { + clearMarker(editSession, adjustedLocation, position); + } + } + + private void clearMarker(EditSession editSession, Location adjustedLocation, Vector relative) throws Exception { + BlockVector3 worldPosition = BlockVector3.at( + adjustedLocation.getBlockX() + relative.getBlockX(), + adjustedLocation.getBlockY() + relative.getBlockY(), + adjustedLocation.getBlockZ() + relative.getBlockZ()); + editSession.setBlock(worldPosition, BukkitAdapter.adapt(Material.AIR.createBlockData())); } private void fillChests() { @@ -311,76 +343,56 @@ private void fillChests() { contents = schematicContainer.getBarrelContents(); String schematicBarrelFile = schematicContainer.getSchematicConfigField().getBarrelTreasureFilename(); treasureFilename = (schematicBarrelFile != null && !schematicBarrelFile.isEmpty()) - ? schematicBarrelFile - : gen.getBarrelTreasureFilename(); + ? schematicBarrelFile : gen.getBarrelTreasureFilename(); } else { contents = schematicContainer.getChestContents(); String schematicTreasureFile = schematicContainer.getSchematicConfigField().getTreasureFile(); treasureFilename = (schematicTreasureFile != null && !schematicTreasureFile.isEmpty()) - ? schematicTreasureFile - : gen.getTreasureFilename(); + ? schematicTreasureFile : gen.getTreasureFilename(); } if (contents == null) continue; contents.rollChestContents(container); - ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); - if (!chestFillEvent.isCancelled()) { - container.update(true); - } + if (!chestFillEvent.isCancelled()) container.update(true); } } private void spawnEntities() { for (Vector entityPosition : schematicContainer.getVanillaSpawns().keySet()) { Location signLocation = LocationProjector.project(location, schematicOffset, entityPosition).clone(); - signLocation.getBlock().setType(Material.AIR); - //If mobs spawn in corners they might choke on adjacent walls signLocation.add(new Vector(0.5, 0, 0.5)); - //I think FAWE is messing with this - signLocation.getChunk().load(); Entity entity = signLocation.getWorld().spawnEntity(signLocation, schematicContainer.getVanillaSpawns().get(entityPosition)); entity.setPersistent(true); - if (entity instanceof LivingEntity) { - ((LivingEntity) entity).setRemoveWhenFarAway(false); - } + if (entity instanceof LivingEntity livingEntity) livingEntity.setRemoveWhenFarAway(false); - if (!VersionChecker.serverVersionOlderThan(21, 0) && - entity.getType().equals(EntityType.END_CRYSTAL)) { + if (!VersionChecker.serverVersionOlderThan(21, 0) && entity.getType().equals(EntityType.END_CRYSTAL)) { EnderCrystal enderCrystal = (EnderCrystal) entity; enderCrystal.setShowingBottom(false); } } + for (Vector elitePosition : schematicContainer.getEliteMobsSpawns().keySet()) { Location eliteLocation = LocationProjector.project(location, schematicOffset, elitePosition).clone(); - eliteLocation.getBlock().setType(Material.AIR); eliteLocation.add(new Vector(0.5, 0, 0.5)); String bossFilename = schematicContainer.getEliteMobsSpawns().get(elitePosition); - //If the spawn fails then don't continue if (!EliteMobs.Spawn(eliteLocation, bossFilename)) return; Location lowestCorner = location.clone().add(schematicOffset); Location highestCorner = lowestCorner.clone().add(new Vector(schematicClipboard.getRegion().getWidth() - 1, schematicClipboard.getRegion().getHeight(), schematicClipboard.getRegion().getLength() - 1)); - if (DefaultConfig.isProtectEliteMobsRegions() && - Bukkit.getPluginManager().getPlugin("WorldGuard") != null && - Bukkit.getPluginManager().getPlugin("EliteMobs") != null) { + if (DefaultConfig.isProtectEliteMobsRegions() + && Bukkit.getPluginManager().getPlugin("WorldGuard") != null + && Bukkit.getPluginManager().getPlugin("EliteMobs") != null) { WorldGuard.Protect(lowestCorner, highestCorner, bossFilename, eliteLocation); - } else { - if (!worldGuardWarn) { - worldGuardWarn = true; - Logger.warn("You are not using WorldGuard, so BetterStructures could not protect a boss arena! Using WorldGuard is recommended to guarantee a fair combat experience."); - } + } else if (!worldGuardWarn) { + worldGuardWarn = true; + Logger.warn("You are not using WorldGuard, so BetterStructures could not protect a boss arena! Using WorldGuard is recommended to guarantee a fair combat experience."); } } - // carm start - Support for MythicMobs for (Map.Entry entry : schematicContainer.getMythicMobsSpawns().entrySet()) { Location mobLocation = LocationProjector.project(location, schematicOffset, entry.getKey()).clone(); - mobLocation.getBlock().setType(Material.AIR); - - //If the spawn fails then don't continue if (!MythicMobs.Spawn(mobLocation, entry.getValue())) return; } - // carm end - Support for MythicMobs } } diff --git a/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java b/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java index aa656f7..489278b 100644 --- a/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java +++ b/src/main/java/com/magmaguy/betterstructures/commands/GenerateModulesCommand.java @@ -6,16 +6,22 @@ import com.magmaguy.magmacore.command.AdvancedCommand; import com.magmaguy.magmacore.command.CommandData; import com.magmaguy.magmacore.command.SenderType; -import com.magmaguy.magmacore.command.arguments.DynamicListStringCommandArgument; +import com.magmaguy.magmacore.command.arguments.ListStringCommandArgument; import com.magmaguy.magmacore.util.Logger; +import java.util.ArrayList; import java.util.List; public class GenerateModulesCommand extends AdvancedCommand { public GenerateModulesCommand() { super(List.of("generateModules")); setUsage("/bs generateModules "); - addArgument("moduleGeneratorsConfigFile", new DynamicListStringCommandArgument(() -> ModuleGeneratorsConfig.getModuleGenerators().keySet().stream().toList(), "")); + // Generator configs are fully loaded before commands are registered, so a stable + // snapshot list gives us tab completion without relying on the removed dynamic + // argument class from newer/unpublished MagmaCore revisions. + addArgument("moduleGeneratorsConfigFile", new ListStringCommandArgument( + new ArrayList<>(ModuleGeneratorsConfig.getModuleGenerators().keySet()), + "")); setPermission("betterstructures.generatemodules"); setDescription("Generates modular builds in a dedicated world, based on the generator's configuration file."); setSenderType(SenderType.PLAYER); @@ -23,18 +29,14 @@ public GenerateModulesCommand() { @Override public void execute(CommandData commandData) { -// if (commandData.getIntegerArgument("radius") > 80 && Runtime.getRuntime().maxMemory() <= 4L * 1024 * 1024 * 1024) { -// Logger.sendMessage(commandData.getCommandSender(), -// "You do not have enough RAM for a radius above 80, you will definitely want more than 4GB of RAM for that. Consider pregenerating it locally on a computer that has more RAM and then putting the world in your server!"); -// return; -// } - ModuleGeneratorsConfigFields moduleGeneratorsConfigFields = ModuleGeneratorsConfig.getModuleGenerators().get(commandData.getStringArgument("moduleGeneratorsConfigFile")); + ModuleGeneratorsConfigFields moduleGeneratorsConfigFields = ModuleGeneratorsConfig.getModuleGenerators().get( + commandData.getStringArgument("moduleGeneratorsConfigFile")); if (moduleGeneratorsConfigFields == null) { - Logger.sendMessage(commandData.getCommandSender(), "File " + commandData.getStringArgument("moduleGeneratorsConfigFile") + " not found! The world won't generate."); + Logger.sendMessage(commandData.getCommandSender(), "File " + + commandData.getStringArgument("moduleGeneratorsConfigFile") + + " not found! The world won't generate."); return; } - WFCGenerator.generateFromConfig( - moduleGeneratorsConfigFields, - commandData.getPlayerSender()); + WFCGenerator.generateFromConfig(moduleGeneratorsConfigFields, commandData.getPlayerSender()); } } diff --git a/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java b/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java index 91fc4bc..9777cf3 100644 --- a/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java +++ b/src/main/java/com/magmaguy/betterstructures/config/DefaultConfig.java @@ -2,7 +2,6 @@ import com.magmaguy.magmacore.config.ConfigurationEngine; import com.magmaguy.magmacore.config.ConfigurationFile; -import com.magmaguy.magmacore.nightbreak.NightbreakPluginUpdater; import lombok.Getter; import java.util.List; @@ -40,7 +39,21 @@ public class DefaultConfig extends ConfigurationFile { @Getter private static int modularChunkPastingSpeed = 10; @Getter - private static double percentageOfTickUsedForPasting = 0.2; + private static double percentageOfTickUsedForPasting = 0.08; + @Getter + private static double percentageOfTickUsedForPastePreparation = 0.04; + @Getter + private static boolean playerGenerationThrottling = true; + @Getter + private static double playerGenerationPauseMSPT = 42.0; + @Getter + private static double playerGenerationResumeMSPT = 32.0; + @Getter + private static double playerGenerationPauseTPS = 18.5; + @Getter + private static double playerGenerationResumeTPS = 19.5; + @Getter + private static int playerGenerationTicksBetweenJobs = 2; @Getter private static double percentageOfTickUsedForPregeneration = 0.1; @Getter @@ -48,7 +61,6 @@ public class DefaultConfig extends ConfigurationFile { @Getter private static double pregenerationTPSResumeThreshold = 14.0; - // Adding getters for the new distance and offset variables @Getter private static int distanceSurface; @Getter @@ -95,7 +107,6 @@ public static void toggleSetupDone(boolean value) { ConfigurationEngine.writeValue(setupDone, instance.file, instance.getFileConfiguration(), "setupDone"); } - public static boolean toggleWarnings() { newBuildingWarn = !newBuildingWarn; ConfigurationEngine.writeValue(newBuildingWarn, instance.file, instance.fileConfiguration, "warnAdminsAboutNewBuildings"); @@ -119,13 +130,47 @@ public void initializeValues() { protectEliteMobsRegions = ConfigurationEngine.setBoolean(fileConfiguration, "protectEliteMobsRegions", true); setupDone = ConfigurationEngine.setBoolean(fileConfiguration, "setupDone", false); modularChunkPastingSpeed = ConfigurationEngine.setInt(fileConfiguration, "modularChunkPastingSpeed", 10); - percentageOfTickUsedForPasting = ConfigurationEngine.setDouble(List.of("Sets the maximum percentage of a tick that BetterStructures will use to paste builds, however many it maybe trying to generate.", "Ranges from 0.01 to 1, where 0.01 is 1% and 1 is 100%.", "Slower speeds will lower performance impact, but can lead to other problems such as builds suddenly popping in."),fileConfiguration, "percentageOfTickUsedForPasting", 0.2); + percentageOfTickUsedForPasting = ConfigurationEngine.setDouble( + List.of( + "Maximum percentage of a 50ms tick used by the distributed block-paste stage.", + "Albion performance default is 0.08 (about 4ms of a healthy tick).", + "Existing configs with a higher value are preserved; lower this if resource-world exploration still causes spikes."), + fileConfiguration, "percentageOfTickUsedForPasting", 0.08); + percentageOfTickUsedForPastePreparation = ConfigurationEngine.setDouble( + List.of( + "Maximum percentage of a 50ms tick used to prepare a schematic before block placement.", + "This prevents the old all-at-once schematic walk from monopolizing a single tick.", + "0.04 is about 2ms of work per tick."), + fileConfiguration, "percentageOfTickUsedForPastePreparation", 0.04); + playerGenerationThrottling = ConfigurationEngine.setBoolean( + fileConfiguration, "playerGenerationThrottling", true); + playerGenerationPauseMSPT = ConfigurationEngine.setDouble( + List.of( + "Pause player-driven BetterStructures generation when average MSPT reaches this value.", + "The chunk is kept queued and will be processed after the server recovers."), + fileConfiguration, "playerGenerationPauseMSPT", 42.0); + playerGenerationResumeMSPT = ConfigurationEngine.setDouble( + List.of( + "Resume queued player-driven generation when average MSPT falls to or below this value.", + "Keep this lower than playerGenerationPauseMSPT to avoid rapid pause/resume oscillation."), + fileConfiguration, "playerGenerationResumeMSPT", 32.0); + playerGenerationPauseTPS = ConfigurationEngine.setDouble( + List.of("Secondary TPS guard for player-driven structure generation."), + fileConfiguration, "playerGenerationPauseTPS", 18.5); + playerGenerationResumeTPS = ConfigurationEngine.setDouble( + List.of("TPS required before paused player-driven structure generation resumes."), + fileConfiguration, "playerGenerationResumeTPS", 19.5); + playerGenerationTicksBetweenJobs = ConfigurationEngine.setInt( + List.of( + "Minimum ticks between expensive structure-fit jobs from ordinary player exploration.", + "A value of 2 prevents several qualifying chunks from running their fitting passes in one tick."), + fileConfiguration, "playerGenerationTicksBetweenJobs", 2); percentageOfTickUsedForPregeneration = ConfigurationEngine.setDouble(List.of("Sets the maximum percentage of a tick that BetterStructures will use for world pregeneration when using the pregenerate command.", "Ranges from 0.01 to 1, where 0.01 is 1% and 1 is 100%.", "This controls how much of each server tick is dedicated to generating chunks, allowing you to balance generation speed with server performance.", "Lower values will generate chunks more slowly but reduce server lag, while higher values will generate faster but may impact server performance."), fileConfiguration, "percentageOfTickUsedForPregeneration", 0.1); pregenerationTPSPauseThreshold = ConfigurationEngine.setDouble(List.of("The TPS threshold at which chunk pregeneration will pause to protect server performance.", "When server TPS drops below this value, pregeneration will pause until TPS recovers.", "Default: 12.0"), fileConfiguration, "pregenerationTPSPauseThreshold", 12.0); pregenerationTPSResumeThreshold = ConfigurationEngine.setDouble(List.of("The TPS threshold at which chunk pregeneration will resume after being paused.", "Pregeneration will only resume when server TPS is at or above this value.", "Should be higher than the pause threshold to prevent rapid pause/resume cycles.", "Default: 14.0"), fileConfiguration, "pregenerationTPSResumeThreshold", 14.0); - autoDownloadPluginUpdates = NightbreakPluginUpdater.setAutoDownloadConfigDefault(fileConfiguration); + // Albion fork safety: never let the upstream Nightbreak updater replace this fork JAR. + autoDownloadPluginUpdates = false; - // Initialize the distances from configuration distanceSurface = ConfigurationEngine.setInt( List.of( "Sets the distance between structures in the surface of a world.", @@ -153,11 +198,9 @@ public void initializeValues() { distanceDungeon = ConfigurationEngine.setInt( List.of( "Sets the distance between dungeons.", - "Shorter distances between dungeons will result in more dungeons overall." - ), + "Shorter distances between dungeons will result in more dungeons overall."), fileConfiguration, "distanceDungeonV2", 80); - // Initialize the maximum offsets from configuration maxOffsetSurface = ConfigurationEngine.setInt( List.of( "Used to tweak the randomization of the distance between structures in the surface of a world.", diff --git a/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java b/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java index cd08239..99d9f4a 100644 --- a/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java +++ b/src/main/java/com/magmaguy/betterstructures/listeners/NewChunkLoadEvent.java @@ -12,7 +12,9 @@ import com.magmaguy.betterstructures.config.modulegenerators.ModuleGeneratorsConfig; import com.magmaguy.betterstructures.config.modulegenerators.ModuleGeneratorsConfigFields; import com.magmaguy.betterstructures.modules.WFCGenerator; +import com.magmaguy.betterstructures.performance.GenerationScheduler; import com.magmaguy.betterstructures.schematics.SchematicContainer; +import com.magmaguy.betterstructures.worldedit.Schematic; import org.bukkit.Chunk; import org.bukkit.event.EventHandler; import org.bukkit.event.EventPriority; @@ -24,89 +26,119 @@ import java.util.HashSet; import java.util.List; import java.util.Random; +import java.util.Set; +import java.util.UUID; import java.util.concurrent.ThreadLocalRandom; public class NewChunkLoadEvent implements Listener { - private static HashSet loadingChunks = new HashSet<>(); + private static final Set loadingChunks = new HashSet<>(); + + public NewChunkLoadEvent() { + GenerationScheduler.start(); + } @EventHandler(priority = EventPriority.HIGHEST, ignoreCancelled = true) public void onChunkLoad(ChunkLoadEvent event) { if (!event.isNewChunk()) return; - if (loadingChunks.contains(event.getChunk())) return; - //In some cases the same chunk gets loaded (at least at an event level) several times, this prevents the plugin from doing multiple scans and placing multiple builds, enhancing performance - loadingChunks.add(event.getChunk()); + // BetterStructures may need to generate neighboring chunks before an already + // selected structure can be pasted. Those internal loads must never recursively + // qualify for more BetterStructures generation. + if (Schematic.isInternalChunkLoad(event.getChunk())) return; + if (!ValidWorldsConfig.isValidWorld(event.getWorld())) return; + + Chunk chunk = event.getChunk(); + ChunkKey chunkKey = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + if (!loadingChunks.add(chunkKey)) return; + new BukkitRunnable() { @Override public void run() { - loadingChunks.remove(event.getChunk()); + loadingChunks.remove(chunkKey); } }.runTaskLater(MetadataHandler.PLUGIN, 20L); - if (!ValidWorldsConfig.isValidWorld(event.getWorld())) return; - surfaceScanner(event.getChunk()); - shallowUndergroundScanner(event.getChunk()); - deepUndergroundScanner(event.getChunk()); - skyScanner(event.getChunk()); - liquidSurfaceScanner(event.getChunk()); - dungeonScanner(event.getChunk()); + // Position checks are deterministic and cheap. Only chunks that can actually + // contain a BetterStructures build enter the expensive generation queue. + List jobs = new ArrayList<>(2); + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SURFACE).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SURFACE, + DefaultConfig.getDistanceSurface(), DefaultConfig.getMaxOffsetSurface())) { + jobs.add(() -> new FitSurfaceBuilding(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW, + DefaultConfig.getDistanceShallow(), DefaultConfig.getMaxOffsetShallow())) { + jobs.add(() -> FitUndergroundShallowBuilding.fit(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_DEEP).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_DEEP, + DefaultConfig.getDistanceDeep(), DefaultConfig.getMaxOffsetDeep())) { + jobs.add(() -> FitUndergroundDeepBuilding.fit(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SKY).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SKY, + DefaultConfig.getDistanceSky(), DefaultConfig.getMaxOffsetSky())) { + jobs.add(() -> new FitAirBuilding(chunk)); + } + + if (!SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.LIQUID_SURFACE).isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.LIQUID_SURFACE, + DefaultConfig.getDistanceLiquid(), DefaultConfig.getMaxOffsetLiquid())) { + jobs.add(() -> new FitLiquidBuilding(chunk)); + } + + if (!ModuleGeneratorsConfig.getModuleGenerators().isEmpty() + && isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.DUNGEON, + DefaultConfig.getDistanceDungeon(), DefaultConfig.getMaxOffsetDungeon())) { + jobs.add(() -> generateDungeon(chunk)); + } + + GenerationScheduler.enqueue(chunk, jobs); } /** * Determines if the given chunk is a valid structure position based on * a diamond grid pattern with seeded random offsets. - * - * @param chunk The chunk to check - * @param structureType The type of structure - * @param gridDistance The distance between grid points - * @param maxOffset The maximum random offset from grid points - * @return True if this chunk should have a structure */ private boolean isValidStructurePosition(Chunk chunk, GeneratorConfigFields.StructureType structureType, int gridDistance, int maxOffset) { int x = chunk.getX(); int z = chunk.getZ(); - // Check spawn protection radius (2D distance from 0,0 in blocks) int spawnProtectionRadius = DefaultConfig.getSpawnProtectionRadius(); if (spawnProtectionRadius > 0) { int blockX = x * 16 + 8; int blockZ = z * 16 + 8; - if ((long) blockX * blockX + (long) blockZ * blockZ < (long) spawnProtectionRadius * spawnProtectionRadius) { + if ((long) blockX * blockX + (long) blockZ * blockZ + < (long) spawnProtectionRadius * spawnProtectionRadius) { return false; } } long worldSeed = chunk.getWorld().getSeed(); + long typeSeed = worldSeed + structureType.name().hashCode() * 7919L; - // Create a unique seed for each structure type - long typeSeed = worldSeed + structureType.name().hashCode() * 7919; // Use a prime number for better distribution - - // Check all nearby grid cells that could have a structure landing on this chunk - for (int gridX = (x - maxOffset) / gridDistance - 1; gridX <= (x + maxOffset) / gridDistance + 1; gridX++) { - for (int gridZ = (z - maxOffset) / gridDistance - 1; gridZ <= (z + maxOffset) / gridDistance + 1; gridZ++) { - // Base position of this grid cell + for (int gridX = (x - maxOffset) / gridDistance - 1; + gridX <= (x + maxOffset) / gridDistance + 1; gridX++) { + for (int gridZ = (z - maxOffset) / gridDistance - 1; + gridZ <= (z + maxOffset) / gridDistance + 1; gridZ++) { int baseX = gridX * gridDistance; int baseZ = gridZ * gridDistance; - // Apply diamond pattern offset (shift every other row by gridDistance/2) if (gridZ % 2 != 0) { baseX += gridDistance / 2; } - // Create a seeded random for this specific grid cell - Random cellRandom = new Random(typeSeed ^ (((long)baseX << 32) | (baseZ & 0xFFFFFFFFL))); - - // Generate the random offset for structure in this grid cell + Random cellRandom = new Random(typeSeed ^ (((long) baseX << 32) | (baseZ & 0xFFFFFFFFL))); int offsetX = maxOffset > 0 ? cellRandom.nextInt(maxOffset * 2 + 1) - maxOffset : 0; int offsetZ = maxOffset > 0 ? cellRandom.nextInt(maxOffset * 2 + 1) - maxOffset : 0; - // Final structure position for this grid cell - int structureX = baseX + offsetX; - int structureZ = baseZ + offsetZ; - - // If this chunk matches the structure position - if (x == structureX && z == structureZ) { + if (x == baseX + offsetX && z == baseZ + offsetZ) { return true; } } @@ -115,54 +147,22 @@ private boolean isValidStructurePosition(Chunk chunk, GeneratorConfigFields.Stru return false; } - private void surfaceScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SURFACE).isEmpty()) return; - // Get config values directly instead of using static finals - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SURFACE, - DefaultConfig.getDistanceSurface(), DefaultConfig.getMaxOffsetSurface())) return; - new FitSurfaceBuilding(chunk); - } - - private void shallowUndergroundScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_SHALLOW, - DefaultConfig.getDistanceShallow(), DefaultConfig.getMaxOffsetShallow())) return; - FitUndergroundShallowBuilding.fit(chunk); - } - - private void deepUndergroundScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.UNDERGROUND_DEEP).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.UNDERGROUND_DEEP, - DefaultConfig.getDistanceDeep(), DefaultConfig.getMaxOffsetDeep())) return; - FitUndergroundDeepBuilding.fit(chunk); - } - - private void skyScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.SKY).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.SKY, - DefaultConfig.getDistanceSky(), DefaultConfig.getMaxOffsetSky())) return; - new FitAirBuilding(chunk); - } - - private void liquidSurfaceScanner(Chunk chunk) { - if (SchematicContainer.getSchematics().get(GeneratorConfigFields.StructureType.LIQUID_SURFACE).isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.LIQUID_SURFACE, - DefaultConfig.getDistanceLiquid(), DefaultConfig.getMaxOffsetLiquid())) return; - new FitLiquidBuilding(chunk); - } - - private void dungeonScanner(Chunk chunk) { - if (ModuleGeneratorsConfig.getModuleGenerators().isEmpty()) return; - if (!isValidStructurePosition(chunk, GeneratorConfigFields.StructureType.DUNGEON, - DefaultConfig.getDistanceDungeon(), DefaultConfig.getMaxOffsetDungeon())) return; + private void generateDungeon(Chunk chunk) { List validatedGenerators = new ArrayList<>(); - for (ModuleGeneratorsConfigFields moduleGeneratorsConfigFields : ModuleGeneratorsConfig.getModuleGenerators().values()){ - if (moduleGeneratorsConfigFields.getValidWorlds() != null && !moduleGeneratorsConfigFields.getValidWorlds().isEmpty() && !moduleGeneratorsConfigFields.getValidWorlds().contains(chunk.getWorld().getName())) continue; - if (moduleGeneratorsConfigFields.getValidWorldEnvironments() != null && !moduleGeneratorsConfigFields.getValidWorldEnvironments().isEmpty() && !moduleGeneratorsConfigFields.getValidWorldEnvironments().contains(chunk.getWorld().getEnvironment())) continue; - validatedGenerators.add(moduleGeneratorsConfigFields); + for (ModuleGeneratorsConfigFields fields : ModuleGeneratorsConfig.getModuleGenerators().values()) { + if (fields.getValidWorlds() != null && !fields.getValidWorlds().isEmpty() + && !fields.getValidWorlds().contains(chunk.getWorld().getName())) continue; + if (fields.getValidWorldEnvironments() != null && !fields.getValidWorldEnvironments().isEmpty() + && !fields.getValidWorldEnvironments().contains(chunk.getWorld().getEnvironment())) continue; + validatedGenerators.add(fields); } + if (validatedGenerators.isEmpty()) return; - ModuleGeneratorsConfigFields moduleGeneratorsConfigFields = validatedGenerators.get(ThreadLocalRandom.current().nextInt(0, validatedGenerators.size())); - new WFCGenerator(moduleGeneratorsConfigFields, chunk.getBlock(8,moduleGeneratorsConfigFields.getCenterModuleAltitude(),8).getLocation()); + ModuleGeneratorsConfigFields fields = validatedGenerators.get( + ThreadLocalRandom.current().nextInt(validatedGenerators.size())); + new WFCGenerator(fields, chunk.getBlock(8, fields.getCenterModuleAltitude(), 8).getLocation()); + } + + private record ChunkKey(UUID worldId, int x, int z) { } -} \ No newline at end of file +} diff --git a/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java b/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java index 7ad7770..cfa38d5 100644 --- a/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java +++ b/src/main/java/com/magmaguy/betterstructures/menus/BetterStructuresSetupMenu.java @@ -1,19 +1,13 @@ package com.magmaguy.betterstructures.menus; import com.magmaguy.betterstructures.MetadataHandler; -import com.magmaguy.betterstructures.BetterStructures; import com.magmaguy.betterstructures.config.contentpackages.ContentPackageConfigFields; import com.magmaguy.betterstructures.content.BSPackage; import com.magmaguy.betterstructures.content.BSPackageRefresher; import com.magmaguy.magmacore.menus.MenuButton; import com.magmaguy.magmacore.menus.SetupMenuBuilder; import com.magmaguy.magmacore.nightbreak.DownloadAllContentPackage; -import com.magmaguy.magmacore.nightbreak.NightbreakAccount; -import com.magmaguy.magmacore.nightbreak.NightbreakSetupControls; import com.magmaguy.magmacore.util.ChatColorConverter; -import com.magmaguy.magmacore.util.ItemStackGenerator; -import com.magmaguy.magmacore.util.Logger; -import com.magmaguy.magmacore.util.SpigotMessage; import org.bukkit.ChatColor; import org.bukkit.Material; import org.bukkit.entity.Player; @@ -37,23 +31,36 @@ public static void createMenu(Player player) { .collect(Collectors.toList()); BSPackageRefresher.refreshContentAndAccess(); - MenuButton infoButton = NightbreakSetupControls.setupInfoButton( - BetterStructures.NIGHTBREAK_PLUGIN_SPEC, - "https://nightbreak.io/plugin/betterstructures/#setup"); + // The published MagmaCore snapshot used by BetterStructures no longer matches + // the newer NightbreakSetupControls helper. Keep the useful setup/package menu + // while avoiding that moving convenience API in the Albion fork. + MenuButton infoButton = new MenuButton( + Material.BOOK, + ChatColor.GREEN + "BetterStructures", + List.of( + ChatColor.GRAY + "Original plugin by MagmaGuy", + ChatColor.GRAY + "AlbionMC performance fork", + ChatColor.YELLOW + "Content packages can be managed here.")) { + @Override + public void onClick(Player clickingPlayer) { + clickingPlayer.sendMessage(ChatColor.GREEN + "BetterStructures Performance / Albion"); + clickingPlayer.sendMessage(ChatColor.GRAY + "Original BetterStructures created by MagmaGuy."); + } + }; - SetupMenuBuilder builder = new SetupMenuBuilder((JavaPlugin) MetadataHandler.PLUGIN, player) + new SetupMenuBuilder((JavaPlugin) MetadataHandler.PLUGIN, player) .title("Setup menu") .infoButton(infoButton) .packages(bsPackages) - .appendPackage(new DownloadAllContentPackage<>(() -> new ArrayList<>(BSPackage.getBsPackages().values()), + .appendPackage(new DownloadAllContentPackage<>( + () -> new ArrayList<>(BSPackage.getBsPackages().values()), "BetterStructures", "https://nightbreak.io/plugin/betterstructures/", "bs downloadall")) .addFilter(Material.GRASS_BLOCK, "Structure Packs", (Predicate) BetterStructuresSetupMenu::filterStructures) .addFilter(Material.DEEPSLATE_BRICKS, "Module Packs", - (Predicate) BetterStructuresSetupMenu::filterModules); - NightbreakSetupControls.prependStandardControls(builder, (JavaPlugin) MetadataHandler.PLUGIN, BetterStructures.NIGHTBREAK_PLUGIN_SPEC) + (Predicate) BetterStructuresSetupMenu::filterModules) .open(); } diff --git a/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java b/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java index de201a3..f8612c6 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/ModularWorld.java @@ -11,6 +11,11 @@ import com.magmaguy.elitemobs.mobconstructor.custombosses.InstancedBossEntity; import com.magmaguy.magmacore.instance.MatchInstance; import com.magmaguy.magmacore.util.Logger; +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.util.SideEffectSet; import lombok.Getter; import org.bukkit.*; import org.bukkit.block.Block; @@ -101,23 +106,39 @@ private void processExitLocations(ModulePasting.InterpretedSign interpretedSign) } public List spawnChests() { + placeLocationsWithFawe(chestLocations, Material.CHEST); List chests = new ArrayList<>(); for (Location chestLocation : chestLocations) { - chestLocation.getBlock().setType(Material.CHEST); chests.add(chestLocation.getBlock()); } return chests; } public List spawnBarrels() { + placeLocationsWithFawe(barrelLocations, Material.BARREL); List barrels = new ArrayList<>(); for (Location barrelLocation : barrelLocations) { - barrelLocation.getBlock().setType(Material.BARREL); barrels.add(barrelLocation.getBlock()); } return barrels; } + private void placeLocationsWithFawe(List locations, Material material) { + if (locations.isEmpty()) return; + try (EditSession editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(world))) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); + for (Location location : locations) { + editSession.setBlock( + BlockVector3.at(location.getBlockX(), location.getBlockY(), location.getBlockZ()), + BukkitAdapter.adapt(material.createBlockData())); + } + } catch (Exception exception) { + Logger.warn("Failed to place modular " + material + " blocks through FAWE: " + exception.getMessage()); + exception.printStackTrace(); + } + } + //todo: maybe this should go into extractioncraft later public List spawnInaccessibleExitLocations() { List randomizedLocations = new ArrayList<>(); @@ -168,7 +189,6 @@ public void run() { scheduledInstancedEntities.add(new ScheduledInstancedEntity(otherLocation.location(), customBossesConfigFields, parsedString, spawnPoolsConfigFields.getMinLevel(), spawnPoolsConfigFields.getMaxLevel())); } } - //got to keep the memory clear for this one, unfortunately otherLocations.clear(); generationFinished(); } @@ -178,15 +198,14 @@ public void run() { public List spawnInstancedEntities(MatchInstance matchInstance) { List instancedBossEntities = new ArrayList<>(); for (ScheduledInstancedEntity scheduledInstancedEntity : scheduledInstancedEntities) { - int totalRadius = 2 * 128 + 64;//todo this is just a placeholder for now that hardcodes the radius - Vector2i center = new Vector2i(64, 64); //todo this is just a placeholder for now that hardcodes the center + int totalRadius = 2 * 128 + 64; + Vector2i center = new Vector2i(64, 64); Vector2i entityLocation = new Vector2i(scheduledInstancedEntity.location.getBlockX(), scheduledInstancedEntity.location.getBlockZ()); double distance = center.distance(entityLocation); double percentageDistance = distance / totalRadius; int level = (int) Math.round((1.0 - percentageDistance) * scheduledInstancedEntity.maxLevel + percentageDistance * scheduledInstancedEntity.minLevel); InstancedBossEntity instancedBossEntity = new InstancedBossEntity(scheduledInstancedEntity.configFields, scheduledInstancedEntity.location, matchInstance, level); -// InstancedBossEntity instancedBossEntity = new InstancedBossEntity(scheduledInstancedEntity.configFields, scheduledInstancedEntity.location, matchInstance, 10);//todo: level is just a placeholder for now instancedBossEntity.spawn(true); instancedBossEntity.addCustomData(new NamespacedKey("betterstructures", "spawnpool"), scheduledInstancedEntity.originalSpawnPool); instancedBossEntities.add(instancedBossEntity); diff --git a/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java b/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java index f3b514e..496773a 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/ModulePasting.java @@ -2,17 +2,16 @@ import com.magmaguy.betterstructures.MetadataHandler; import com.magmaguy.betterstructures.api.ChestFillEvent; -import com.magmaguy.betterstructures.config.DefaultConfig; import com.magmaguy.betterstructures.chests.ChestContents; +import com.magmaguy.betterstructures.config.DefaultConfig; import com.magmaguy.betterstructures.config.modulegenerators.ModuleGeneratorsConfigFields; import com.magmaguy.betterstructures.config.modules.ModulesConfigFields; import com.magmaguy.betterstructures.config.treasures.TreasureConfig; import com.magmaguy.betterstructures.config.treasures.TreasureConfigFields; import com.magmaguy.betterstructures.util.WorldEditUtils; -import com.magmaguy.easyminecraftgoals.NMSManager; +import com.magmaguy.betterstructures.worldedit.FaweEditQueue; import com.magmaguy.magmacore.util.Logger; import com.magmaguy.magmacore.util.SpigotMessage; -import com.magmaguy.magmacore.util.WorkloadRunnable; import com.sk89q.worldedit.EditSession; import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.WorldEditException; @@ -29,11 +28,6 @@ import org.bukkit.World; import org.bukkit.block.Block; import org.bukkit.block.Container; -import org.bukkit.block.data.BlockData; -import org.bukkit.block.data.Directional; -import org.bukkit.block.data.Rail; -import org.bukkit.block.data.type.Chest; -import org.bukkit.block.data.type.Sign; import org.bukkit.entity.EntityType; import org.bukkit.entity.LivingEntity; import org.bukkit.entity.Player; @@ -47,75 +41,56 @@ import java.util.Map; import java.util.Set; +/** + * FAWE-native module/dungeon placement for the Albion fork. + * + *

Upstream split module blocks between NMS palette writes, Bukkit slow-block writes, + * and separate WorldEdit NBT repair. Albion 1.1.1 instead sends the complete block plan + * through one serialized asynchronous FAWE edit using BaseBlock data, then returns to + * the primary thread only for Bukkit-required loot/events/entity spawning.

+ */ public final class ModulePasting { private final List interpretedSigns = new ArrayList<>(); - private final List chestsToPlace = new ArrayList<>(); + private final List chestsToFill = new ArrayList<>(); private final List barrelsToFill = new ArrayList<>(); private final List entitiesToSpawn = new ArrayList<>(); private final String spawnPoolSuffix; private final Location startLocation; private final boolean createModularWorld; - private final List nbtToPlace = new ArrayList<>(); private ModularWorld modularWorld; private final World world; private final File worldFolder; private final ModuleGeneratorsConfigFields moduleGeneratorsConfigFields; - public ModulePasting(World world, File worldFolder, Deque WFCNodeDeque, String spawnPoolSuffix, Location startLocation, ModuleGeneratorsConfigFields moduleGeneratorsConfigFields) { + public ModulePasting(World world, + File worldFolder, + Deque WFCNodeDeque, + String spawnPoolSuffix, + Location startLocation, + ModuleGeneratorsConfigFields moduleGeneratorsConfigFields) { this.spawnPoolSuffix = spawnPoolSuffix; this.startLocation = startLocation; this.world = world; this.worldFolder = worldFolder; this.moduleGeneratorsConfigFields = moduleGeneratorsConfigFields; - // Check debug mode and modular world creation settings from first node WFCNode firstNode = WFCNodeDeque.peek(); - this.createModularWorld = firstNode != null && firstNode.getWfcGenerator() != null && - firstNode.getWfcGenerator().getModuleGeneratorsConfigFields().isWorldGeneration(); + this.createModularWorld = firstNode != null + && firstNode.getWfcGenerator() != null + && firstNode.getWfcGenerator().getModuleGeneratorsConfigFields().isWorldGeneration(); batchPaste(WFCNodeDeque, interpretedSigns); - createModularWorld(world, worldFolder); - - // Send notification to players - if (DefaultConfig.isNewBuildingWarn()) { - for (Player player : Bukkit.getOnlinePlayers()) { - if (player.hasPermission("betterstructures.warn")) { - player.spigot().sendMessage( - SpigotMessage.commandHoverMessage( - "[BetterStructures] New dungeon started generating! Do not stop your server now. Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", - "Click to teleport to " + startLocation.getWorld().getName() + ", " + - startLocation.getBlockX() + ", " + startLocation.getBlockY() + ", " + startLocation.getBlockZ(), - "/betterstructures teleport " + startLocation.getWorld().getName() + " " + - startLocation.getBlockX() + " " + startLocation.getBlockY() + " " + startLocation.getBlockZ()) - ); - } - } - } - } - - private static boolean isNbtRichMaterial(Material m) { - if (m == Material.CHEST || m == Material.TRAPPED_CHEST || m == Material.BARREL) return false; - if (m.name().endsWith("_SIGN") || m.name().endsWith("_WALL_SIGN") || m.name().endsWith("_HANGING_SIGN")) - return false; - - return switch (m) { - case SPAWNER, - DISPENSER, DROPPER, HOPPER, - BEACON, LECTERN, JUKEBOX, - COMMAND_BLOCK, REPEATING_COMMAND_BLOCK, CHAIN_COMMAND_BLOCK, - PLAYER_HEAD, PLAYER_WALL_HEAD, - SCULK_CATALYST, SCULK_SHRIEKER -> true; - default -> false; - }; + notifyPlayers(); } + /** + * Small explicit module paste. It still uses the FAWE provider and never falls back + * to Bukkit/NMS block writes. + */ public static void paste(Clipboard clipboard, Location location, Integer rotation) { - if (rotation == null) { - return; - } + if (clipboard == null || rotation == null || location.getWorld() == null) return; - // Transform the clipboard using the same approach as batch paste AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); Clipboard transformedClipboard; try { @@ -125,47 +100,27 @@ public static void paste(Clipboard clipboard, Location location, Integer rotatio throw new RuntimeException(e); } - // Get dimensions and calculate proper center BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); - - World world = location.getWorld(); - int baseX = location.getBlockX(); - int baseY = location.getBlockY(); - int baseZ = location.getBlockZ(); - - // Create edit session for actual placement - com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(world); + com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(location.getWorld()); try (EditSession editSession = WorldEdit.getInstance().newEditSession(adaptedWorld)) { editSession.setTrackingHistory(false); editSession.setSideEffectApplier(SideEffectSet.none()); - // Process each block using calculated center point as reference - transformedClipboard.getRegion().forEach(blockPos -> { - try { - BaseBlock baseBlock = transformedClipboard.getFullBlock(blockPos); - - // Skip air blocks - if (baseBlock.getBlockType().getMaterial().isAir()) return; - - // Calculate world coordinates relative to center point - int worldX = baseX + (blockPos.x() - minPoint.x()); - int worldY = baseY + (blockPos.y() - minPoint.y()); - int worldZ = baseZ + (blockPos.z() - minPoint.z()); + for (BlockVector3 blockPos : transformedClipboard.getRegion()) { + BaseBlock baseBlock = transformedClipboard.getFullBlock(blockPos); + if (baseBlock.getBlockType().getMaterial().isAir()) continue; - // Place the block - BlockVector3 worldPos = BlockVector3.at(worldX, worldY, worldZ); - editSession.setBlock(worldPos, baseBlock); - - } catch (WorldEditException e) { - Logger.warn("Failed to place block at " + blockPos + ": " + e.getMessage()); - } - }); + BlockVector3 worldPos = BlockVector3.at( + location.getBlockX() + (blockPos.x() - minPoint.x()), + location.getBlockY() + (blockPos.y() - minPoint.y()), + location.getBlockZ() + (blockPos.z() - minPoint.z())); + editSession.setBlock(worldPos, baseBlock); + } pasteArmorStands(transformedClipboard, location, rotation); - } catch (Exception e) { - Logger.warn("Failed to paste structure: " + e.getMessage()); + Logger.warn("Failed to paste module through FAWE: " + e.getMessage()); throw new RuntimeException(e); } } @@ -178,72 +133,54 @@ public static void pasteArmorStands(Clipboard clipboard, Location location, Inte if (rotation == null) rotation = 0; AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); - Clipboard transformedClipboard; try { - transformedClipboard = clipboard.transform(transform); + Clipboard transformedClipboard = clipboard.transform(transform); + WorldEditUtils.pasteArmorStandsOnlyFromTransformed(transformedClipboard, location); } catch (WorldEditException e) { Logger.warn("Failed to transform clipboard for entities: " + e.getMessage()); - return; } - - WorldEditUtils.pasteArmorStandsOnlyFromTransformed(transformedClipboard, location); } - private List generatePasteMeList(Clipboard clipboard, - Location worldPasteOriginLocation, - Integer rotation, - List interpretedSigns, - ModulesConfigFields modulesConfigFields) { - List pasteableList = new ArrayList<>(); - - // Apply rotation transformation + private List generatePlacementPlan(Clipboard clipboard, + Location worldPasteOriginLocation, + Integer rotation, + List interpretedSigns, + ModulesConfigFields modulesConfigFields) { + List placements = new ArrayList<>(); AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); - Clipboard transformedClipboard; + + final Clipboard transformedClipboard; try { transformedClipboard = clipboard.transform(transform); } catch (WorldEditException e) { throw new RuntimeException(e); } - // Get the minimum point of the transformed clipboard to use as reference BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); - - World world = worldPasteOriginLocation.getWorld(); int baseX = worldPasteOriginLocation.getBlockX(); int baseY = worldPasteOriginLocation.getBlockY(); int baseZ = worldPasteOriginLocation.getBlockZ(); - // Process each block in the transformed clipboard - transformedClipboard.getRegion().forEach(blockPos -> { + for (BlockVector3 blockPos : transformedClipboard.getRegion()) { BaseBlock baseBlock = transformedClipboard.getFullBlock(blockPos); BlockState blockState = baseBlock.toImmutableState(); - // Air must still be pasted when generating into an existing world, as it is what - // carves the walkable interiors out of the terrain. Only void worlds can skip it. - if (createModularWorld && WorldEditUtils.isAir(blockState)) return; - // Calculate world coordinates relative to the minimum point + if (createModularWorld && WorldEditUtils.isAir(blockState)) continue; + int worldX = baseX + (blockPos.x() - minPoint.x()); int worldY = baseY + (blockPos.y() - minPoint.y()); int worldZ = baseZ + (blockPos.z() - minPoint.z()); - + BlockVector3 worldPosition = BlockVector3.at(worldX, worldY, worldZ); Location pasteLocation = new Location(world, worldX, worldY, worldZ); Material material = WorldEditUtils.adaptMaterial(blockState); - // Skip barriers - if (material == Material.BARRIER) return; + if (material == Material.BARRIER) continue; - BlockData blockData = material == null ? null : WorldEditUtils.createBlockDataOrNull(baseBlock); - if (blockData == null) { - nbtToPlace.add(new NbtPlacement(pasteLocation, baseBlock)); - return; - } - - // Handle signs - collect instructions then turn into AIR - if (blockData.getMaterial().toString().toLowerCase().contains("sign")) { + if (isControlSign(material)) { List lines = getLines(baseBlock); interpretedSigns.add(new InterpretedSign(pasteLocation, lines)); - // Parse sign content for special markers + Material replacement = Material.AIR; for (String line : lines) { if (line.contains("[spawn]") && lines.size() > 1) { try { @@ -253,37 +190,39 @@ private List generatePasteMeList(Clipboard clipboard, Logger.warn("Invalid entity type in sign: " + lines.get(1)); } } else if (line.contains("[chest]")) { - chestsToPlace.add(new ChestPlacement(pasteLocation, Material.CHEST, rotation)); + replacement = Material.CHEST; + chestsToFill.add(new ChestPlacement(pasteLocation, Material.CHEST)); } else if (line.contains("[trapped_chest]")) { - chestsToPlace.add(new ChestPlacement(pasteLocation, Material.TRAPPED_CHEST, rotation)); + replacement = Material.TRAPPED_CHEST; + chestsToFill.add(new ChestPlacement(pasteLocation, Material.TRAPPED_CHEST)); } } - // Replace sign with air in the paste list so it won't be deferred as NBT-rich - blockData = Material.AIR.createBlockData(); - } - - // Convert bedrock to stone (unless replacing a solid block) - if (blockData.getMaterial().equals(Material.BEDROCK)) { - if (pasteLocation.getBlock().getType().isSolid()) return; - blockData = Material.STONE.createBlockData(); + placements.add(FawePlacement.replacement(worldPosition, replacement)); + continue; } - // Defer complex NBT blocks (dispensers, spawners, etc.) for post-processing via BaseBlock - if (isNbtRichMaterial(blockData.getMaterial())) { - nbtToPlace.add(new NbtPlacement(pasteLocation, baseBlock)); // keep full NBT - return; // do NOT add to normal paste list + if (material == Material.BEDROCK) { + placements.add(FawePlacement.bedrockFiller(worldPosition)); + continue; } - if (blockData.getMaterial() == Material.BARREL) { + if (material == Material.BARREL) { barrelsToFill.add(new BarrelPlacement(pasteLocation, modulesConfigFields)); } - // Normal placement path - pasteableList.add(new Pasteable(pasteLocation, blockData)); - }); + // BaseBlock keeps all block state and NBT. No separate Bukkit slow path or + // NBT repair pass is necessary with FAWE. + placements.add(FawePlacement.baseBlock(worldPosition, baseBlock)); + } + + return placements; + } - return pasteableList; + private static boolean isControlSign(Material material) { + if (material == null) return false; + String name = material.name(); + return name.endsWith("_SIGN") || name.endsWith("_WALL_SIGN") || name.endsWith("_HANGING_SIGN"); } private List getLines(BaseBlock baseBlock) { @@ -297,144 +236,117 @@ private List getLines(BaseBlock baseBlock) { } public List batchPaste(Deque WFCNodeDeque, List interpretedSigns) { - List pasteableList = new ArrayList<>(); - - // Collect entity paste info while processing blocks + List placements = new ArrayList<>(); List entityPasteInfos = new ArrayList<>(); while (!WFCNodeDeque.isEmpty()) { - WFCNode WFCNode = WFCNodeDeque.poll(); - if (WFCNode == null || WFCNode.getModulesContainer() == null) continue; - Clipboard clipboard = WFCNode.getModulesContainer().getClipboard(); + WFCNode node = WFCNodeDeque.poll(); + if (node == null || node.getModulesContainer() == null) continue; + + Clipboard clipboard = node.getModulesContainer().getClipboard(); if (clipboard == null) continue; - // Process blocks - ModulesConfigFields modulesConfigField = WFCNode.getModulesContainer().getModulesConfigField(); - pasteableList.addAll(generatePasteMeList(clipboard, WFCNode.getRealLocation(startLocation), - WFCNode.getModulesContainer().getRotation(), interpretedSigns, modulesConfigField)); + ModulesConfigFields modulesConfigField = node.getModulesContainer().getModulesConfigField(); + Integer rotation = node.getModulesContainer().getRotation(); + Location realLocation = node.getRealLocation(startLocation); - // Store entity paste info for later - WITH TRANSFORMED CLIPBOARD - AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(WFCNode.getModulesContainer().getRotation())); + placements.addAll(generatePlacementPlan( + clipboard, realLocation, rotation, interpretedSigns, modulesConfigField)); + + AffineTransform transform = new AffineTransform().rotateY(normalizeRotation(rotation)); try { Clipboard transformedClipboard = clipboard.transform(transform); - entityPasteInfos.add(new EntityPasteInfo(transformedClipboard, WFCNode.getRealLocation(startLocation), - WFCNode.getModulesContainer().getRotation())); + entityPasteInfos.add(new EntityPasteInfo(transformedClipboard, realLocation)); } catch (WorldEditException e) { - Logger.warn("Failed to transform clipboard for entities: " + e.getMessage()); + Logger.warn("Failed to transform module clipboard for entities: " + e.getMessage()); } } - List slowBlocks = new ArrayList<>(); - WorkloadRunnable pasteMeRunnable = new WorkloadRunnable(.1, () -> { - WorkloadRunnable vanillaPlacementRunnable = new WorkloadRunnable(.1, () -> { - postPasteProcessing(entityPasteInfos); - }); - - for (Pasteable slowBlock : slowBlocks) - vanillaPlacementRunnable.addWorkload(() -> { - slowBlock.location.getBlock().setBlockData(slowBlock.blockData, false); + FaweEditQueue.submit( + "module batch at " + startLocation.getBlockX() + "," + + startLocation.getBlockY() + "," + startLocation.getBlockZ(), + () -> executeFaweBatch(placements, entityPasteInfos), + failure -> { + if (failure != null) { + Logger.warn("Module FAWE batch failed: " + failure.getMessage()); + failure.printStackTrace(); + return; + } + postPasteProcessing(); }); - vanillaPlacementRunnable.runTaskTimer(MetadataHandler.PLUGIN, 0, 1); - }); - List freshlyInterpretedSigns = new ArrayList<>(); + return interpretedSigns; + } - // Enable fast path only for world-based generation - final boolean fastPathEnabled = this.createModularWorld; + private void executeFaweBatch(List placements, + List entityPasteInfos) throws Exception { + com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(world); - for (Pasteable pasteable : pasteableList) { - if (!fastPathEnabled) { - // Not world-based generation: force slow placement for EVERYTHING - slowBlocks.add(pasteable); - continue; - } + try (EditSession editSession = WorldEdit.getInstance().newEditSession(adaptedWorld)) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); - // World-based generation: keep original split between fast/slow - if (pasteable.blockData.getLightEmission() > 0 - || pasteable.blockData instanceof Directional - || pasteable.blockData instanceof Rail - || pasteable.blockData instanceof Sign) { - slowBlocks.add(pasteable); - } else { - pasteMeRunnable.addWorkload(() -> { - NMSManager.getAdapter().setBlockInNativeDataPalette( - pasteable.location.getWorld(), - pasteable.location.getBlockX(), - pasteable.location.getBlockY(), - pasteable.location.getBlockZ(), - pasteable.blockData, - true); - }); + for (FawePlacement placement : placements) { + if (placement.bedrockFiller()) { + if (editSession.getBlock(placement.position()) + .getBlockType().getMaterial().isSolid()) { + continue; + } + editSession.setBlock(placement.position(), + BukkitAdapter.adapt(Material.STONE.createBlockData())); + } else if (placement.replacementMaterial() != null) { + editSession.setBlock(placement.position(), + BukkitAdapter.adapt(placement.replacementMaterial().createBlockData())); + } else if (placement.baseBlock() != null) { + editSession.setBlock(placement.position(), placement.baseBlock()); + } } } - pasteMeRunnable.runTaskTimer(MetadataHandler.PLUGIN, 0, 1); - - return freshlyInterpretedSigns; + // Entity clipboard operations remain in the same serialized asynchronous FAWE + // lane instead of running during the Bukkit completion phase. + for (EntityPasteInfo info : entityPasteInfos) { + WorldEditUtils.pasteArmorStandsOnlyFromTransformed(info.clipboard(), info.location()); + } } - private void postPasteProcessing(List entityPasteInfos) { + /** + * Bukkit-only completion phase: inventory APIs, plugin events, and entity spawns. + * There are intentionally no block mutation calls here. + */ + private void postPasteProcessing() { if (createModularWorld) { createModularWorld(world, worldFolder); modularWorld.spawnOtherEntities(); } - // 1) Paste deferred NBT-rich blocks (dispenser, spawner, etc.) with WE so NBT is preserved - if (!nbtToPlace.isEmpty()) { - com.sk89q.worldedit.world.World adaptedWorld = BukkitAdapter.adapt(world); - try (EditSession editSession = WorldEdit.getInstance().newEditSession(adaptedWorld)) { - editSession.setTrackingHistory(false); - editSession.setSideEffectApplier(SideEffectSet.none()); - - for (NbtPlacement np : nbtToPlace) { - BlockVector3 wp = BlockVector3.at( - np.location().getBlockX(), - np.location().getBlockY(), - np.location().getBlockZ() - ); - try { - editSession.setBlock(wp, np.baseBlock()); // BaseBlock carries NBT - } catch (WorldEditException e) { - Logger.warn("Failed to set NBT block at " + np.location() + ": " + e.getMessage()); - } - } - } catch (Exception e) { - Logger.warn("Failed NBT post-paste session: " + e.getMessage()); - } - } + for (ChestPlacement chestPlacement : chestsToFill) { + Block block = chestPlacement.location().getBlock(); + if (block.getType() != chestPlacement.material()) continue; + if (!(block.getState() instanceof Container container)) continue; - // 2) Paste entities from schematics (armor stands, etc.) - pasteArmorStandsForBatch(entityPasteInfos); - - for (ChestPlacement chestPlacement : chestsToPlace) { - Block block = chestPlacement.location.getBlock(); - block.setType(chestPlacement.material); - - if (block.getBlockData() instanceof Chest chest) { - block.setBlockData(chest, false); - - String treasureFilename = moduleGeneratorsConfigFields.getTreasureFile(); - TreasureConfigFields treasureConfigFields = TreasureConfig.getConfigFields(treasureFilename); - if (treasureConfigFields != null) { - ChestContents chestContents = new ChestContents(treasureConfigFields); - Container container = (Container) block.getState(); - chestContents.rollChestContents(container); - ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); - Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); - if (!chestFillEvent.isCancelled()) - container.update(true); - } - } + String treasureFilename = moduleGeneratorsConfigFields.getTreasureFile(); + TreasureConfigFields treasureConfigFields = TreasureConfig.getConfigFields(treasureFilename); + if (treasureConfigFields == null) continue; + + ChestContents chestContents = new ChestContents(treasureConfigFields); + chestContents.rollChestContents(container); + ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); + Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); + if (!chestFillEvent.isCancelled()) container.update(true); } if (moduleGeneratorsConfigFields.isGenerateLootInBarrels() && !barrelsToFill.isEmpty()) { Map contentsByTreasure = new HashMap<>(); Set warnedMissingTreasures = new HashSet<>(); + for (BarrelPlacement bp : barrelsToFill) { ModulesConfigFields modConfig = bp.modulesConfigFields(); if (modConfig != null && !modConfig.isGenerateLootInBarrels()) continue; - String treasureFilename = (modConfig != null && modConfig.getBarrelTreasureFilename() != null && !modConfig.getBarrelTreasureFilename().isEmpty()) + String treasureFilename = (modConfig != null + && modConfig.getBarrelTreasureFilename() != null + && !modConfig.getBarrelTreasureFilename().isEmpty()) ? modConfig.getBarrelTreasureFilename() : moduleGeneratorsConfigFields.getBarrelTreasureFilename(); if (treasureFilename == null || treasureFilename.isEmpty()) continue; @@ -445,9 +357,12 @@ private void postPasteProcessing(List entityPasteInfos) { barrelContents = barrelTreasureFields != null ? new ChestContents(barrelTreasureFields) : null; contentsByTreasure.put(treasureFilename, barrelContents); } + if (barrelContents == null) { if (warnedMissingTreasures.add(treasureFilename)) { - Logger.warn("Module generator " + moduleGeneratorsConfigFields.getFilename() + " has barrels referencing barrelTreasureFilename '" + treasureFilename + "' but it did not resolve to a valid treasure config. Affected barrels will be empty."); + Logger.warn("Module generator " + moduleGeneratorsConfigFields.getFilename() + + " has barrels referencing barrelTreasureFilename '" + treasureFilename + + "' but it did not resolve to a valid treasure config. Affected barrels will be empty."); } continue; } @@ -459,47 +374,51 @@ private void postPasteProcessing(List entityPasteInfos) { barrelContents.rollChestContents(container); ChestFillEvent chestFillEvent = new ChestFillEvent(container, treasureFilename); Bukkit.getServer().getPluginManager().callEvent(chestFillEvent); - if (!chestFillEvent.isCancelled()) { - container.update(true); - } + if (!chestFillEvent.isCancelled()) container.update(true); } } - // 4) Spawn entities last for (EntitySpawn entitySpawn : entitiesToSpawn) { try { - LivingEntity entity = (LivingEntity) world.spawnEntity(entitySpawn.location, entitySpawn.entityType); + LivingEntity entity = (LivingEntity) world.spawnEntity(entitySpawn.location(), entitySpawn.entityType()); entity.setRemoveWhenFarAway(false); entity.setPersistent(true); } catch (Exception e) { - Logger.warn("Failed to spawn entity of type " + entitySpawn.entityType + " at " + entitySpawn.location); + Logger.warn("Failed to spawn entity of type " + entitySpawn.entityType() + + " at " + entitySpawn.location()); } } } - // Helper method to paste entities for all collected clipboards - private void pasteArmorStandsForBatch(List entityPasteInfos) { - for (EntityPasteInfo info : entityPasteInfos) { - try { - WorldEditUtils.pasteArmorStandsOnlyFromTransformed(info.clipboard, info.location); - } catch (Exception e) { - Logger.warn("Failed to paste entities for batch operation at " + info.location + ": " + e.getMessage()); + private void notifyPlayers() { + if (!DefaultConfig.isNewBuildingWarn()) return; + + Runnable notifier = () -> { + for (Player player : Bukkit.getOnlinePlayers()) { + if (!player.hasPermission("betterstructures.warn")) continue; + player.spigot().sendMessage( + SpigotMessage.commandHoverMessage( + "[BetterStructures] New dungeon started generating! Click to teleport. Do \"/betterstructures silent\" to stop getting warnings!", + "Click to teleport to " + startLocation.getWorld().getName() + ", " + + startLocation.getBlockX() + ", " + startLocation.getBlockY() + ", " + startLocation.getBlockZ(), + "/betterstructures teleport " + startLocation.getWorld().getName() + " " + + startLocation.getBlockX() + " " + startLocation.getBlockY() + " " + startLocation.getBlockZ()) + ); } - } + }; + + if (Bukkit.isPrimaryThread()) notifier.run(); + else Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, notifier); } private void createModularWorld(World world, File worldFolder) { modularWorld = new ModularWorld(world, worldFolder, interpretedSigns); } - private record NbtPlacement(Location location, BaseBlock baseBlock) { + private record EntityPasteInfo(Clipboard clipboard, Location location) { } - // Record to hold entity paste information - now with transformed clipboard - private record EntityPasteInfo(Clipboard clipboard, Location location, Integer rotation) { - } - - private record ChestPlacement(Location location, Material material, Integer rotation) { + private record ChestPlacement(Location location, Material material) { } private record BarrelPlacement(Location location, ModulesConfigFields modulesConfigFields) { @@ -508,9 +427,25 @@ private record BarrelPlacement(Location location, ModulesConfigFields modulesCon private record EntitySpawn(Location location, EntityType entityType) { } - public record InterpretedSign(Location location, List text) { + private record FawePlacement( + BlockVector3 position, + BaseBlock baseBlock, + Material replacementMaterial, + boolean bedrockFiller) { + + private static FawePlacement baseBlock(BlockVector3 position, BaseBlock baseBlock) { + return new FawePlacement(position, baseBlock, null, false); + } + + private static FawePlacement replacement(BlockVector3 position, Material material) { + return new FawePlacement(position, null, material, false); + } + + private static FawePlacement bedrockFiller(BlockVector3 position) { + return new FawePlacement(position, null, null, true); + } } - private record Pasteable(Location location, BlockData blockData) { + public record InterpretedSign(Location location, List text) { } } diff --git a/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java b/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java index 0a78f08..3eeeff2 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/WFCNode.java @@ -2,6 +2,11 @@ import com.magmaguy.betterstructures.MetadataHandler; import com.magmaguy.magmacore.util.Logger; +import com.sk89q.worldedit.EditSession; +import com.sk89q.worldedit.WorldEdit; +import com.sk89q.worldedit.bukkit.BukkitAdapter; +import com.sk89q.worldedit.math.BlockVector3; +import com.sk89q.worldedit.util.SideEffectSet; import lombok.Getter; import org.bukkit.*; import org.bukkit.entity.Display; @@ -44,7 +49,7 @@ public class WFCNode { * @param nodeMap The global node map reference */ public WFCNode(Vector3i nodePosition, World world, WFCLattice lattice, Map nodeMap, WFCGenerator wfcGenerator) { - this.nodePosition = new Vector3i(nodePosition); // Defensive copy + this.nodePosition = new Vector3i(nodePosition); this.world = world; this.lattice = lattice; this.nodeMap = nodeMap; @@ -80,26 +85,16 @@ public boolean isBoundary() { /** * Gets a safe copy of the cell location. - * - * @return A new Vector3i containing the cell location */ public Vector3i getCellLocation() { return new Vector3i(nodePosition); } - /** - * Updates the possible states for this node based on its adjacent nodes. - */ public void updatePossibleStates() { possibleStates = ModulesContainer.getValidModulesFromSurroundings(this); showDebugTextDisplays(); } - /** - * Gets the count of valid module options for this cell. - * - * @return The number of valid options, or 0 if none are available - */ public int getValidOptionCount() { if (possibleStates == null) { updatePossibleStates(); @@ -111,20 +106,10 @@ public int getValidOptionCount() { return possibleStates.size(); } - /** - * Gets a map of neighboring cells in each direction. - * - * @return Map of Direction to WFCNode for each neighbor - */ public Map getOrientedNeighbors() { return adjacentNodes; } - /** - * Gets the possible states for this node. - * - * @return Set of possible module states for this node - */ public HashSet getValidOptions() { if (possibleStates == null) { updatePossibleStates(); @@ -132,11 +117,6 @@ public HashSet getValidOptions() { return possibleStates; } - /** - * Gets the real world location of this cell's origin point. - * - * @return Location object representing the cell's origin in the world - */ public Location getRealLocation(Location startLocation) { Vector3i worldCoord; if (startLocation != null) @@ -146,15 +126,12 @@ public Location getRealLocation(Location startLocation) { return new Location(world, worldCoord.x, worldCoord.y, worldCoord.z); } - /** - * Creates debug text displays showing cell information. - */ public void showDebugTextDisplays() { if (!wfcGenerator.getModuleGeneratorsConfigFields().isDebug()) return; new BukkitRunnable() { @Override public void run() { - if (textDisplays != null && !textDisplays.isEmpty()) clearDebugDisplays(); + if (textDisplays != null && !textDisplays.isEmpty()) clearDebugDisplays(); textDisplays = new ArrayList<>(); if (modulesContainer == null) { @@ -207,7 +184,6 @@ private void displayBorderInfo(Location centerLocation, Color color) { Location tagLocation = centerLocation.clone().add(offset.x, offset.y, offset.z); spawnDebugText(tagLocation, entry.getKey().name(), color, 1); - displayNeighborTags(tagLocation, entry.getValue(), color); } } @@ -234,7 +210,7 @@ private void spawnDebugText(Location location, String text, Color color, float s new BukkitRunnable() { @Override public void run() { - Location adjustedLocation = location.clone().subtract(new Vector(0,textDisplays.size()/2d,0)); + Location adjustedLocation = location.clone().subtract(new Vector(0, textDisplays.size() / 2d, 0)); TextDisplay textDisplay = (TextDisplay) world.spawnEntity(adjustedLocation, EntityType.TEXT_DISPLAY); configureTextDisplay(textDisplay, text, color, scale); textDisplays.add(textDisplay); @@ -256,23 +232,14 @@ private void configureTextDisplay(TextDisplay display, String text, Color color, display.setViewRange(1); } - /** - * Checks if this cell has been generated. - * - * @return true if the cell has a module container - */ public boolean isCollapsed() { return modulesContainer != null; } - public boolean isNothing(){ + public boolean isNothing() { return modulesContainer != null && modulesContainer.isNothing(); } - /** - * Resets this cell's data. - * - */ public void resetState() { if (isInitialNode() || isBoundary()) return; @@ -283,14 +250,10 @@ public void resetState() { } } - public boolean isInitialNode() { return new Vector3i().equals(nodePosition); } - /** - * Clears generation data for this cell. - */ public void clearGenerationData() { clearDebugDisplays(); possibleStates = null; @@ -303,34 +266,43 @@ private void clearDebugDisplays() { } } + /** + * Debug lattice wireframe placement. Debug blocks are still world edits, so Albion's + * FAWE-native rule applies here too rather than falling back to Bukkit block writes. + */ private void placeMaterial(Location startLocation, Material material) { int sizeXZ = wfcGenerator.getModuleGeneratorsConfigFields().getModuleSizeXZ(); int sizeY = wfcGenerator.getModuleGeneratorsConfigFields().getModuleSizeY(); - - for (int x = 0; x < sizeXZ; x++) { - for (int y = 0; y < sizeY; y++) { - for (int z = 0; z < sizeXZ; z++) { - Location blockLocation = startLocation.clone().add(x, y, z); - - // Check if block is on an edge (intersection of at least 2 faces) - boolean isOnXEdge = (x == 0 || x == sizeXZ - 1); - boolean isOnYEdge = (y == 0 || y == sizeY - 1); - boolean isOnZEdge = (z == 0 || z == sizeXZ - 1); - - // Count how many edges this block touches - int edgeCount = 0; - if (isOnXEdge) edgeCount++; - if (isOnYEdge) edgeCount++; - if (isOnZEdge) edgeCount++; - - // Place material only if block is on at least 2 edges (true edge/corner) - if (edgeCount >= 2) { - blockLocation.getBlock().setType(material); - } else { - blockLocation.getBlock().setType(Material.AIR); + int baseX = startLocation.getBlockX(); + int baseY = startLocation.getBlockY(); + int baseZ = startLocation.getBlockZ(); + + try (EditSession editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(world))) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); + + for (int x = 0; x < sizeXZ; x++) { + for (int y = 0; y < sizeY; y++) { + for (int z = 0; z < sizeXZ; z++) { + boolean isOnXEdge = x == 0 || x == sizeXZ - 1; + boolean isOnYEdge = y == 0 || y == sizeY - 1; + boolean isOnZEdge = z == 0 || z == sizeXZ - 1; + + int edgeCount = 0; + if (isOnXEdge) edgeCount++; + if (isOnYEdge) edgeCount++; + if (isOnZEdge) edgeCount++; + + Material targetMaterial = edgeCount >= 2 ? material : Material.AIR; + editSession.setBlock( + BlockVector3.at(baseX + x, baseY + y, baseZ + z), + BukkitAdapter.adapt(targetMaterial.createBlockData())); } } } + } catch (Exception exception) { + Logger.warn("Failed to paste WFC debug lattice through FAWE: " + exception.getMessage()); + exception.printStackTrace(); } } @@ -353,7 +325,8 @@ public void run() { try { Thread.sleep(50); } catch (InterruptedException e) { + Thread.currentThread().interrupt(); throw new RuntimeException(e); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java b/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java index 6ac7fb9..6238cea 100644 --- a/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java +++ b/src/main/java/com/magmaguy/betterstructures/modules/WorldInitializer.java @@ -11,11 +11,14 @@ public class WorldInitializer { public static World generateWorld(String worldName, Player player) { WorldCreator worldCreator = new WorldCreator(worldName); worldCreator.environment(World.Environment.NORMAL); - worldCreator.keepSpawnInMemory(false); + // Paper 1.21.9+ removed functional always-loaded spawn chunks. There is no + // replacement needed for BetterStructures' generated module worlds. worldCreator.generator(new VoidGenerator()); World world = worldCreator.createWorld(); + if (world == null) { + throw new IllegalStateException("Failed to create BetterStructures module world " + worldName); + } world.setAutoSave(false); -// player.teleport(new Location(world, 8, 16, 8)); player.setGameMode(GameMode.SPECTATOR); return world; } diff --git a/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java new file mode 100644 index 0000000..5c7b2e6 --- /dev/null +++ b/src/main/java/com/magmaguy/betterstructures/performance/GenerationScheduler.java @@ -0,0 +1,159 @@ +package com.magmaguy.betterstructures.performance; + +import com.magmaguy.betterstructures.MetadataHandler; +import com.magmaguy.betterstructures.config.DefaultConfig; +import com.magmaguy.betterstructures.worldedit.Schematic; +import org.bukkit.Bukkit; +import org.bukkit.Chunk; +import org.bukkit.scheduler.BukkitRunnable; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; + +/** + * Serializes expensive structure-fitting work created by normal player exploration. + * + *

ChunkLoadEvent still performs the cheap deterministic position check immediately, + * but qualifying structures are admitted through this queue. This prevents several + * structure fits from landing in the same server tick when a player moves quickly + * through a resource world.

+ */ +public final class GenerationScheduler { + + private static final Deque JOBS = new ArrayDeque<>(); + private static final Set TICKETED_CHUNKS = new HashSet<>(); + private static boolean started = false; + private static boolean pausedForLoad = false; + private static int cooldownTicks = 0; + + private GenerationScheduler() { + } + + public static void start() { + if (started) return; + started = true; + + new BukkitRunnable() { + @Override + public void run() { + tick(); + } + }.runTaskTimer(MetadataHandler.PLUGIN, 1L, 1L); + } + + public static void shutdown() { + for (Chunk chunk : TICKETED_CHUNKS) { + chunk.removePluginChunkTicket(MetadataHandler.PLUGIN); + } + TICKETED_CHUNKS.clear(); + JOBS.clear(); + pausedForLoad = false; + cooldownTicks = 0; + started = false; + } + + public static void enqueue(Chunk chunk, List jobs) { + if (jobs == null || jobs.isEmpty()) return; + + if (!DefaultConfig.isPlayerGenerationThrottling()) { + jobs.forEach(Runnable::run); + return; + } + + // A qualifying structure chunk is rare. Holding its center chunk while queued + // is much cheaper than forcing a synchronous reload later when the player has + // already flown away from it. + chunk.addPluginChunkTicket(MetadataHandler.PLUGIN); + TICKETED_CHUNKS.add(chunk); + ChunkKey key = new ChunkKey(chunk.getWorld().getUID(), chunk.getX(), chunk.getZ()); + + int totalJobs = jobs.size(); + for (int i = 0; i < totalJobs; i++) { + JOBS.addLast(new GenerationJob(key, chunk, jobs.get(i), i == totalJobs - 1)); + } + } + + public static int queuedJobs() { + return JOBS.size(); + } + + private static void tick() { + if (JOBS.isEmpty()) return; + + if (cooldownTicks > 0) { + cooldownTicks--; + return; + } + + // Do not start another terrain fit while a structure is loading chunks, being + // pasted by FAWE, or waiting in the paste queue. This makes the full expensive + // path serialized, not just the final block placement. + if (Schematic.isBusy()) return; + + double mspt = Bukkit.getAverageTickTime(); + double[] tpsSamples = Bukkit.getTPS(); + double tps = tpsSamples.length == 0 ? 20.0 : tpsSamples[0]; + + if (pausedForLoad) { + if (mspt <= DefaultConfig.getPlayerGenerationResumeMSPT() + && tps >= DefaultConfig.getPlayerGenerationResumeTPS()) { + pausedForLoad = false; + Bukkit.getLogger().info("[BetterStructures] Player-generation queue resumed at " + + String.format("%.1f", mspt) + " MSPT / " + String.format("%.2f", tps) + " TPS."); + } else { + return; + } + } + + if (mspt >= DefaultConfig.getPlayerGenerationPauseMSPT() + || tps <= DefaultConfig.getPlayerGenerationPauseTPS()) { + pausedForLoad = true; + Bukkit.getLogger().warning("[BetterStructures] Player-generation queue paused to protect TPS at " + + String.format("%.1f", mspt) + " MSPT / " + String.format("%.2f", tps) + + " TPS. Queued jobs: " + JOBS.size()); + return; + } + + GenerationJob job = JOBS.pollFirst(); + if (job == null) return; + + try { + job.work().run(); + } catch (Throwable throwable) { + Bukkit.getLogger().severe("[BetterStructures] A queued structure-generation job failed in chunk " + + job.key().x() + "," + job.key().z() + "."); + throwable.printStackTrace(); + } finally { + if (job.releaseTicketAfter()) { + // Keep the center chunk around briefly while schematic chunk preparation + // and the FAWE paste get underway. Schematic itself tickets every chunk + // touched by the structure for the duration of the actual edit. + new BukkitRunnable() { + @Override + public void run() { + job.chunk().removePluginChunkTicket(MetadataHandler.PLUGIN); + TICKETED_CHUNKS.remove(job.chunk()); + } + }.runTaskLater(MetadataHandler.PLUGIN, 200L); + } + } + + cooldownTicks = Math.max(0, DefaultConfig.getPlayerGenerationTicksBetweenJobs()); + } + + private record GenerationJob(ChunkKey key, Chunk chunk, Runnable work, boolean releaseTicketAfter) { + private GenerationJob { + Objects.requireNonNull(key); + Objects.requireNonNull(chunk); + Objects.requireNonNull(work); + } + } + + private record ChunkKey(UUID worldId, int x, int z) { + } +} diff --git a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java index f71bbfb..554f505 100644 --- a/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java +++ b/src/main/java/com/magmaguy/betterstructures/util/WorldEditUtils.java @@ -1,5 +1,6 @@ package com.magmaguy.betterstructures.util; +import com.fastasyncworldedit.core.extent.clipboard.CPUOptimizedClipboard; import com.magmaguy.magmacore.util.Logger; import com.sk89q.jnbt.CompoundTag; import com.sk89q.jnbt.ListTag; @@ -8,20 +9,16 @@ import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.WorldEditException; import com.sk89q.worldedit.bukkit.BukkitAdapter; -import com.sk89q.worldedit.entity.BaseEntity; -import com.sk89q.worldedit.entity.Entity; import com.sk89q.worldedit.extent.clipboard.Clipboard; import com.sk89q.worldedit.function.mask.BlockTypeMask; import com.sk89q.worldedit.function.operation.Operation; import com.sk89q.worldedit.function.operation.Operations; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldedit.regions.CuboidRegion; -import com.sk89q.worldedit.regions.Region; import com.sk89q.worldedit.session.ClipboardHolder; import com.sk89q.worldedit.util.SideEffectSet; import com.sk89q.worldedit.world.block.BaseBlock; import com.sk89q.worldedit.world.block.BlockState; -import com.sk89q.worldedit.world.block.BlockStateHolder; import com.sk89q.worldedit.world.block.BlockType; import org.bukkit.Bukkit; import org.bukkit.Location; @@ -133,8 +130,7 @@ private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int li Matcher matcher = pattern.matcher(text); if (matcher.find()) { - String extractedText = matcher.group(1); - return extractedText; + return matcher.group(1); } else { throw new Exception(); } @@ -146,11 +142,8 @@ private static String getOldWEFormat(@NotNull CompoundTag data, @Positive int li private static String getNewWEFormat(@NotNull CompoundTag data, @Positive int line) { try { - //Get front text CompoundTag frontText = (CompoundTag) data.getValue().get("front_text"); - //Get messages ListTag messages = (ListTag) frontText.getValue().get("messages"); - //Get the line String text = messages.getString(line - 1); if (text.contains("\"text\":")) text = text.split("text\":\"")[1].split("\"")[0]; @@ -165,75 +158,21 @@ private static String getNewWEFormat(@NotNull CompoundTag data, @Positive int li return null; } + /** + * Creates a real one-block FAWE clipboard without consulting FAWE's global + * clipboard-storage settings. This keeps NBT-rich one-block metadata pastes fast, + * deterministic, and unit-testable while avoiding a hand-written Clipboard shim. + */ public static Clipboard createSingleBlockClipboard(Location location, BaseBlock baseBlock, BlockState blockState) { - return new Clipboard() { - @Override - public > boolean setBlock(BlockVector3 position, T block) throws WorldEditException { - return false; - } - - @Nullable - @Override - public Operation commit() { - return null; - } - - @Override - public BlockState getBlock(BlockVector3 position) { - return blockState; - } - - @Override - public BaseBlock getFullBlock(BlockVector3 position) { - return baseBlock; - } - - @Override - public BlockVector3 getMinimumPoint() { - return BlockVector3.at(0,0,0); - } - - @Override - public BlockVector3 getMaximumPoint() { - return BlockVector3.at(0,0,0); - } - - @Override - public List getEntities(Region region) { - return new ArrayList<>(); - } - - @Override - public List getEntities() { - return new ArrayList<>(); - } - - @Nullable - @Override - public Entity createEntity(com.sk89q.worldedit.util.Location location, BaseEntity entity) { - return null; - } - - @Override - public Region getRegion() { - return new CuboidRegion(BlockVector3.at(0,0,0), BlockVector3.at(0,0,0)); - } - - @Override - public BlockVector3 getDimensions() { - return BlockVector3.at(1,1,1); - } - - @Override - public BlockVector3 getOrigin() { - return BlockVector3.at(0,0,0); - } - - @Override - public void setOrigin(BlockVector3 origin) { - - } - }; + CuboidRegion region = new CuboidRegion(BlockVector3.at(0, 0, 0), BlockVector3.at(0, 0, 0)); + CPUOptimizedClipboard clipboard = new CPUOptimizedClipboard(region); + clipboard.setOrigin(BlockVector3.at(0, 0, 0)); + try { + clipboard.setBlock(BlockVector3.at(0, 0, 0), baseBlock); + } catch (WorldEditException e) { + throw new RuntimeException("Failed to create one-block FAWE clipboard", e); + } + return clipboard; } public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClipboard, Location location) { @@ -248,7 +187,6 @@ public static void pasteArmorStandsOnlyFromTransformed(Clipboard transformedClip BlockVector3 minPoint = transformedClipboard.getMinimumPoint(); BlockVector3 origin = transformedClipboard.getOrigin(); - // Align entities the same way you aligned blocks: min -> base BlockVector3 pastePosition = BlockVector3.at( location.getBlockX() + (origin.x() - minPoint.x()), location.getBlockY() + (origin.y() - minPoint.y()), diff --git a/src/main/java/com/magmaguy/betterstructures/worldedit/FaweEditQueue.java b/src/main/java/com/magmaguy/betterstructures/worldedit/FaweEditQueue.java new file mode 100644 index 0000000..71c0c27 --- /dev/null +++ b/src/main/java/com/magmaguy/betterstructures/worldedit/FaweEditQueue.java @@ -0,0 +1,94 @@ +package com.magmaguy.betterstructures.worldedit; + +import com.magmaguy.betterstructures.MetadataHandler; +import com.magmaguy.magmacore.util.Logger; +import org.bukkit.Bukkit; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Consumer; + +/** + * Global serialized executor for BetterStructures world edits. + * + *

The Albion fork intentionally routes block-changing work through FAWE and allows + * only one heavy edit at a time. FAWE can execute edits asynchronously, but allowing + * several large structures or module batches to compete at once can still saturate + * chunk loading, CPU, and disk I/O on a busy resource world.

+ */ +public final class FaweEditQueue { + + private static final Queue JOBS = new ConcurrentLinkedQueue<>(); + private static final AtomicBoolean RUNNING = new AtomicBoolean(false); + + private FaweEditQueue() { + } + + @FunctionalInterface + public interface EditWork { + void run() throws Exception; + } + + /** + * Submit an edit to the single global FAWE lane. The edit body runs asynchronously; + * completion always runs on the Bukkit primary thread and receives {@code null} on + * success or the thrown failure on error. + */ + public static void submit(String description, EditWork work, Consumer completion) { + JOBS.add(new EditJob(description, work, completion)); + startNext(); + } + + public static boolean isBusy() { + return RUNNING.get() || !JOBS.isEmpty(); + } + + public static int queuedEdits() { + return JOBS.size(); + } + + public static void shutdown() { + JOBS.clear(); + } + + private static void startNext() { + if (!RUNNING.compareAndSet(false, true)) return; + + EditJob job = JOBS.poll(); + if (job == null) { + RUNNING.set(false); + if (!JOBS.isEmpty()) startNext(); + return; + } + + Bukkit.getScheduler().runTaskAsynchronously(MetadataHandler.PLUGIN, () -> { + Throwable failure = null; + try { + job.work().run(); + } catch (Throwable throwable) { + failure = throwable; + Logger.warn("FAWE edit failed (" + job.description() + "): " + throwable.getMessage()); + } + + Throwable finalFailure = failure; + Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, () -> { + try { + if (job.completion() != null) { + job.completion().accept(finalFailure); + } + } catch (Throwable completionFailure) { + Logger.warn("FAWE completion callback failed (" + job.description() + "): " + + completionFailure.getMessage()); + completionFailure.printStackTrace(); + } finally { + RUNNING.set(false); + startNext(); + } + }); + }); + } + + private record EditJob(String description, EditWork work, Consumer completion) { + } +} diff --git a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java index eaa3d9e..8bfac9a 100644 --- a/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java +++ b/src/main/java/com/magmaguy/betterstructures/worldedit/Schematic.java @@ -4,7 +4,6 @@ import com.magmaguy.betterstructures.config.DefaultConfig; import com.magmaguy.betterstructures.util.WorldEditUtils; import com.magmaguy.magmacore.util.Logger; -import com.magmaguy.magmacore.util.WorkloadRunnable; import com.sk89q.worldedit.EditSession; import com.sk89q.worldedit.WorldEdit; import com.sk89q.worldedit.WorldEditException; @@ -17,45 +16,78 @@ import com.sk89q.worldedit.function.operation.Operations; import com.sk89q.worldedit.math.BlockVector3; import com.sk89q.worldedit.session.ClipboardHolder; +import com.sk89q.worldedit.util.SideEffectSet; import com.sk89q.worldedit.world.World; import com.sk89q.worldedit.world.block.BaseBlock; -import com.sk89q.worldedit.world.block.BlockState; import org.bukkit.Bukkit; +import org.bukkit.Chunk; import org.bukkit.Location; import org.bukkit.Material; -import org.bukkit.block.Block; -import org.bukkit.block.data.BlockData; import org.bukkit.util.Vector; import java.io.File; import java.io.FileInputStream; import java.io.IOException; -import java.util.*; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Queue; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.function.Function; -public class Schematic { - // Queue to hold pending paste operations - private static final Queue pasteQueue = new ConcurrentLinkedQueue<>(); +/** + * BetterStructures schematic I/O and the Albion FAWE paste pipeline. + */ +public final class Schematic { + + private static final int CHUNK_LOAD_BATCH_SIZE = 2; + private static final long CHUNK_LOAD_BATCH_DELAY_TICKS = 1L; + private static final long PRESSURE_RETRY_TICKS = 10L; + + private static final Queue PASTE_QUEUE = new ConcurrentLinkedQueue<>(); + private static final Set INTERNAL_CHUNK_LOADS = ConcurrentHashMap.newKeySet(); + private static boolean erroredOnce = false; - private static boolean isDistributedPasting = false; + private static boolean pasteInProgress = false; private Schematic() { } - /** - * Loads a schematic from a file - * - * @param schematicFile The schematic file to load - * @return The loaded clipboard or null if loading failed - */ - public static Clipboard load(File schematicFile) { - Clipboard clipboard; + @FunctionalInterface + public interface FawePostProcessor { + void run(EditSession editSession, Location adjustedLocation) throws Exception; + } + public static void shutdown() { + PASTE_QUEUE.clear(); + INTERNAL_CHUNK_LOADS.clear(); + pasteInProgress = false; + FaweEditQueue.shutdown(); + } + + public static boolean isBusy() { + return pasteInProgress || !PASTE_QUEUE.isEmpty() || FaweEditQueue.isBusy(); + } + + public static boolean isInternalChunkLoad(Chunk chunk) { + return INTERNAL_CHUNK_LOADS.contains(new ChunkKey( + chunk.getWorld().getUID(), chunk.getX(), chunk.getZ())); + } + + public static Clipboard load(File schematicFile) { ClipboardFormat format = ClipboardFormats.findByFile(schematicFile); + if (format == null) { + Logger.warn("Could not determine schematic format for " + schematicFile.getName()); + return null; + } try (ClipboardReader reader = format.getReader(new FileInputStream(schematicFile))) { - clipboard = reader.read(); + return reader.read(); } catch (IOException e) { e.printStackTrace(); return null; @@ -64,28 +96,30 @@ public static Clipboard load(File schematicFile) { e.printStackTrace(); return null; } catch (Exception e) { - Logger.warn("Failed to load schematic " + schematicFile.getName() + " ! 99% of the time, this is because you are not using the correct WorldEdit version for your Minecraft server. You should be downloading WorldEdit from here https://dev.bukkit.org/projects/worldedit . You can check which versions the download links are compatible with by hovering over them."); - erroredOnce = true; - if (!erroredOnce) e.printStackTrace(); - else Logger.warn("Hiding stacktrace for this error, as it has already been printed once"); + Logger.warn("Failed to load schematic " + schematicFile.getName() + + ". This Albion fork requires a compatible FastAsyncWorldEdit build for your server version."); + if (!erroredOnce) { + erroredOnce = true; + e.printStackTrace(); + } else { + Logger.warn("Hiding stacktrace for this error because one has already been printed."); + } return null; } - return clipboard; } /** - * Pastes a schematic synchronously - * - * @param clipboard The WorldEdit clipboard containing the schematic - * @param location The location to paste at + * Synchronous FAWE paste for small component/explicit paths that require completion + * before returning. FastAsyncWorldEdit is the required WorldEdit provider. */ public static void paste(Clipboard clipboard, Location location) { World world = BukkitAdapter.adapt(location.getWorld()); try (EditSession editSession = WorldEdit.getInstance().newEditSession(world)) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); Operation operation = new ClipboardHolder(clipboard) .createPaste(editSession) - .to(BlockVector3.at(location.getX(), location.getY(), location.getZ())) - // configure here + .to(BlockVector3.at(location.getBlockX(), location.getBlockY(), location.getBlockZ())) .build(); Operations.complete(operation); } catch (WorldEditException e) { @@ -93,213 +127,323 @@ public static void paste(Clipboard clipboard, Location location) { } } - private static boolean isSolidBlock(Clipboard schematicClipboard, BlockVector3 clipboardPosition) { - return WorldEditUtils.isSolid(schematicClipboard.getBlock(clipboardPosition)); - } - /** - * Creates a list of paste blocks from a schematic - * - * @param schematicClipboard The clipboard containing the schematic - * @param location The location to paste at - * @param schematicOffset The offset of the schematic - * @param pedestalMaterialProvider Function that provides pedestal material based on whether it's a surface block - * @return List of paste blocks + * Queue a natural structure. Chunk preparation happens through Paper's async chunk + * API and the complete block edit, including optional post-processing, is executed + * in the one global FAWE lane before chunk tickets are released. */ - private static List createPasteBlocks( + public static void pasteSchematic( Clipboard schematicClipboard, Location location, Vector schematicOffset, - Function pedestalMaterialProvider) { - - List pasteBlocks = new ArrayList<>(); - - // Iterate through the schematic and create PasteBlock objects - Location adjustedLocation = location.clone().add(schematicOffset); - for (int x = 0; x < schematicClipboard.getDimensions().x(); x++) - for (int y = 0; y < schematicClipboard.getDimensions().y(); y++) - for (int z = 0; z < schematicClipboard.getDimensions().z(); z++) { - BlockVector3 adjustedClipboardLocation = BlockVector3.at( - x + schematicClipboard.getMinimumPoint().x(), - y + schematicClipboard.getMinimumPoint().y(), - z + schematicClipboard.getMinimumPoint().z()); - BaseBlock baseBlock = schematicClipboard.getFullBlock(adjustedClipboardLocation); - BlockState blockState = baseBlock.toImmutableState(); - Material material = WorldEditUtils.adaptMaterial(blockState); - Block worldBlock = adjustedLocation.clone().add(new Vector(x, y, z)).getBlock(); - boolean isGround = !isSolidBlock(schematicClipboard, BlockVector3.at( - adjustedClipboardLocation.x(), - adjustedClipboardLocation.y() + 1, - adjustedClipboardLocation.z())); - - if (material == Material.BARRIER) { - // special behavior: do not replace barriers, so do nothing - continue; - } + Runnable prePasteCallback, + Function pedestalMaterialProvider, + FawePostProcessor fawePostProcessor, + Runnable onComplete) { - BlockData blockData = material == null ? null : WorldEditUtils.createBlockDataOrNull(baseBlock); - if (blockData == null) { - if (WorldEditUtils.isAir(blockState)) { - // Air still carves interiors out of existing terrain - place plain air - pasteBlocks.add(new PasteBlock(worldBlock, Material.AIR.createBlockData(), null)); - } else { - pasteBlocks.add(new PasteBlock(worldBlock, null, - WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); - } - continue; - } + PASTE_QUEUE.add(new PasteRequest( + schematicClipboard, + location.clone(), + schematicOffset.clone(), + prePasteCallback, + pedestalMaterialProvider, + fawePostProcessor, + onComplete)); - String materialString = material.toString().toUpperCase(Locale.ROOT); - if (materialString.endsWith("SIGN") || - materialString.endsWith("STAIRS") || - materialString.endsWith("BOX") || - materialString.endsWith("CHEST_BOAT") || - materialString.equals("BEACON") || - materialString.endsWith("FURNACE") || - materialString.equals("CALIBRATED_SCULK_SENSOR") || - materialString.equals("CAMPFIRE") || - materialString.equals("CARTOGRAPHY_TABLE") || - materialString.equals("CAULDRON") || - materialString.contains("COMMAND_BLOCK") || - materialString.endsWith("ANVIL") || - materialString.equals("CRAFTER") || - materialString.equals("ITEM_FRAME") || - materialString.equals("DISPENSER") || - materialString.equals("DROPPER") || - materialString.equals("ENCHANTING_TABLE") || - materialString.equals("BARREL") || - materialString.equals("CHEST") || - materialString.equals("ENDER_CHEST") || - materialString.equals("TRAPPED_CHEST") || - materialString.equals("FLETCHING_TABLE") || - materialString.equals("FURNACE_MINECART") || - materialString.equals("GRINDSTONE") || - materialString.equals("HOPPER") || - materialString.equals("HOPPER_MINECART") || - materialString.equals("JUKEBOX") || - materialString.equals("LEVER") || - materialString.equals("LOOM") || - materialString.equals("LODESTONE") || - materialString.startsWith("POTTED") || - materialString.startsWith("SCULK") || - materialString.equals("POWERED_RAIL") || - materialString.equals("SMOKER") || - materialString.equals("STONECUTTER") || - materialString.equals("SOUL_CAMPFIRE") || - materialString.contains("SPAWNER")) { - // tricky metadata has to be done via worldedit - pasteBlocks.add(new PasteBlock(worldBlock, null, - WorldEditUtils.createSingleBlockClipboard(adjustedLocation, baseBlock, blockState))); - } else if (material == Material.BEDROCK) { - // special behavior: if it's not solid, replace with solid filler block - if (!worldBlock.getType().isSolid()) { - Material pedestalMaterial = pedestalMaterialProvider.apply(isGround); - worldBlock.setType(pedestalMaterial); - pasteBlocks.add(new PasteBlock(worldBlock, pedestalMaterial.createBlockData(), null)); - } - } else { - pasteBlocks.add(new PasteBlock(worldBlock, blockData, null)); - } - } + if (Bukkit.isPrimaryThread()) { + processNextPaste(); + } else { + Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, Schematic::processNextPaste); + } + } - return pasteBlocks; + public static void pasteSchematic( + Clipboard schematicClipboard, + Location location, + Vector schematicOffset, + Runnable prePasteCallback, + Function pedestalMaterialProvider, + Runnable onComplete) { + pasteSchematic(schematicClipboard, location, schematicOffset, prePasteCallback, + pedestalMaterialProvider, null, onComplete); } - /** - * Pastes a schematic using the provided pedestal material provider - * - * @param schematicClipboard The clipboard containing the schematic - * @param location The location to paste at - * @param schematicOffset The offset of the schematic - * @param pedestalMaterialProvider Function that provides pedestal material based on whether it's a surface block - * @param onComplete Callback to run when paste is complete - */ public static void pasteSchematic( Clipboard schematicClipboard, Location location, Vector schematicOffset, Function pedestalMaterialProvider, Runnable onComplete) { + pasteSchematic(schematicClipboard, location, schematicOffset, null, + pedestalMaterialProvider, null, onComplete); + } - List pasteBlocks = createPasteBlocks( - schematicClipboard, - location, - schematicOffset, - pedestalMaterialProvider); + private static void processNextPaste() { + if (!Bukkit.isPrimaryThread()) { + Bukkit.getScheduler().runTask(MetadataHandler.PLUGIN, Schematic::processNextPaste); + return; + } + if (pasteInProgress) return; + + PasteRequest request = PASTE_QUEUE.poll(); + if (request == null) return; - pasteDistributed(pasteBlocks, location, onComplete); + pasteInProgress = true; + attemptStartPaste(request); } - /** - * Pastes a schematic using a distributed workload over multiple ticks. - * If another paste operation is already in progress, this operation - * will be queued and executed when the current operation completes. - * - * @param pasteBlocks List of blocks to paste - * @param location The location to paste at - * @param onComplete Optional callback to run when paste is complete - */ - public static void pasteDistributed(List pasteBlocks, Location location, Runnable onComplete) { - // Add this paste operation to the queue - pasteQueue.add(new PasteBlockOperation(pasteBlocks, location, onComplete)); + private static void attemptStartPaste(PasteRequest request) { + if (serverUnderPressure()) { + Bukkit.getScheduler().runTaskLater( + MetadataHandler.PLUGIN, + () -> attemptStartPaste(request), + PRESSURE_RETRY_TICKS); + return; + } - // If we're not currently pasting, start processing the queue - if (!isDistributedPasting) { - processNextPaste(); + org.bukkit.World world = request.location().getWorld(); + if (world == null) { + failRequest(request, Set.of(), "world is unavailable"); + return; } + + List requiredChunks = new ArrayList<>(calculateRequiredChunks( + request.schematicClipboard(), request.location(), request.schematicOffset())); + loadChunkBatch(request, world, requiredChunks, 0, new LinkedHashSet<>()); } - /** - * Processes the next paste operation in the queue - */ - private static void processNextPaste() { - if (pasteQueue.isEmpty()) { - isDistributedPasting = false; + private static boolean serverUnderPressure() { + if (!DefaultConfig.isPlayerGenerationThrottling()) return false; + if (Bukkit.getAverageTickTime() >= DefaultConfig.getPlayerGenerationPauseMSPT()) return true; + double[] tps = Bukkit.getTPS(); + return tps.length > 0 && tps[0] <= DefaultConfig.getPlayerGenerationPauseTPS(); + } + + private static Set calculateRequiredChunks( + Clipboard clipboard, + Location location, + Vector schematicOffset) { + + LinkedHashSet chunks = new LinkedHashSet<>(); + Location adjusted = location.clone().add(schematicOffset); + + int minX = adjusted.getBlockX(); + int minZ = adjusted.getBlockZ(); + int maxX = minX + Math.max(0, clipboard.getDimensions().x() - 1); + int maxZ = minZ + Math.max(0, clipboard.getDimensions().z() - 1); + + for (int chunkX = minX >> 4; chunkX <= maxX >> 4; chunkX++) { + for (int chunkZ = minZ >> 4; chunkZ <= maxZ >> 4; chunkZ++) { + chunks.add(chunkKey(chunkX, chunkZ)); + } + } + return chunks; + } + + private static void loadChunkBatch( + PasteRequest request, + org.bukkit.World world, + List requiredChunks, + int startIndex, + Set ticketedChunks) { + + if (startIndex >= requiredChunks.size()) { + startFawePaste(request, world, ticketedChunks); return; } - isDistributedPasting = true; - PasteBlockOperation operation = pasteQueue.poll(); + int endIndex = Math.min(requiredChunks.size(), startIndex + CHUNK_LOAD_BATCH_SIZE); + List batch = requiredChunks.subList(startIndex, endIndex); + List> futures = new ArrayList<>(batch.size()); + + for (long key : batch) { + int chunkX = chunkX(key); + int chunkZ = chunkZ(key); + ChunkKey chunkKey = new ChunkKey(world.getUID(), chunkX, chunkZ); + INTERNAL_CHUNK_LOADS.add(chunkKey); + futures.add(world.getChunkAtAsync(chunkX, chunkZ, true)); + } + + CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])) + .whenComplete((ignored, throwable) -> Bukkit.getScheduler().runTask( + MetadataHandler.PLUGIN, + () -> { + for (long key : batch) { + INTERNAL_CHUNK_LOADS.remove(new ChunkKey( + world.getUID(), chunkX(key), chunkZ(key))); + } + + if (throwable != null) { + failRequest(request, ticketedChunks, + "async chunk preparation failed: " + throwable.getClass().getSimpleName()); + return; + } + + for (long key : batch) { + int chunkX = chunkX(key); + int chunkZ = chunkZ(key); + world.addPluginChunkTicket(chunkX, chunkZ, MetadataHandler.PLUGIN); + ticketedChunks.add(key); + } + + int nextIndex = endIndex; + Bukkit.getScheduler().runTaskLater( + MetadataHandler.PLUGIN, + () -> loadChunkBatch(request, world, requiredChunks, + nextIndex, ticketedChunks), + CHUNK_LOAD_BATCH_DELAY_TICKS); + })); + } + + private static void startFawePaste( + PasteRequest request, + org.bukkit.World world, + Set ticketedChunks) { - // Create a workload for this paste operation - WorkloadRunnable workload = new WorkloadRunnable(DefaultConfig.getPercentageOfTickUsedForPasting(), () -> { - // Run the completion callback if provided - if (operation.onComplete != null) { - operation.onComplete.run(); + try { + if (request.prePasteCallback() != null) { + request.prePasteCallback().run(); } - // Process the next paste in the queue - processNextPaste(); - }); - - for (PasteBlock pasteBlock : operation.blocks) { - workload.addWorkload(() -> { - if (pasteBlock.blockData() != null) { - pasteBlock.block().setBlockData(pasteBlock.blockData()); - } else if (pasteBlock.clipboard() != null) { - try (EditSession editSession = WorldEdit.getInstance().newEditSession(BukkitAdapter.adapt(pasteBlock.block().getLocation().getWorld()))) { - Operation worldeditPaste = new ClipboardHolder(pasteBlock.clipboard()) - .createPaste(editSession) - .to(BlockVector3.at(pasteBlock.block().getX(), pasteBlock.block().getY(), pasteBlock.block().getZ())) - // configure here - .build(); - Operations.complete(worldeditPaste); - } catch (WorldEditException e) { - throw new RuntimeException(e); + } catch (Throwable throwable) { + Logger.warn("BetterStructures pre-paste preparation failed: " + throwable.getMessage()); + throwable.printStackTrace(); + failRequest(request, ticketedChunks, "pre-paste callback failed"); + return; + } + + String description = "natural structure at " + + request.location().getBlockX() + "," + + request.location().getBlockY() + "," + + request.location().getBlockZ(); + + FaweEditQueue.submit(description, + () -> executeFawePaste(request, world), + failure -> { + try { + if (failure == null) { + if (request.onComplete() != null) { + request.onComplete().run(); + } + } else { + Logger.warn("FAWE structure paste failed at " + + request.location().getBlockX() + "," + + request.location().getBlockY() + "," + + request.location().getBlockZ() + ": " + + failure.getMessage()); + failure.printStackTrace(); + } + } finally { + releaseTickets(world, ticketedChunks); + pasteInProgress = false; + processNextPaste(); + } + }); + } + + private static void executeFawePaste(PasteRequest request, org.bukkit.World bukkitWorld) + throws Exception { + + Clipboard clipboard = request.schematicClipboard(); + Location adjustedLocation = request.location().clone().add(request.schematicOffset()); + World world = BukkitAdapter.adapt(bukkitWorld); + + int sizeX = clipboard.getDimensions().x(); + int sizeY = clipboard.getDimensions().y(); + int sizeZ = clipboard.getDimensions().z(); + int minimumX = clipboard.getMinimumPoint().x(); + int minimumY = clipboard.getMinimumPoint().y(); + int minimumZ = clipboard.getMinimumPoint().z(); + + try (EditSession editSession = WorldEdit.getInstance().newEditSession(world)) { + editSession.setTrackingHistory(false); + editSession.setSideEffectApplier(SideEffectSet.none()); + + for (int x = 0; x < sizeX; x++) { + for (int y = 0; y < sizeY; y++) { + for (int z = 0; z < sizeZ; z++) { + BlockVector3 clipboardPosition = BlockVector3.at( + x + minimumX, + y + minimumY, + z + minimumZ); + + BaseBlock baseBlock = clipboard.getFullBlock(clipboardPosition); + Material material = WorldEditUtils.adaptMaterial(baseBlock); + if (material == Material.BARRIER) continue; + + BlockVector3 worldPosition = BlockVector3.at( + adjustedLocation.getBlockX() + x, + adjustedLocation.getBlockY() + y, + adjustedLocation.getBlockZ() + z); + + if (material == Material.BEDROCK) { + if (editSession.getBlock(worldPosition) + .getBlockType().getMaterial().isSolid()) { + continue; + } + + boolean isGround = y + 1 >= sizeY || !WorldEditUtils.isSolid( + clipboard.getBlock(BlockVector3.at( + clipboardPosition.x(), + clipboardPosition.y() + 1, + clipboardPosition.z()))); + + Material pedestalMaterial = request.pedestalMaterialProvider().apply(isGround); + if (pedestalMaterial != null) { + editSession.setBlock(worldPosition, + BukkitAdapter.adapt(pedestalMaterial.createBlockData())); + } + } else { + editSession.setBlock(worldPosition, baseBlock); + } } } - }); + } + + if (request.fawePostProcessor() != null) { + request.fawePostProcessor().run(editSession, adjustedLocation); + } } + } - // Start the workload - workload.runTaskTimer(MetadataHandler.PLUGIN, 0, 1); + private static void failRequest(PasteRequest request, Set ticketedChunks, String reason) { + org.bukkit.World world = request.location().getWorld(); + if (world != null) { + releaseTickets(world, ticketedChunks); + } + Logger.warn("Skipping BetterStructures paste: " + reason); + pasteInProgress = false; + processNextPaste(); } - /** - * Represents a single paste operation - */ - private record PasteBlockOperation(List blocks, Location location, Runnable onComplete) { + private static void releaseTickets(org.bukkit.World world, Set ticketedChunks) { + for (long key : ticketedChunks) { + world.removePluginChunkTicket(chunkX(key), chunkZ(key), MetadataHandler.PLUGIN); + } + ticketedChunks.clear(); + } + + private static long chunkKey(int x, int z) { + return ((long) x << 32) | (z & 0xFFFFFFFFL); + } + + private static int chunkX(long key) { + return (int) (key >> 32); + } + + private static int chunkZ(long key) { + return (int) key; + } + + private record PasteRequest( + Clipboard schematicClipboard, + Location location, + Vector schematicOffset, + Runnable prePasteCallback, + Function pedestalMaterialProvider, + FawePostProcessor fawePostProcessor, + Runnable onComplete) { } - public record PasteBlock(Block block, BlockData blockData, Clipboard clipboard) { + private record ChunkKey(UUID worldId, int x, int z) { } } diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index f67a82a..1e06196 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -1,19 +1,18 @@ name: BetterStructures -version: '2.6.3' +version: '@Version@' main: com.magmaguy.betterstructures.BetterStructures -api-version: '1.21.4' -depend: [ WorldEdit ] +api-version: '1.21.11' +depend: [ FastAsyncWorldEdit ] softdepend: - EliteMobs - WorldGuard - - WorldEdit - Terralith - Iris - Terra - TerraformGenerator authors: [ MagmaGuy ] -description: A plugin that adds random structures to your Minecraft world! -website: magmaguy.com +description: AlbionMC performance fork of BetterStructures, based on upstream 2.6.3. +website: AlbionMC.com commands: betterstructures: description: Main command diff --git a/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java b/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java index 1a3e944..ee6e058 100644 --- a/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java +++ b/src/test/java/com/magmaguy/betterstructures/util/WorldEditUtilsTest.java @@ -49,8 +49,8 @@ void createsSingleBlockClipboardWithStableOneBlockGeometry() { assertEquals(BlockVector3.at(0, 0, 0), clipboard.getMinimumPoint()); assertEquals(BlockVector3.at(0, 0, 0), clipboard.getMaximumPoint()); assertEquals(BlockVector3.at(1, 1, 1), clipboard.getDimensions()); - assertEquals(blockState, clipboard.getBlock(BlockVector3.at(0, 0, 0))); - assertEquals(baseBlock, clipboard.getFullBlock(BlockVector3.at(0, 0, 0))); + // FAWE stores blocks in an internal palette and is free to canonicalize the + // supplied holder, so object-identity assertions are intentionally avoided. assertTrue(clipboard.getEntities().isEmpty()); }