diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1fad08cc..1d7fe752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,6 +17,67 @@ concurrency: cancel-in-progress: true jobs: + cold-forge-bootstrap: + name: Cold Forge bootstrap + runs-on: ubuntu-latest + timeout-minutes: 60 + + steps: + - name: Check out source + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 + + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime and compilation toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '17.0.1+12' + + - name: Bootstrap Forge from an empty cache + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/orespawn-cold-gradle + run: | + set -euo pipefail + test ! -e .gradle + test ! -e "$GRADLE_USER_HOME" + mkdir -p "$GRADLE_USER_HOME" + chmod +x ./gradlew + gradle_args=( + classes verifyLegacyFixtures verifyMavenizerCompatibilityFixture + --no-daemon --no-build-cache --stacktrace --max-workers=2 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew "${gradle_args[@]}" + + - name: Verify same-cache bootstrap offline + shell: bash + env: + GRADLE_USER_HOME: ${{ runner.temp }}/orespawn-cold-gradle + run: | + set -euo pipefail + gradle_args=( + classes verifyLegacyFixtures verifyMavenizerCompatibilityFixture + --rerun-tasks --offline --no-daemon --no-build-cache --stacktrace --max-workers=2 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew "${gradle_args[@]}" + build: name: Build, test, and audit runs-on: ubuntu-latest @@ -26,7 +87,19 @@ jobs: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install pinned Java 17 runtime and toolchain + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime and compilation toolchain uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin @@ -42,6 +115,9 @@ jobs: run: >- ./gradlew clean check build javadoc verifyReleaseArtifacts writeReleaseChecksums verifyEclipseProductionClasspath --no-daemon --stacktrace + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false - name: Upload audited release candidate uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 @@ -50,9 +126,9 @@ jobs: if-no-files-found: error retention-days: 30 path: | - build/libs/OreSpawn-4.0.16.118021.jar - build/libs/OreSpawn-4.0.16.118021-sources.jar - build/libs/OreSpawn-4.0.16.118021-javadoc.jar + build/libs/OreSpawn-4.1.0.118021.jar + build/libs/OreSpawn-4.1.0.118021-sources.jar + build/libs/OreSpawn-4.1.0.118021-javadoc.jar build/release/SHA256SUMS CHANGELOG.txt diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f9ed9ff2..781d331d 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -26,7 +26,19 @@ jobs: - name: Check out source uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7 - - name: Install pinned Java 17 runtime and toolchain + - name: Install pinned Java 25 ForgeGradle Mavenizer runtime + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '25.0.3+9.0.LTS' + + - name: Install pinned Java 8 launcher toolchain + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 + with: + distribution: temurin + java-version: '8.0.502+7' + + - name: Install pinned Java 17 Gradle runtime and compilation toolchain uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5 with: distribution: temurin @@ -43,7 +55,14 @@ jobs: - name: Compile production code run: | chmod +x ./gradlew - ./gradlew clean classes --no-daemon --stacktrace + gradle_args=( + clean classes + --no-daemon --stacktrace --max-workers=2 + -Dorg.gradle.java.installations.paths="$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64" + -Dorg.gradle.java.installations.auto-detect=false + -Dorg.gradle.java.installations.auto-download=false + ) + ./gradlew "${gradle_args[@]}" - name: Analyze uses: github/codeql-action/analyze@db488ddef3bf6cb639b32c2e9a7c0a7ea8271d28 # v4 diff --git a/CHANGELOG.txt b/CHANGELOG.txt index 40222c33..0aceff27 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -1,3 +1,17 @@ +Version 4.1.0.118021 + +* Add a mod directory and material-group ore controls, including exact block-tag + aliases and sensible defaults when multiple managed MMD mods share an ore. +* Add a biome directory with exact new-terrain replacement and dimension-wide + material controls. Existing chunks remain unchanged. +* Preserve older profiles and missing-mod settings while upgrading the provider, + global, and world schemas to 5, 8, and 7. +* Split the build logic into smaller, commented scripts and use LF text files. +* Keep managed vanilla ore settings effective for inline biome features while + preserving shared feature order and the original placement modifiers. +* Reject invalid geome IDs without renaming them, and add separate shared-ore + test launches with complete Forge arguments and processed resources. + Version 4.0.16.118021 * Leave benchmark shutdown to the GameTest harness when a benchmark is run diff --git a/README.md b/README.md index 4cf000c1..cc8bb9fb 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ End" policy used by mods such as Base Metals. This is not the unrelated mod that adds mobs and dimensions under the same name. -This branch builds target-qualified version `4.0.16.118021`: the OreSpawn 4.0.16 +This branch builds target-qualified version `4.1.0.118021`: the OreSpawn 4.1 feature set for Minecraft 1.18.2 and Forge. See the [versioning policy](docs/VERSIONS.md) for the encoding and release convention. @@ -54,6 +54,15 @@ Important files: Profile edits affect newly generated chunks. Ore and flat-bedrock retrogen are separate opt-in features; OreSpawn never retro-generates rock strata. +**Ore Sources** groups ores by exact block tags such as `forge:ores/sulfur`. +When two loaded ordinary MMD providers share one tag, new worlds use a +Balanced output policy and one placement budget per channel. Older worlds keep +their saved Keep Original behaviour. Imported Ore Dictionary aliases remain +visible but dormant until you assign a known exact block tag. The **Biomes** +directory can replace one loaded biome with another in new terrain; its +**Overworld Materials** control applies across the dimension, not just the +selected biome. + When an already-generated world has saved Mineralogy 1.10, 1.12, or 5.x mod metadata but no OreSpawn world profile, OreSpawn reads the matching published configuration contract and records the exact engine, numeric settings, rock @@ -104,6 +113,12 @@ Minecraft 1.18.2; the build rejects a different Java 17 toolchain: .\gradlew.bat genEclipseRuns verifyEclipseProductionClasspath --no-daemon ``` +For a manual duplicate-ore test, use `runOreSourcesClient.launch` (also named +`runTestOreSourcesClient.launch`). It loads two isolated dummy providers and +keeps its worlds and settings in `run-ore-sources`. Ordinary launches do not +load those providers. See [the build guide](gradle/README.md) for the automated +fresh/reload and managed Nether ore checks. + `build` runs the standard `check` lifecycle. In addition to the JUnit suite, that lifecycle packages a test-only provider mod and verifies exposed, underwater, filler, and ceiling surfaces in open and ceiling normal-noise @@ -119,8 +134,6 @@ and fixtures. Published jars are deterministic, SRG-reobfuscated for the Forge 40 runtime, audited for their six access-transformer rules and contents, and accompanied by SHA-256 checksums. -Machine-specific `AGENTS.md` and `agent-notes/` files are intentionally ignored. -Public developer and AI integration guidance lives in `docs/` and is included -in the built jar. +Developer and player guides live in `docs/` and are included in the built jar. OreSpawn is licensed under LGPL-2.1. diff --git a/build.gradle b/build.gradle index a1a5b7f0..a5f0a779 100644 --- a/build.gradle +++ b/build.gradle @@ -117,7 +117,7 @@ minecraft { mods { orespawn { source sourceSets.main } } } register('client') - register('server') { args '--nogui' } + register('server') { args 'nogui' } register('data') { workingDir.convention layout.projectDirectory.dir('run-data') args '--mod', project.mod_id, '--all', '--output', file('src/generated/resources/'), @@ -134,7 +134,7 @@ minecraft { args '--launchTarget', 'forgeserveruserdev', '--gameDir', '.', '--fml.forgeVersion', forge_version, '--fml.mcVersion', minecraft_version, '--fml.forgeGroup', 'net.minecraftforge', '--fml.mcpVersion', mcp_version, - '--nogui' + 'nogui' systemProperty 'legacyClassPath.file', '{minecraft_classpath_file}' systemProperty 'ignoreList', 'bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-' @@ -154,7 +154,7 @@ minecraft { args '--launchTarget', 'forgeserveruserdev', '--gameDir', '.', '--fml.forgeVersion', forge_version, '--fml.mcVersion', minecraft_version, '--fml.forgeGroup', 'net.minecraftforge', '--fml.mcpVersion', mcp_version, - '--nogui' + 'nogui' systemProperty 'legacyClassPath.file', '{minecraft_classpath_file}' systemProperty 'ignoreList', 'bootstraplauncher,securejarhandler,asm-commons,asm-util,asm-analysis,asm-tree,asm,JarJarFileSystems,client-extra,fmlcore,javafmllanguage,lowcodelanguage,mclanguage,forge-' @@ -337,10 +337,6 @@ tasks.named('processResources', ProcessResources) { minecraft_version_range: project.minecraft_version_range ]) } - from('docs/AGENTS.md') { - into '' - rename { 'AGENTS.md' } - } filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) } @@ -352,1227 +348,36 @@ def prepareEclipseResources = tasks.register('prepareEclipseResources') { from(layout.buildDirectory.dir('resources/main')) into(layout.projectDirectory.dir('bin/main')) } - } -} - -// A Forge process is not green merely because it returns exit code zero. The -// loader can log a worldgen/linkage failure and still shut down normally. -def acceptedForge40LogNoise = [ - ~/FML appears to be missing any signature data/, - ~/Found multiple arguments for option fml\.mcVersion/, - ~/Found multiple arguments for option fml\.forgeVersion/, - ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to verify authentication$/, - ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, - ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, - ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / -] - -def runtimeCrashSnapshot = { File runDirectory -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (!crashDirectory.isDirectory()) return [] as Set - return fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() }.collect { it.absolutePath } as Set -} - -def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes -> - File crashDirectory = new File(runDirectory, 'crash-reports') - if (crashDirectory.isDirectory()) { - def crashes = fileTree(crashDirectory) { include '**/*' }.files - .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } - if (!crashes.isEmpty()) { - throw new GradleException("${context} produced crash report ${crashes.first()}") - } - } - - File logsDirectory = new File(runDirectory, 'logs') - if (!logsDirectory.isDirectory()) return - def failures = [] - [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] - .findAll { it.isFile() }.each { File log -> - int lineNumber = 0 - log.eachLine('UTF-8') { String line -> - lineNumber++ - boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ - boolean knownNoise = acceptedForge40LogNoise.any { line =~ it } - boolean fatalText = line.contains('Encountered an unexpected exception') || - line.contains('Exception stopping the server') || - line.contains('Migration audit failed') || - line.contains('java.lang.Error:') || - line.contains('NoSuchMethodError') || - line.contains('NoClassDefFoundError') || - line.contains('ExceptionInInitializerError') || - line.contains('Tried to assign a mutable BlockPos') || - line.contains('causing cascading worldgen lag') - if ((unexpectedSeverity && !knownNoise) || fatalText) { - failures.add("${log.name}:${lineNumber}: ${line}") - } - } - } - if (!failures.isEmpty()) { - throw new GradleException("${context} logged unexpected errors:\n" - + failures.take(20).join('\n')) - } -} - -task runtimeLogScannerTest { - group = 'verification' - description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' - doLast { - File probe = file("${buildDir}/runtime-log-scanner-test") - delete probe - File logs = new File(probe, 'logs'); logs.mkdirs() - new File(logs, 'latest.log').setText( - '[main/ERROR] [FML]: FML appears to be missing any signature data\n' - + '[Render thread/ERROR] [net.minecraft.client.Minecraft/]: Failed to verify authentication\n' - + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' - + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') - assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) - new File(logs, 'latest.log').setText( - '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') - boolean rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') - new File(logs, 'latest.log').setText( - '[Server thread/DEBUG] [FML]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') - new File(logs, 'latest.log').setText( - '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') - rejected = false - try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } - catch (GradleException expected) { rejected = true } - if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') - delete probe - } -} - -check.dependsOn runtimeLogScannerTest - -task verifyMineralogyOracleIsolation { - group = 'verification' - description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' - doLast { - configurations.each { configuration -> - if (configuration.canBeResolved && - configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { - throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") - } - } - } -} - -check.dependsOn verifyMineralogyOracleIsolation - -['runClient', 'runServer', 'runData'].each { String taskName -> - tasks.matching { it.name == taskName }.all { JavaExec runTask -> - doFirst { - new File(runTask.workingDir, 'mods').mkdirs() - runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) - } - doLast { - assertRuntimeLogsClean(runTask.workingDir, taskName, - runTask.ext.oreSpawnCrashSnapshot as Set) - } - } -} - -def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") -tasks.register('compileClientIntegrationTestMod', JavaCompile) { - dependsOn tasks.named('classes') - source fileTree('src/clientIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = clientIntegrationClasses - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - options.encoding = 'UTF-8' -} -tasks.register('clientIntegrationTestModJar', Jar) { - dependsOn tasks.named('compileClientIntegrationTestMod') - archiveFileName = 'clientprobe.jar' - destinationDirectory = file("${buildDir}/client-integration-fixture") - from clientIntegrationClasses - from 'src/clientIntegrationTest/resources' -} -def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { - map.from minecraft.dependency.toSrgFile - output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') -} - -def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") -task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { - source fileTree('src/biomeIntegrationTest/java') - classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) - destinationDirectory = surfaceIntegrationClasses - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - options.encoding = 'UTF-8' -} - -task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { - archiveFileName = 'surfaceprobe.jar' - destinationDirectory = file("${buildDir}/surface-integration-fixture") - from surfaceIntegrationClasses - from 'src/biomeIntegrationTest/resources' -} - -def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") -task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { - doLast { - delete surfaceIntegrationRunDirectory - surfaceIntegrationRunDirectory.mkdirs() - copy { - from surfaceIntegrationTestModJar.archiveFile - into new File(surfaceIntegrationRunDirectory, 'mods') - } - new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} - -def surfaceIntegrationFreshProcess = tasks.register('surfaceIntegrationFreshProcess') { - group = 'verification' - dependsOn 'runSurfaceIntegrationFresh' -} -tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { - dependsOn prepareSurfaceIntegrationTest -} -tasks.matching { it.name == 'runSurfaceIntegrationReload' }.all { - mustRunAfter surfaceIntegrationFreshProcess -} -surfaceIntegrationFreshProcess.configure { - doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) { - throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") - } - assertRuntimeLogsClean(surfaceIntegrationRunDirectory, - 'surface integration fresh phase', [] as Set) - } -} -def surfaceIntegrationReloadProcess = tasks.register('surfaceIntegrationReloadProcess') { - group = 'verification' - dependsOn surfaceIntegrationFreshProcess - dependsOn 'runSurfaceIntegrationReload' -} -surfaceIntegrationReloadProcess.configure { - doLast { - assertRuntimeLogsClean(surfaceIntegrationRunDirectory, - 'surface integration reload phase', [] as Set) - } -} - -task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { - group = 'verification' - doLast { - File marker = new File(surfaceIntegrationRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties result = new Properties() - marker.withInputStream { result.load(it) } - if (result.getProperty('reload_verified') != 'true') { - throw new GradleException("Surface integration reload was not verified: ${marker}") - } - logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', - result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) - } -} - -check.dependsOn surfaceIntegrationTest - -task syncForge40EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { - group = 'ide' - doLast { - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String ordinaryModClasses = "${mod_id}%%${mainOutput}" - String fixtureOutput = surfaceIntegrationClasses.absolutePath - String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath - String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + - "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + - "surfaceprobe%%${fixtureResources}" - ['Client', 'Server', 'Data'].each { String runName -> - File launch = file("run${runName}.launch") - if (!launch.isFile()) { - throw new GradleException("Missing generated Eclipse launch: ${launch}") - } - String text = launch.getText('UTF-8') - if (text.contains('')) { - text = text.replace( - '', - '\r\n' + - " \r\n" + - '') - } - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - text = text.replace('', - ' \r\n' + - '') - } - launch.setText(text, 'UTF-8') - } - ['Fresh', 'Reload'].each { String phase -> - File launch = file("runSurfaceIntegration${phase}.launch") - File serverLaunch = file('runServer.launch') - if (!serverLaunch.isFile()) { - throw new GradleException("Missing generated Eclipse launch: ${serverLaunch}") - } - String text = serverLaunch.getText('UTF-8') - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - text = text.replaceFirst( - //, - { String match, String arguments -> - "" - }) - text = text.replaceFirst( - //, - java.util.regex.Matcher.quoteReplacement( - "")) - launch.setText(text, 'UTF-8') - } - } -} - -task verifyForge40EclipseLaunchIsolation { - group = 'verification' - doLast { - ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> - File launch = file(launchName) - String launchText = launch.getText('UTF-8') - if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || - !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { - throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") - } - } - } -} - -syncForge40EclipseLaunches.finalizedBy verifyForge40EclipseLaunchIsolation - -tasks.matching { it.name == 'genEclipseRuns' }.all { - finalizedBy syncForge40EclipseLaunches -} - -def packagedSurfaceProbeJar = renamer.classes(tasks.named('surfaceIntegrationTestModJar', Jar)) { - map.from minecraft.dependency.toSrgFile - output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') -} - -tasks.named('jar', Jar) { - archiveClassifier = 'deobf' - destinationDirectory = layout.buildDirectory.dir('libs-dev') - manifest { - attributes([ - 'Specification-Title' : 'OreSpawn', - 'Specification-Vendor' : 'SkyBlade1978', - 'Specification-Version' : '1', - 'Implementation-Title' : base.archivesName.get(), - 'Implementation-Version' : project.version, - 'Implementation-Vendor' : 'SkyBlade1978', - 'OreSpawn-API-Version' : '1', - 'FMLAT' : 'accesstransformer.cfg', - 'Maven-Artifact' : expectedMavenCoordinate, - 'Built-On-Java' : '17', - 'Built-On' : "${project.minecraft_version}-${project.forge_version}" - ]) - } -} - -def releaseJar = renamer.classes(tasks.named('jar', Jar)) { - map.from minecraft.dependency.toSrgFile - archiveClassifier = null - accessTransformers = true - output = layout.buildDirectory.file( - "libs/${base.archivesName.get()}-${project.version}.jar") -} - -tasks.named('sourcesJar', Jar) { - dependsOn prepareBundledDocumentation - filteringCharset = 'UTF-8' - includeEmptyDirs = false - filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-sources', - 'Implementation-Version': project.version - ]) - } -} -tasks.named('javadocJar', Jar) { - filteringCharset = 'UTF-8' - filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) - manifest { - attributes([ - 'Implementation-Title' : 'OreSpawn-javadoc', - 'Implementation-Version': project.version - ]) - } -} -['apiElements', 'runtimeElements'].each { configurationName -> - configurations.named(configurationName) { artifacts.clear() } - artifacts { add(configurationName, releaseJar) } -} -tasks.named('assemble') { - dependsOn releaseJar - dependsOn tasks.named('sourcesJar') - dependsOn tasks.named('javadocJar') -} - -def expectedReleaseFiles = providers.provider { - String prefix = "${base.archivesName.get()}-${project.version}" - ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] -} -def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') - -tasks.register('verifyReleaseConfiguration') { - group = 'verification' - doLast { - if (project.mod_version != '4.0.16.118021' - || project.mod_group != expectedMavenGroup - || project.minecraft_version != '1.18.2' - || project.forge_version != '40.3.0' - || project.mapping_channel != 'official' - || project.mapping_version != '1.18.2') { - throw new GradleException('Unexpected OreSpawn 1.18.2 release identity') - } - if (project.loader_name != 'forge' || project.loader_code != '1' - || project.java_version != '17' || project.gradle_java_version != '17' - || project.java_toolchain_version != '17.0.1+12') { - throw new GradleException('Unexpected dispatcher or Java target metadata') - } - List expectedPublicArtifacts = [ - 'OreSpawn-4.0.16.118021.jar', - 'OreSpawn-4.0.16.118021-sources.jar', - 'OreSpawn-4.0.16.118021-javadoc.jar' - ] - if (base.archivesName.get() != expectedMavenArtifact - || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { - throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') - } - String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') - expectedPublicArtifacts.each { artifactName -> - if (!ciWorkflow.contains("build/libs/${artifactName}")) { - throw new GradleException("CI does not upload expected public artifact ${artifactName}") - } - } - [ - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', - 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', - 'README.md', 'CHANGELOG.txt' - ].each { path -> - if (!file(path).getText('UTF-8').contains('4.0.16.118021')) { - throw new GradleException("Release identity missing from ${path}") - } - } - if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { - throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') - } - if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') - .getText('UTF-8').contains('API_VERSION = 1')) { - throw new GradleException('OreSpawn API major must remain 1') - } - if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') - .getText('UTF-8').contains('SCHEMA_VERSION = 6') - || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') - .getText('UTF-8').contains('SCHEMA_VERSION = 5')) { - throw new GradleException('Global/world schemas must remain 6/5') - } - def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) - if (!(schema.properties.schema_version.enum as List).contains(4)) { - throw new GradleException('Provider schema must remain version 4') - } - } -} - -def trackedDocumentationDirectory = file('docs') -def documentationFiles = { - fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { - trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') }.sort() -} -def assertDocumentationTree = { File root, List expected, String label -> - List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } - .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) - .toString().replace('\\', '/') } - .sort() : [] - if (actual != expected) { - throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") - } - expected.each { relative -> - byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes - byte[] candidate = new File(root, relative).bytes - if (!java.util.Arrays.equals(tracked, candidate)) { - throw new GradleException("${label}/${relative} differs from tracked documentation") - } - } -} - -def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { - group = 'verification' - dependsOn prepareBundledDocumentation - dependsOn tasks.named('processResources') - dependsOn prepareEclipseResources - dependsOn releaseJar - doLast { - List expected = documentationFiles() - if (expected.size() != 21 || !expected.contains('VERSIONS.md')) { - throw new GradleException("Expected exactly 21 tracked guide files including VERSIONS.md, found ${expected}") - } - assertDocumentationTree(bundledDocumentationDirectory.asFile, - expected, 'generated resources') - assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, - 'META-INF/orespawn/docs'), expected, 'processed resources') - assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), - expected, 'Eclipse bin/main') - new ZipFile(releaseJar.get().output.get().asFile).withCloseable { zip -> - expected.each { relative -> - def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") - if (entry == null || !java.util.Arrays.equals( - new File(trackedDocumentationDirectory, relative).bytes, - zip.getInputStream(entry).withCloseable { it.bytes })) { - throw new GradleException("Release jar documentation differs at ${relative}") - } - } - } - } -} - -tasks.register('verifyReleaseArtifacts') { - group = 'verification' - dependsOn tasks.named('verifyReleaseConfiguration') - dependsOn verifyDocumentationParity - dependsOn tasks.named('assemble') - doLast { - File libs = layout.buildDirectory.dir('libs').get().asFile - List jars = (libs.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - // Provider interpolation yields GString values; normalize them before - // comparing with real filesystem String names. - List expected = expectedReleaseFiles.get() - .collect { it.toString() }.sort() - if (jars.collect { it.name } != expected) { - throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") - } - jars.each { candidate -> - if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") - new ZipFile(candidate).withCloseable { zip -> - zip.entries().findAll { entry -> - !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } - || entry.name.endsWith('/element-list') - || entry.name.endsWith('/package-list')) - }.each { entry -> - boolean cr = zip.getInputStream(entry).withCloseable { - input -> input.bytes.any { value -> value == 13 } - } - if (cr) throw new GradleException( - "${candidate.name}!/${entry.name} is not LF-normalized") - } - [ - 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', - 'agent-notes/', 'surfaceprobe', 'clientprobe', 'ci-fixtures/', - 'org/junit/', 'org/mockito/', 'net/bytebuddy/', - 'Mineralogy-1.18.2-5.4.0.jar' - ].each { forbidden -> - if (zip.entries().any { it.name.contains(forbidden) }) { - throw new GradleException( - "${candidate.name} contains forbidden ${forbidden}") - } - } - } - } - - File mainJar = new File(libs, expectedReleaseFiles.get()[0]) - new ZipFile(mainJar).withCloseable { zip -> - List names = zip.entries().collect { it.name } - [ - 'META-INF/mods.toml', - 'META-INF/accesstransformer.cfg', - 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', - 'META-INF/orespawn/docs/VERSIONS.md', - 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json', - 'AGENTS.md' - ].each { required -> - if (!names.contains(required)) { - throw new GradleException("Release jar is missing ${required}") - } - } - String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) - .getText(StandardCharsets.UTF_8.name()) - if (!metadata.contains('modId="orespawn"') - || !metadata.contains("version=\"${project.version}\"") - || !metadata.contains('versionRange="[1.18.2,1.19)"')) { - throw new GradleException('Packaged Forge metadata is incorrect') - } - String transformer = zip.getInputStream( - zip.getEntry('META-INF/accesstransformer.cfg')) - .getText(StandardCharsets.UTF_8.name()) - List actualRules = transformer.readLines() - .collect { it.replaceFirst(/\s*#.*/, '').trim() } - .findAll { !it.isEmpty() } - List expectedRules = [ - 'public-f net.minecraft.world.level.chunk.ChunkGenerator f_62137_', - 'public-f net.minecraft.world.level.chunk.ChunkGenerator f_62138_', - 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator f_188607_', - 'public-f net.minecraft.world.level.biome.Biome f_47438_', - 'public net.minecraft.world.level.biome.Biome f_47437_', - 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration f_68128_' - ] - if (actualRules != expectedRules) { - throw new GradleException("Unexpected packaged SRG access transformer: ${actualRules}") - } - def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') - def manifest = manifestEntry == null ? null : - new Manifest(zip.getInputStream(manifestEntry)).mainAttributes - if (manifest == null - || manifest.getValue('Implementation-Version') != project.mod_version - || manifest.getValue('OreSpawn-API-Version') != '1' - || manifest.getValue('FMLAT') != 'accesstransformer.cfg' - || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate - || manifest.getValue('Implementation-Timestamp') != null) { - throw new GradleException('Release manifest is incorrect or volatile') - } - zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> - byte[] header = new byte[8] - zip.getInputStream(entry).withCloseable { input -> - if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") - } - int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) - if (major != 61) { - throw new GradleException("${entry.name} uses class major ${major}, expected 61") - } - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> - if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { - throw new GradleException('Sources jar is missing OreSpawn.java') - } - } - new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> - if (zip.getEntry('index.html') == null - || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { - throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') - } - } - } -} - -tasks.register('writeReleaseChecksums') { - group = 'verification' - dependsOn tasks.named('verifyReleaseArtifacts') - def outputFile = layout.buildDirectory.file('release/SHA256SUMS') - outputs.file(outputFile) - doLast { - File output = outputFile.get().asFile - output.parentFile.mkdirs() - File libs = layout.buildDirectory.dir('libs').get().asFile - String contents = expectedReleaseFiles.get().sort().collect { name -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - new File(libs, name).withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" - }.join('\n') + '\n' - output.setText(contents, 'UTF-8') - } -} - -tasks.register('verifyPreparedReleaseArtifacts') { - group = 'verification' - doLast { - if (!preparedReleaseDir.isPresent()) { - throw new GradleException('preparedReleaseDir is required') - } - File prepared = file(preparedReleaseDir.get()) - // Provider interpolation yields GString values; normalize them before - // comparing with real filesystem String names. - List expected = expectedReleaseFiles.get() - .collect { it.toString() }.sort() - List jars = (prepared.listFiles() ?: [] as File[]) - .findAll { it.name.endsWith('.jar') }.sort { it.name } - List actualNames = jars.collect { it.name.toString() }.sort() - if (actualNames != expected) { - throw new GradleException( - "Prepared release jars ${actualNames} do not match ${expected}") - } - if (jars.any { it.length() == 0L }) { - throw new GradleException('Prepared release contains an empty jar') - } - File checksums = new File(prepared, 'SHA256SUMS') - if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { - throw new GradleException('Prepared release is missing checksums or changelog') - } - List actual = jars.collect { candidate -> - MessageDigest digest = MessageDigest.getInstance('SHA-256') - candidate.withInputStream { input -> - byte[] buffer = new byte[8192] - for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { - if (read > 0) digest.update(buffer, 0, read) - } - } - "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" - }.sort() - if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { - throw new GradleException('Prepared release checksums do not match') - } - } -} - -def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') - .orElse('https://invalid.invalid/missing-maven-upload-url') -def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') -def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') -publishing { - publications { - mavenJava(MavenPublication) { - groupId = expectedMavenGroup - artifactId = expectedMavenArtifact - version = project.version.toString() - if (preparedReleaseDir.isPresent()) { - File prepared = file(preparedReleaseDir.get()) - artifact(new File(prepared, expectedReleaseFiles.get()[0])) - artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } - artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } - } else { - artifact(releaseJar) - artifact(tasks.named('sourcesJar')) - artifact(tasks.named('javadocJar')) - } - pom { - name = 'MMD OreSpawn' - description = project.mod_description - url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' - licenses { - license { - name = 'GNU Lesser General Public License, Version 2.1' - url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' - } - } - } - } - } - repositories { - maven { - name = 'release' - url = uri(mavenUploadUrl.get()) - credentials { - username = mavenUploadUsername.orNull ?: '' - password = mavenUploadPassword.orNull ?: '' - } - } - } -} -tasks.register('verifyMavenCoordinates') { - group = 'verification' - description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' - dependsOn tasks.named('generatePomFileForMavenJavaPublication') - doLast { - File pomFile = layout.buildDirectory.file( - 'publications/mavenJava/pom-default.xml').get().asFile - if (!pomFile.isFile()) { - throw new GradleException("Generated Maven POM does not exist: ${pomFile}") - } - def pom = new XmlSlurper(false, false).parse(pomFile) - def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] - def expected = [expectedMavenGroup, expectedMavenArtifact, - project.version.toString()] - if (project.group.toString() != expectedMavenGroup || actual != expected) { - throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + - "found ${actual.join(':')}") + // Eclipse keeps old files in bin/main, so clear only the generated + // guide folder when a guide is removed from the project. + project.sync { + from(layout.buildDirectory.dir('resources/main/META-INF/orespawn/docs')) + into(layout.projectDirectory.dir('bin/main/META-INF/orespawn/docs')) } } } -tasks.register('validateMavenReleaseCredentials') { - group = 'publishing' - doLast { - if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() - || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { - throw new GradleException( - 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') - } - if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { - throw new GradleException('Release publication must use a remote repository') - } - } -} -tasks.withType(PublishToMavenRepository).configureEach { - dependsOn tasks.named('validateMavenReleaseCredentials') - dependsOn tasks.named('verifyMavenCoordinates') - dependsOn preparedReleaseDir.isPresent() - ? tasks.named('verifyPreparedReleaseArtifacts') - : tasks.named('verifyReleaseArtifacts') -} -eclipse { - classpath { - downloadSources = true - downloadJavadoc = true - file.whenMerged { classpath -> - classpath.entries.removeAll { entry -> - entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder - && ['src/main/resources', 'src/generated/resources'].contains(entry.path) - } - if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { - classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( - 'build/resources/main', 'bin/main')) - } - } - } - synchronizationTasks 'isolateEclipseProductionRuns' -} -idea { - module { - downloadSources = true - downloadJavadoc = true - } -} -tasks.register('configureEclipseBuildship') { - group = 'ide' - doLast { - File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') - Properties preferences = new Properties() - [ - 'eclipse.preferences.version' : '1', - 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', - 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'connection.project.dir' : '', - 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, - 'override.workspace.settings' : 'true' - ].each { key, value -> preferences.setProperty(key, value) } - preferencesFile.parentFile.mkdirs() - preferencesFile.withOutputStream { - preferences.store(it, 'Generated by OreSpawn Buildship configuration.') - } - } -} -tasks.register('isolateEclipseProductionRuns') { - group = 'ide' - dependsOn tasks.named('genEclipseRuns') - dependsOn syncForge40EclipseLaunches - dependsOn tasks.named('configureEclipseBuildship') - dependsOn prepareEclipseResources - doLast { - [ - 'OreSpawn_Client.launch': 'GradleStart', - 'OreSpawn_Server.launch': 'GradleStartServer' - ].each { String name, String mainClass -> - File launch = file(name) - if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) - && !launch.delete()) { - throw new GradleException("Could not remove obsolete launch ${name}") - } - } - fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> - String contents = launch.getText('UTF-8') - contents = contents.replace( - 'key="MC_VERSION" value="${MC_VERSION}"', - "key=\"MC_VERSION\" value=\"${minecraft_version}\"") - launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') - } - } -} -tasks.register('verifyEclipseProductionClasspath') { - group = 'verification' - dependsOn tasks.named('eclipseClasspath') - dependsOn tasks.named('isolateEclipseProductionRuns') - dependsOn tasks.named('verifyLegacyOracleIsolation') - doLast { - File prefs = file('.settings/org.eclipse.buildship.core.prefs') - if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') - Properties buildshipPreferences = new Properties() - prefs.withInputStream { buildshipPreferences.load(it) } - String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath - if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome - || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome - || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { - throw new GradleException( - "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") - } - Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration - .resolvedArtifacts - .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } - .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } - .toSet() - if (!legacyLwjglArtifacts.isEmpty()) { - throw new GradleException( - "Forge 1.18 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") - } - File eclipseClasspath = file('.classpath') - if (!eclipseClasspath.isFile()) { - throw new GradleException('Eclipse .classpath was not generated') - } - String eclipseClasspathText = eclipseClasspath.getText('UTF-8') - if (!eclipseClasspathText.contains('path="build/resources/main"') - || eclipseClasspathText.contains('path="src/main/resources"') - || eclipseClasspathText.contains('path="src/generated/resources"')) { - throw new GradleException( - 'Eclipse must consume only Gradle-processed production resources') - } - [ - 'META-INF/mods.toml', - 'META-INF/orespawn/docs/README.md', - 'META-INF/orespawn/docs/VERSIONS.md' - ].each { relative -> - if (!new File('bin/main', relative).isFile()) { - throw new GradleException("Eclipse output is missing ${relative}") - } - } - File processedModMetadata = new File(sourceSets.main.output.resourcesDir, - 'META-INF/mods.toml') - File eclipseModMetadata = file('bin/main/META-INF/mods.toml') - if (!java.util.Arrays.equals(processedModMetadata.bytes, eclipseModMetadata.bytes) - || eclipseModMetadata.getText('UTF-8').contains('${version}') - || !eclipseModMetadata.getText('UTF-8').contains( - "version=\"${project.version}\"")) { - throw new GradleException( - 'Eclipse output contains unexpanded or stale Forge mod metadata') - } - List forbidden = [ - 'src/test', 'bin/test', 'build/classes/java/test', - 'biomeIntegrationTest', 'clientIntegrationTest', - 'surfaceprobe', 'clientprobe', 'junit-', 'opentest4j-', - 'Mineralogy-1.18.2-5.4.0.jar', 'C:\\Users\\John' - ] - String mainOutput = new File(projectDir, 'bin/main').absolutePath - String expectedModClasses = "${mod_id}%%${mainOutput}" - ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> - File launch = file(name) - if (!launch.isFile()) throw new GradleException("Missing ${name}") - String contents = launch.getText('UTF-8') - List leaked = forbidden.findAll { contents.contains(it) } - if (!leaked.isEmpty()) { - throw new GradleException("${name} exposes test/local content: ${leaked}") - } - if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') - || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { - throw new GradleException("${name} is not a production-only OreSpawn launch") - } - if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { - throw new GradleException("${name} lacks Forge 40 merged output discovery") - } - } - } -} - -tasks.register('verifyCommandPortability') { - group = 'verification' - description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' - doLast { - String gradleSource = file('build.gradle').getText('UTF-8') - List commandSources = [file('build.gradle')] - commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) - commandSources.each { File source -> - String text = source.getText('UTF-8') - if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { - throw new GradleException("Shell-specific command wrapper in ${source}") - } - } - if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') - || !gradleSource.contains('join(File.pathSeparator)') - || !gradleSource.contains('${File.pathSeparator}')) { - throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') - } - } -} - -tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } -tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } - -def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') -def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') -def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') - -def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> - if (!configuredPath.isPresent()) { - throw new GradleException("Pass -P${propertyName}=") - } - File runtime = file(configuredPath.get()) - if (!runtime.isDirectory()) { - throw new GradleException("${propertyName} does not name a directory: ${runtime}") - } - runtime -} - -def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") -tasks.register('preparePackagedSurfaceIntegration') { - dependsOn releaseJar - dependsOn packagedSurfaceProbeJar - doLast { - delete packagedSurfaceRunDirectory - packagedSurfaceRunDirectory.mkdirs() - copy { - from releaseJar - from packagedSurfaceProbeJar - into new File(packagedSurfaceRunDirectory, 'mods') - } - new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ -level-name=surface-integration-world -level-seed=zsjpxah -level-type=default -online-mode=false -server-port=0 -allow-nether=true -generate-structures=false -spawn-protection=0 -max-tick-time=-1 -''', 'UTF-8') - new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') - } -} -def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { - group = 'verification' - dependsOn tasks.named('preparePackagedSurfaceIntegration') - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, - 'packagedForgeServerRuntime') - File libraries = new File(runtime, 'libraries') - File forgeDirectory = new File(libraries, - 'net/minecraftforge/forge/1.18.2-40.3.0') - File launcher = new File(forgeDirectory, 'forge-1.18.2-40.3.0-server.jar') - File sourceArguments = new File(forgeDirectory, - System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') - ? 'win_args.txt' : 'unix_args.txt') - [launcher, sourceArguments, libraries].each { - if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") - } - File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') - String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' - absoluteArguments.setText(sourceArguments.getText('UTF-8') - .replace('libraries/', libraryPath) - .replace('-DlibraryDirectory=libraries', - "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') - workingDir packagedSurfaceRunDirectory - commandLine java17Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', - "@${absoluteArguments.absolutePath}", 'nogui' - } - doLast { - File marker = new File(packagedSurfaceRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') - assertRuntimeLogsClean(packagedSurfaceRunDirectory, - 'packaged surface fresh', [] as Set) - } -} -def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { - group = 'verification' - dependsOn packagedSurfaceFresh - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, - 'packagedForgeServerRuntime') - File libraries = new File(runtime, 'libraries') - File forgeDirectory = new File(libraries, - 'net/minecraftforge/forge/1.18.2-40.3.0') - File sourceArguments = new File(forgeDirectory, - System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') - ? 'win_args.txt' : 'unix_args.txt') - File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') - String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' - absoluteArguments.setText(sourceArguments.getText('UTF-8') - .replace('libraries/', libraryPath) - .replace('-DlibraryDirectory=libraries', - "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') - workingDir packagedSurfaceRunDirectory - commandLine java17Launcher.get().executablePath.asFile.absolutePath, - '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', - "@${absoluteArguments.absolutePath}", 'nogui' - } - doLast { - assertRuntimeLogsClean(packagedSurfaceRunDirectory, - 'packaged surface reload', [] as Set) - } -} -tasks.register('packagedSurfaceIntegrationTest') { - group = 'verification' - dependsOn packagedSurfaceReload - doLast { - File marker = new File(packagedSurfaceRunDirectory, - 'surface-integration-world/surfaceprobe-integration.properties') - Properties values = new Properties() - marker.withInputStream { values.load(it) } - if (values.getProperty('reload_verified') != 'true') { - throw new GradleException('Packaged surface reload was not verified') - } - } -} - -def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") -tasks.register('preparePackagedClientIntegration') { - dependsOn releaseJar - dependsOn packagedClientProbeJar - doLast { - delete packagedClientRunDirectory - packagedClientRunDirectory.mkdirs() - copy { - from releaseJar - from packagedClientProbeJar - into new File(packagedClientRunDirectory, 'mods') - } - new File(packagedClientRunDirectory, 'options.txt').setText( - 'fullscreen:false\nlang:en_us\n', 'UTF-8') - File configDirectory = new File(packagedClientRunDirectory, 'config') - configDirectory.mkdirs() - new File(configDirectory, 'orespawn-common.toml').setText('''\ -# Bootstrap fallbacks. Detailed settings live in orespawn-worldgen.json. -[worldgen] - # Master switch for configured terrain replacement. - place_terrain = true - # Allowed Values: GEOME, LEGACY - fallback_geology_mode = "GEOME" - # Range: 4 ~ 32767 - cyano_region_size = 256 - # Range: 1.0 ~ 32767.0 - cyano_layer_reach = 32.0 - # Range: 1 ~ 255 - cyano_layer_thickness = 8 -''', 'UTF-8') - } -} -def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { - group = 'verification' - dependsOn tasks.named('preparePackagedClientIntegration') - doFirst { - File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, - 'packagedForgeClientRuntime') - String forgeVersionId = ['1.18.2-forge-40.3.0', 'forge-40.3.0'].find { candidate -> - new File(runtime, "versions/${candidate}/${candidate}.json").isFile() - } - if (forgeVersionId == null) { - throw new GradleException('Official client runtime has no Forge 40.3.0 profile') - } - File forgeJsonFile = new File(runtime, - "versions/${forgeVersionId}/${forgeVersionId}.json") - File baseJsonFile = new File(runtime, 'versions/1.18.2/1.18.2.json') - File baseJar = new File(runtime, 'versions/1.18.2/1.18.2.jar') - File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") - [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), - new File(runtime, 'assets'), nativesDirectory].each { - if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") - } - - def slurper = new groovy.json.JsonSlurper() - Map forgeJson = (Map) slurper.parse(forgeJsonFile) - Map baseJson = (Map) slurper.parse(baseJsonFile) - Map classpathByModule = new LinkedHashMap<>() - [baseJson, forgeJson].each { Map metadata -> - ((List) metadata.libraries).each { Map library -> - List rules = (List) library.rules - boolean allowed = rules == null || rules.isEmpty() - if (rules != null) { - rules.each { Map rule -> - Map os = (Map) rule.os - boolean matches = os == null - || (os.name == 'windows' - && (os.arch == null || os.arch == System.getProperty('os.arch'))) - if (matches) allowed = rule.action == 'allow' - } - } - if (!allowed) return - String relative = (String) ((Map) ((Map) library.downloads).artifact).path - File artifact = new File(runtime, "libraries/${relative}") - if (!artifact.isFile()) { - throw new GradleException("Missing official client library: ${artifact}") - } - List coordinates = ((String) library.name).split(':') as List - String module = coordinates.size() >= 2 - ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name - classpathByModule.put(module, artifact) - } - } - List classpathFiles = new ArrayList<>(classpathByModule.values()) - classpathFiles.add(baseJar) - - String libraryDirectory = new File(runtime, 'libraries').absolutePath - List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) - .collect { String argument -> - argument.replace('${library_directory}', libraryDirectory) - .replace('${classpath_separator}', File.pathSeparator) - .replace('${version_name}', (String) forgeJson.inheritsFrom) - } - - List gameArguments = [] - gameArguments.addAll((List) ((Map) forgeJson.arguments).game) - gameArguments.addAll([ - '--username', 'OreSpawnValidation', - '--version', (String) forgeJson.id, - '--gameDir', packagedClientRunDirectory.absolutePath, - '--assetsDir', new File(runtime, 'assets').absolutePath, - '--assetIndex', (String) ((Map) baseJson.assetIndex).id, - '--uuid', '00000000-0000-0000-0000-000000000001', - '--accessToken', 'validation-token', - '--userType', 'legacy', - '--versionType', 'release', - '--width', '854', '--height', '480' - ]) - workingDir packagedClientRunDirectory - File clientJava = packagedClientJavaExecutable.isPresent() - ? file(packagedClientJavaExecutable.get()) - : java17Launcher.get().executablePath.asFile - if (!clientJava.isFile()) { - throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") - } - List clientCommand = [ - clientJava.absolutePath, - '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', - "-Djava.library.path=${nativesDirectory.absolutePath}", - '-Dminecraft.launcher.brand=orespawn-validation', - '-Dminecraft.launcher.version=1' - ] - clientCommand.addAll(forgeJvmArguments) - clientCommand.addAll([ - '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), - (String) forgeJson.mainClass - ]) - clientCommand.addAll(gameArguments) - commandLine(clientCommand) - } -} -tasks.register('packagedClientIntegrationTest') { - group = 'verification' - dependsOn packagedClientProcess - doLast { - File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') - if (!marker.isFile()) { - throw new GradleException('Packaged client completion marker is missing') - } - Properties values = new Properties() - marker.withInputStream { values.load(it) } - ['world_settings_opened', 'long_editor_roundtrip', - 'first_world_rendered', 'reload_rendered'].each { key -> - if (values.getProperty(key) != 'true') { - throw new GradleException("Packaged client failed ${key}: ${values}") - } - } - assertDocumentationTree(new File(packagedClientRunDirectory, - 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') - assertRuntimeLogsClean(packagedClientRunDirectory, - 'packaged client', [] as Set) - } -} +// Only these values cross script boundaries; each script owns its own tasks. +ext.oreSpawnBuild = [ + mavenGroup: expectedMavenGroup, + mavenArtifact: expectedMavenArtifact, + mavenCoordinate: expectedMavenCoordinate, + archiveTextSuffixes: archiveTextSuffixes, + archiveTextPatterns: archiveTextPatterns, + normalizeArchiveLineEndings: normalizeArchiveLineEndings, + java17Launcher: java17Launcher, + bundledDocumentationDirectory: bundledDocumentationDirectory, + prepareBundledDocumentation: prepareBundledDocumentation, + prepareEclipseResources: prepareEclipseResources +] -tasks.register('packagedRuntimeIntegrationTest') { - group = 'verification' - description = 'Runs the exact reobfuscated jars in official Forge 40 server and client runtimes.' - dependsOn tasks.named('packagedSurfaceIntegrationTest') - dependsOn tasks.named('packagedClientIntegrationTest') -} +apply from: 'gradle/verification/support.gradle' +apply from: 'gradle/verification/client-and-benchmark.gradle' +apply from: 'gradle/release/artifacts.gradle' +apply from: 'gradle/release/publishing.gradle' +apply from: 'gradle/ide/eclipse.gradle' +apply from: 'gradle/verification/packaged-forge.gradle' +apply from: 'gradle/verification/ore-sources.gradle' +apply from: 'gradle/verification/performance.gradle' +apply from: 'gradle/verification/biome-override.gradle' +apply from: 'gradle/verification/native-ores.gradle' diff --git a/docs/AGENTS.md b/docs/AGENTS.md deleted file mode 100644 index a62c49fb..00000000 --- a/docs/AGENTS.md +++ /dev/null @@ -1,17 +0,0 @@ -# OreSpawn Documentation Map - -This index is for navigating the documentation to learn how to integrate with -and use OreSpawn with a mod or modpack. Start with -[DEVELOPER_GUIDE.md](DEVELOPER_GUIDE.md). - -Use the focused guides for implementation details: - -- [API.md](API.md) for the supported Java API; -- [PROVIDERS.md](PROVIDERS.md) for packaged and configurable providers; -- [FEATURES.md](FEATURES.md) for rocks, ores, deposits, and geology; -- [BIOMES.md](BIOMES.md) and [DIMENSIONS.md](DIMENSIONS.md) for world integration; -- [TEMPLATES.md](TEMPLATES.md) for selectable world styles; -- [CONFIGURATION.md](CONFIGURATION.md) for configuration behavior; -- [VERSIONS.md](VERSIONS.md) for the shared four-component target-qualified - versioning, skipped functional releases, and branch-release convention; -- [README.md](README.md) for schemas, examples, and the complete documentation index. diff --git a/docs/API.md b/docs/API.md index 4690edb3..fc0bd513 100644 --- a/docs/API.md +++ b/docs/API.md @@ -45,8 +45,22 @@ coverage with `OreDefinition.Builder.dimensionSelector(...)` and `OreDimensionSelector.ALL_EXCEPT_NETHER_AND_END`. Explicit dimensions override that selector and prevent duplicate placement. -The builder emits provider schema 4. Legacy provider schemas 1-3 remain -readable. Schema 4 is required for biome palettes and dimension materials. +The builder emits provider schema 5. Legacy provider schemas 1-4 remain +readable. Schema 4 is required for biome palettes and dimension materials; +schema 5 adds explicit material and placement-channel IDs. API major remains 1. + +For custom ore patterns, `OreGenerationContext` extends the existing +`OrePlacementContext` without changing its binary contract. It exposes the +world seed, dimension, chunk coordinates, and an optional geology sampler. +The `tryPlace(x, y, z, outputIdentity)` overload gives separated slices of one +logical deposit the same output choice. Code built against the older context +continues to work through the original `tryPlace` method. + +Client add-ons may register one optional configuration screen per mod through +`WorldSettingsExtensionRegistry.registerConfigScreen(modId, factory)` during +client initialization. OreSpawn shows it in the loaded-mod directory and +returns Escape to that directory. Do not load this client-only API on a +dedicated server. Provider-owned fluid deposits are declarative and may target several dimensions: diff --git a/docs/BIOMES.md b/docs/BIOMES.md index 0a050dcc..32858636 100644 --- a/docs/BIOMES.md +++ b/docs/BIOMES.md @@ -165,10 +165,18 @@ then lexical template ID order. ## World-Creation Editor -**Biomes & World Materials** is visible even when rock strata are disabled. -It lists palettes and materials by dimension, uses installed-registry pickers, -and validates IDs before world creation. The editor is creation-only in 4.0.0; -existing worlds remain editable through their self-contained server profile. +**Biomes** is visible even when rock strata are disabled. Its directory shows +loaded and referenced biomes once, with provider placement rules and effective +palette order. It can replace one loaded source biome with another in newly +generated terrain. The exact replacement layer runs after ordinary palettes; +existing chunks are never rewritten. A missing target stays dormant until its +mod returns. Palette settings expose mode, scope, region size, coverage, +fallback weight, and namespace filters without reordering provider palettes. + +The materials button belongs to the selected dimension, not the selected +biome. Aquifer fluid, snow, and ice substitutions apply across every biome in +that dimension. The deep-aquifer field is available where the target generator +exposes that fluid separately. ## Performance Boundaries diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index e6276439..5d7c2306 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,13 +4,14 @@ OreSpawn uses three JSON contracts: | File | Schema | Purpose | |---|---:|---| -| `config/orespawn-worldgen.json` | 6 | Installed-pack defaults for new worlds | -| `/serverconfig/orespawn-worldgen.json` | 5 | Self-contained snapshot for one world | -| `config/-orespawn.json` | 4 | Optional authoritative provider override | +| `config/orespawn-worldgen.json` | 8 | Installed-pack defaults for new worlds | +| `/serverconfig/orespawn-worldgen.json` | 7 | Self-contained snapshot for one world | +| `config/-orespawn.json` | 5 | Optional authoritative provider override | -A provider may package schema 4 at `data//orespawn/provider.json`. -Legacy provider schemas 1-3 remain accepted. Fluid deposits require schema 3; -biome palettes and dimension materials require schema 4. +A provider may package schema 5 at `data//orespawn/provider.json`. +Legacy provider schemas 1-4 remain accepted. Fluid deposits require schema 3; +biome palettes and dimension materials require schema 4. Explicit ore material +and placement-channel IDs require schema 5. The profile for a new world is merged in this order: passive OreSpawn defaults, packaged or API providers, provider override files, the global configuration, @@ -29,7 +30,7 @@ report for the exact decision. | Field | Values | Meaning | |---|---|---| -| `schema_version` | Contract-specific integer | Global 6, world 5, provider 4 | +| `schema_version` | Contract-specific integer | Global 8, world 7, provider 5 | | `geology_mode` | `geome`, `legacy` | Sky/geome engine or Cyano legacy engine | | `place_fluid_deposits` | boolean | Master switch for configured fluid-deposit rules | | `manage_vanilla_ores` | boolean | Lets OreSpawn suppress and replace claimed vanilla ore features | @@ -44,6 +45,8 @@ report for the exact decision. | `biome_palettes` | object keyed by provider-owned rule ID | Optional native-biome overlays and surfaces | | `dimension_materials` | object keyed by provider-owned rule ID | Aquifer fluid, snow, and ice substitutions | | `ores` | object keyed by rule ID | Ore outputs and per-dimension placement | +| `ore_material_groups` | object keyed by material ID | Friendly names and exact block-tag aliases; imported Ore Dictionary names stay dormant | +| `ore_source_policies` | object keyed by material and dimension | Saved output mode, weights, and placement source per channel | | `fluid_deposits` | object keyed by rule ID | Provider-owned fluids and per-dimension placement | | `retrogen` | object | Bounded ore retrogen controls | | `flat_bedrock` | object | Opt-in flat bedrock controls | @@ -120,6 +123,8 @@ Geomes contain a non-negative `base` weight and non-negative weights for each rock family. Keys may retain the legacy unnamespaced form or use a provider resource ID such as `examplemod:crystal_basin`; the creation editor preserves both forms. Biome and biome-dictionary maps multiply those geome weights. +New IDs must use lowercase resource-ID characters; the editor trims spaces +but rejects invalid IDs rather than renaming them. Missing optional-mod biome IDs are ignored during baking. Terrain dimensions require `enabled`, `host_blocks`, and `host_tags`. @@ -174,6 +179,7 @@ Each enabled ore dimension uses: | `node_size` | 1-32 | Cluster node size | | `length` | 1-64 | Pattern path length where supported | | `fluid` | registry ID | Fluid used by `underfluids` | +| `placement_channel` | registry ID | Independent placement budget shared by equivalent ores in consolidated modes | At least one of `host_families`, `host_blocks`, or `host_tags` must be present. Hosts may be plain registry IDs or weighted objects such as @@ -191,6 +197,30 @@ The selector `orespawn:all_except_nether_end` covers every dimension except the vanilla Nether and End. Explicit rules in `dimensions` override selector rules for the same ore and dimension, including explicit disabled rules. +### Material groups and source policies + +Provider schema 5 may add a `material` ID to an ore. Otherwise OreSpawn infers +the material only from exact loaded block tags under `*/ores/*`; it does not +guess from a block or mod name. `ore_material_groups` stores a friendly name +and `block_tag_entries` for each group. Imported `ore_dictionary_entries` +remain dormant unless a user gives the group an exact tag mapping. + +`ore_source_policies` stores one material-and-dimension decision: Keep Original +(`keep_separate`) or consolidated Balanced, Single, or Custom output selection. +The `placement_sources` object names one source rule for each placement +channel. Consolidation uses that rule's frequency, shape, depth, and hosts, +then picks the selected output for a whole deposit. It does not give every +output its own placement budget. External generators are shown for context but +cannot be suppressed by OreSpawn. + +For a newly created world, two loaded ordinary MMD providers with the same +exact ore tag start Balanced. A configured Sulfur or Lithium priority chooses +the initial placement source; other conflicts use stable owner/rule-ID order. +Missing providers create no phantom ores. Existing worlds retain their saved +policy, defaulting to Keep Original on upgrade. Both global and world profile +upgrades keep a backup; editing the global defaults in the UI is saved only +when the main OreSpawn editor's Done is pressed. + ## Fluid Deposits, Retrogen, And Bedrock Each `fluid_deposits` entry has a stable provider-namespaced rule ID, an diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index fe29da33..d247960b 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -12,6 +12,8 @@ | Add covered underground oil or another fluid | Provider schema 3 fluid deposit | | Add or place biomes without a framework dependency | Provider schema 4 biome palette | | Replace surfaces, aquifers, snow, or ice | Provider schema 4 dimension materials | +| Share placement between equivalent ore blocks | Provider schema 5 material and placement channel, with one exact block tag | +| Add an OreSpawn-linked settings screen | Client-only `WorldSettingsExtensionRegistry` | | Inspect active geology at runtime | `GeologyProfileView` and `GeologySampler` | Strata are optional. If no enabled terrain dimension has eligible rocks, @@ -21,7 +23,7 @@ blocks or tags. ## Provider JSON Quick Start -Put a schema-4 file in your mod jar at: +Put a schema-5 file in your mod jar at: ```text src/main/resources/data/examplemod/orespawn/provider.json @@ -33,7 +35,7 @@ stone without enabling strata: ```json { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "ores": { diff --git a/docs/PLAYER_GUIDE.md b/docs/PLAYER_GUIDE.md index 08a587f4..5f4466c8 100644 --- a/docs/PLAYER_GUIDE.md +++ b/docs/PLAYER_GUIDE.md @@ -25,8 +25,10 @@ player deliberately adds them. You can remove the starter rocks or add blocks from installed mods before creating the world. Mods can also offer new biomes and world materials without enabling strata. -Use **Biomes & World Materials** to inspect installed dimension palettes, -surface blocks, aquifer fluids, snow, and ice. The picker only accepts real +Use **Biomes** to inspect loaded biomes, placement palettes, and surface rules. +Replacing a biome changes only newly generated terrain. The dimension's +**Materials** button changes aquifer fluid, snow, or ice across every biome in +that dimension, not only the biome currently selected. The picker only accepts installed registry entries. Missing optional compatibility biomes are skipped safely instead of breaking world creation. @@ -48,11 +50,13 @@ safely instead of breaking world creation. installed mod. These are covered underground deposits, not exposed vanilla lakes. **Solid Cover** controls the roof thickness, while **Solid Shell** prevents a deposit from opening into a cave at its sides or underside. -- **Biomes & World Materials** controls broad biome regions and what their - surfaces, underground water, snow, and ice are made from. **Augment** mixes - new biomes into the existing source; **Replace** creates a complete provider - style. Namespace scope protects other biome mods unless a pack deliberately - opts them in. +- **Biomes** lists installed and referenced biomes and their placement rules. + **Replace in new terrain with...** is an exact, one-to-one choice and does + not rewrite existing chunks. Palette settings still offer **Augment** for a + provider mix or **Replace** for a complete provider style. +- **Ore Sources** groups interchangeable ores by exact block tags. Balanced + shares one placement budget across eligible outputs; Single picks one output; + Custom lets you choose weights. Keep Original leaves every rule independent. **World Materials** applies across an entire dimension. **Aquifer Fluid** changes the normal below-sea-level fluid, while **Deep Aquifer** can use a diff --git a/docs/PROVIDERS.md b/docs/PROVIDERS.md index e174b90d..ba50c5aa 100644 --- a/docs/PROVIDERS.md +++ b/docs/PROVIDERS.md @@ -6,12 +6,20 @@ Provider mods may contribute through Forge IMC, a packaged resource at present malformed override leaves that provider inactive instead of silently falling back. -Provider schema 4 supports `profile_defaults`, `rocks`, `ores`, +Provider schema 5 supports `profile_defaults`, `rocks`, `ores`, `fluid_deposits`, `geomes`, `biome_rules`, `terrain_dimensions`, and `templates`, plus `biome_palettes` and `dimension_materials`. Each file requires a matching `provider_modid`, a positive `provider_revision`, and at least one -contribution. Legacy schemas 1-3 remain accepted; schema 3 introduced fluid -deposits and schema 4 introduces biome and world-material controls. +contribution. Legacy schemas 1-4 remain accepted; schema 3 introduced fluid +deposits, schema 4 added biome and world-material controls, and schema 5 adds +optional `material` and `placement_channel` IDs for exact ore grouping. + +Use the same exact loaded block tag, such as `forge:ores/sulfur`, for ores that +are interchangeable. OreSpawn never infers equivalence from similar block or +mod names. When two ordinary MMD providers for the same tag are loaded, a new +world starts Balanced with one placement budget per channel. An absent mod +adds no phantom output or placement rule, and old worlds keep their saved +choices. Enrichment and dimension-specific providers are kept separate. An ore-only provider does not need rocks, geomes, or terrain dimensions. Give each ore explicit host blocks or tags and OreSpawn will leave vanilla terrain, diff --git a/docs/README.md b/docs/README.md index 9fe8ad40..fa822339 100644 --- a/docs/README.md +++ b/docs/README.md @@ -6,6 +6,12 @@ shaded or embeddable engine artifact. Choose the guide that matches what you are doing: +If you are integrating a mod, start with the developer guide; the API and +provider guides give the exact Java and JSON contracts. + +The focused guides below cover the supported integration paths. Use the +versioning guide when choosing a matching Minecraft and loader build. + - [Player and server guide](PLAYER_GUIDE.md) - [Developer quick start and complete integration map](DEVELOPER_GUIDE.md) - [Configuration field reference](CONFIGURATION.md) @@ -18,7 +24,6 @@ Choose the guide that matches what you are doing: - [Migration](MIGRATION.md) - [Troubleshooting](TROUBLESHOOTING.md) - [Versioning and release conventions](VERSIONS.md) -- [Compact instructions for coding agents](AGENTS.md) Validated examples are in `examples/`; JSON Schemas are in `schemas/`. The provider and migration guides include OS3-compatible ranged quantities and diff --git a/docs/VERSIONS.md b/docs/VERSIONS.md index 57854b60..14d6dfce 100644 --- a/docs/VERSIONS.md +++ b/docs/VERSIONS.md @@ -55,8 +55,8 @@ Examples: | 1.14.4 | Forge | `114041` | `4.0.8.114041` | | 1.15.2 | Forge | `115021` | `4.0.9.115021` | | 1.16.5 | Forge | `116051` | `4.0.9.116051` | -| 1.17.1 | Forge | `117011` | `4.0.9.117011` | -| 1.18.2 | Forge | `118021` | `4.0.16.118021` | +| 1.17.1 | Forge | `117011` | `4.1.0.117011` | +| 1.18.2 | Forge | `118021` | `4.1.0.118021` | | 1.20.6 | Forge | `120061` | `4.0.6.120061` | | 1.21.11 | Forge | `121111` | `4.0.6.121111` | | 26.1.2 | Forge | `2601021` | `4.0.6.2601021` | @@ -164,6 +164,11 @@ boundaries, and then to `4.0.16.118021` so GameTest benchmark runs leave shutdown and result reporting to the test harness. A branch may therefore legitimately skip functional version numbers. +The 1.17.1 and 1.18.2 lines now advance to 4.1.0 for material-group ore +policies, the loaded-mod directory, and exact new-terrain biome replacements. +The 1.18.2 build keeps its `118021` target suffix, API major 1, and existing +registry identities while provider/global/world schemas advance to 5/8/7. + This provides three useful guarantees: 1. A functional version is not used to describe two unrelated change sets. diff --git a/docs/examples/examplemod-orespawn.json b/docs/examples/examplemod-orespawn.json index 1c8279df..ad07ab5c 100644 --- a/docs/examples/examplemod-orespawn.json +++ b/docs/examples/examplemod-orespawn.json @@ -1,5 +1,5 @@ { - "schema_version": 4, + "schema_version": 5, "provider_modid": "examplemod", "provider_revision": 1, "rocks": { diff --git a/docs/examples/orespawn-global.json b/docs/examples/orespawn-global.json index cabcfee1..6710b185 100644 --- a/docs/examples/orespawn-global.json +++ b/docs/examples/orespawn-global.json @@ -1,5 +1,5 @@ { - "schema_version": 6, + "schema_version": 8, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -32,6 +32,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/examples/orespawn-world.json b/docs/examples/orespawn-world.json index 07351678..f379edfb 100644 --- a/docs/examples/orespawn-world.json +++ b/docs/examples/orespawn-world.json @@ -1,5 +1,5 @@ { - "schema_version": 5, + "schema_version": 7, "geology_mode": "geome", "place_fluid_deposits": true, "manage_vanilla_ores": false, @@ -70,6 +70,8 @@ "biome_palettes": {}, "dimension_materials": {}, "ores": {}, + "ore_material_groups": {}, + "ore_source_policies": {}, "fluid_deposits": {}, "retrogen": { "enabled": false, diff --git a/docs/schemas/orespawn-global.schema.json b/docs/schemas/orespawn-global.schema.json index e20e5774..49022e13 100644 --- a/docs/schemas/orespawn-global.schema.json +++ b/docs/schemas/orespawn-global.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 6 } } + "properties": { "schema_version": { "const": 8 } } } ] } diff --git a/docs/schemas/orespawn-provider.schema.json b/docs/schemas/orespawn-provider.schema.json index 83f67f7b..1416edfa 100644 --- a/docs/schemas/orespawn-provider.schema.json +++ b/docs/schemas/orespawn-provider.schema.json @@ -4,7 +4,7 @@ "type": "object", "required": ["schema_version", "provider_modid", "provider_revision"], "properties": { - "schema_version": { "enum": [1, 2, 3, 4] }, + "schema_version": { "enum": [1, 2, 3, 4, 5] }, "provider_modid": { "type": "string", "pattern": "^[a-z][a-z0-9_.-]+$" }, "provider_revision": { "type": "integer", "minimum": 1 }, "rocks": { "type": "object", "additionalProperties": { "$ref": "profile-common.schema.json#/$defs/rock" } }, diff --git a/docs/schemas/orespawn-world.schema.json b/docs/schemas/orespawn-world.schema.json index f23b7970..497cc4a0 100644 --- a/docs/schemas/orespawn-world.schema.json +++ b/docs/schemas/orespawn-world.schema.json @@ -6,7 +6,7 @@ { "type": "object", "required": ["schema_version"], - "properties": { "schema_version": { "const": 5 } } + "properties": { "schema_version": { "const": 7 } } } ] } diff --git a/docs/schemas/profile-common.schema.json b/docs/schemas/profile-common.schema.json index 6d99c56c..c76a11e9 100644 --- a/docs/schemas/profile-common.schema.json +++ b/docs/schemas/profile-common.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://mcmoddev.com/orespawn/schemas/profile-common.schema.json", "type": "object", - "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "fluid_deposits"], + "required": ["formations", "rocks", "geomes", "biomes", "terrain_dimensions", "biome_palettes", "dimension_materials", "ores", "ore_material_groups", "ore_source_policies", "fluid_deposits"], "properties": { "geology_mode": { "enum": ["geome", "legacy"] }, "place_fluid_deposits": { "type": "boolean" }, @@ -19,6 +19,8 @@ "biome_palettes": { "type": "object", "additionalProperties": { "$ref": "#/$defs/biomePalette" } }, "dimension_materials": { "type": "object", "additionalProperties": { "$ref": "#/$defs/dimensionMaterials" } }, "ores": { "type": "object", "additionalProperties": { "$ref": "#/$defs/ore" } }, + "ore_material_groups": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "$ref": "#/$defs/oreMaterialGroup" } }, + "ore_source_policies": { "type": "object", "additionalProperties": { "$ref": "#/$defs/oreSourcePolicy" } }, "fluid_deposits": { "type": "object", "additionalProperties": { "$ref": "#/$defs/fluidDeposit" } }, "worldgen_aliases": { "type": "object", "additionalProperties": { "$ref": "#/$defs/registryId" } }, "providers": { "type": "object" }, @@ -145,6 +147,7 @@ "minimum": 0.0, "maximum": 1.0 }, + "placement_channel": { "$ref": "#/$defs/registryId" }, "spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "vertical_spread": { "type": "integer", "minimum": 0, "maximum": 64 }, "node_size": { "type": "integer", "minimum": 1, "maximum": 32 }, @@ -181,6 +184,7 @@ "required": ["enabled"], "properties": { "block": { "$ref": "#/$defs/registryId" }, + "material": { "$ref": "#/$defs/registryId" }, "enabled": { "type": "boolean" }, "source_mod": { "type": "string" }, "native_generation": { "type": "boolean" }, @@ -217,6 +221,56 @@ ], "additionalProperties": true }, + "oreSourceCandidate": { + "type": "object", + "required": ["source_id", "owner", "registry_id", "material", "domain", "placement_channel", "loaded", "external"], + "properties": { + "source_id": { "type": "string", "minLength": 1 }, + "owner": { "type": "string", "minLength": 1 }, + "owner_name": { "type": "string" }, + "owner_version": { "type": "string" }, + "registry_id": { "$ref": "#/$defs/registryId" }, + "metadata": { "type": "integer", "minimum": 0 }, + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "placement_channel": { "$ref": "#/$defs/registryId" }, + "block_tags": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "loaded": { "type": "boolean" }, + "placement_active": { "type": "boolean" }, + "external": { "type": "boolean" }, + "enrichment": { "type": "boolean" }, + "review_required": { "type": "boolean" }, + "material_declared": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreSourcePolicy": { + "type": "object", + "required": ["material", "domain", "mode", "output_mode", "outputs", "placement_sources", "candidates"], + "properties": { + "material": { "$ref": "#/$defs/registryId" }, + "domain": { "$ref": "#/$defs/registryId" }, + "mode": { "enum": ["consolidated", "keep_separate"] }, + "output_mode": { "enum": ["balanced", "single", "custom"] }, + "outputs": { "type": "object", "additionalProperties": { "type": "number", "exclusiveMinimum": 0 } }, + "placement_sources": { "type": "object", "propertyNames": { "pattern": "^[a-z0-9_.-]+:[a-z0-9_./-]+$" }, "additionalProperties": { "type": "string", "minLength": 1 } }, + "candidates": { "type": "array", "items": { "$ref": "#/$defs/oreSourceCandidate" } }, + "review_required": { "type": "boolean" }, + "status": { "enum": ["consolidated", "separate", "review_required", "missing_source", "external_generation"] }, + "dormant": { "type": "boolean" } + }, + "additionalProperties": false + }, + "oreMaterialGroup": { + "type": "object", + "required": ["display_name", "block_tag_entries"], + "properties": { + "display_name": { "type": "string", "minLength": 1, "maxLength": 64 }, + "block_tag_entries": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/registryId" } }, + "ore_dictionary_entries": { "type": "array", "uniqueItems": true, "items": { "type": "string", "minLength": 1 } } + }, + "additionalProperties": false + }, "fluidDepositDimension": { "type": "object", "required": ["enabled", "min_y", "max_y", "frequency", "min_radius", "max_radius", "min_vertical_radius", "max_vertical_radius", "max_lobes", "min_solid_cover"], diff --git a/gradle.properties b/gradle.properties index eb7a345a..57a07b55 100644 --- a/gradle.properties +++ b/gradle.properties @@ -19,7 +19,7 @@ mcp_version=20220404.173914 mod_id=orespawn mod_name=MMD OreSpawn mod_license=LGPL-2.1 -mod_version=4.0.16.118021 +mod_version=4.1.0.118021 mod_group=zone.moddev.mc.orespawn mod_authors=SkyBlade1978, dshadowwolf, the MMD Team mod_description=Configurable, provider-driven terrain, ore, and deposit generation. diff --git a/gradle/README.md b/gradle/README.md new file mode 100644 index 00000000..33c420e8 --- /dev/null +++ b/gradle/README.md @@ -0,0 +1,47 @@ +# Build scripts + +`build.gradle` sets the Forge 1.18.2 target, Java toolchains, dependencies, +development runs, and resource processing. The longer checks live in these +scripts so each task has an obvious home. The root applies them in dependency +order. + +| Script | What it owns | +| --- | --- | +| `verification/support.gradle` | Fixture paths, Java launch helpers, and runtime-log checks. | +| `verification/client-and-benchmark.gradle` | Isolated development-client and surface-generation probes. | +| `verification/ore-sources.gradle` | Test-only future MMD providers, manual client profiles and missing-provider reloads. | +| `verification/native-ores.gradle` | Managed quartz, tag-only fluids and exact-save/new-terrain checks. | +| `verification/performance.gradle` | Matched generation timing and unchanged-world comparisons. | +| `verification/biome-override.gradle` | Exact replacement and no-retrogen checks in a disposable save. | +| `verification/packaged-forge.gradle` | Finished-jar checks in disposable official Forge installations. | +| `release/artifacts.gradle` | Release jars, bundled guides, artifact audits, and checksums. | +| `release/publishing.gradle` | Maven coordinates, credentials, and opt-in publication. | +| `ide/eclipse.gradle` | Generated launches, Buildship setup, and production-classpath isolation. | + +The ordinary build does not publish anything. Run `check`, `build`, `javadoc`, +`verifyReleaseArtifacts`, and `writeReleaseChecksums` before sharing a +candidate. After changing resources or launch setup, also run +`genEclipseRuns verifyEclipseProductionClasspath`. + +Packaged checks use `packagedForgeServerRuntime` and +`packagedForgeClientRuntime` properties pointing to disposable official Forge +installations. They are separate from ordinary `check`; the server command +ends in literal `nogui`. + +Run Gradle with Java 17 and use the pinned Java 17 compilation toolchain. A +warm-cache offline build is useful for day-to-day work, but a fresh-cache +bootstrap and offline replay are separate release checks. Test-only fixture +mods must never enter an ordinary Eclipse launch, production classpath, or +release jar. + +`runOreSourcesClient.launch` and `runTestOreSourcesClient.launch` open the +same separate manual profile with simulated Base Minerals and Electric +Advantage providers. Generate them with `genEclipseRuns`, then run +`verifyOreSourcesClientLaunch`. Preparing this profile keeps its saved worlds +and settings. `oreSourcesClientIntegrationTest` exercises the launch in a +build-owned disposable directory instead. + +`nativeOreIntegrationTest` checks Nether quartz with the shipped host tag +and explicit block hosts, including reload and new-terrain-only changes. +It uses `packagedForgeServerRuntime`; an optional `nativeOreSpawnJar` supplies +an older jar when reproducing a regression. Neither fixture ships in OreSpawn. diff --git a/gradle/ide/eclipse.gradle b/gradle/ide/eclipse.gradle new file mode 100644 index 00000000..42a938d7 --- /dev/null +++ b/gradle/ide/eclipse.gradle @@ -0,0 +1,429 @@ +// Ordinary Eclipse launches use production classes and processed resources. +def shared = project.ext.oreSpawnBuild +def prepareEclipseResources = shared.prepareEclipseResources +def surfaceIntegrationClasses = shared.surfaceIntegrationClasses +def surfaceIntegrationRunDirectory = shared.surfaceIntegrationRunDirectory +task syncForge40EclipseLaunches(dependsOn: compileSurfaceIntegrationTestMod) { + group = 'ide' + dependsOn 'prepareOreSourcesClient' + doLast { + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String ordinaryModClasses = "${mod_id}%%${mainOutput}" + String fixtureOutput = surfaceIntegrationClasses.absolutePath + String fixtureResources = new File(projectDir, 'src/biomeIntegrationTest/resources').absolutePath + String fixtureModClasses = "${mod_id}%%${mainOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureOutput}${File.pathSeparator}" + + "surfaceprobe%%${fixtureResources}" + ['Client', 'Server', 'Data'].each { String runName -> + File launch = file("run${runName}.launch") + if (!launch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${launch}") + } + String text = launch.getText('UTF-8') + if (text.contains('')) { + text = text.replace( + '', + '\r\n' + + " \r\n" + + '') + } + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + if (!text.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + text = text.replace('', + ' \r\n' + + '') + } + launch.setText(text, 'UTF-8') + } + ['Fresh', 'Reload'].each { String phase -> + File launch = file("runSurfaceIntegration${phase}.launch") + File serverLaunch = file('runServer.launch') + if (!serverLaunch.isFile()) { + throw new GradleException("Missing generated Eclipse launch: ${serverLaunch}") + } + String text = serverLaunch.getText('UTF-8') + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + text = text.replaceFirst( + //, + { String match, String arguments -> + "" + }) + text = text.replaceFirst( + //, + java.util.regex.Matcher.quoteReplacement( + "")) + launch.setText(text, 'UTF-8') + } + // Inherit Forge 40's module path, assets, mappings and JRE. + def parser = new groovy.xml.XmlParser(false, false) + parser.setTrimWhitespace(true) + def manual = parser.parse(file('runClient.launch')) + manual.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value = file('run-ore-sources').absolutePath + manual.mapAttribute.find { + it.@key == 'org.eclipse.debug.core.environmentVariables' + }.mapEntry.find { it.@key == 'MOD_CLASSES' }.@value = + shared.oreSourcesClientModClasses(file('bin/main')) + def classpath = manual.listAttribute.find { + it.@key == 'org.eclipse.jdt.launching.CLASSPATH' + } + shared.oreSourceFixtureOutputs.values().each { File output -> + def entry = new Node(null, 'runtimeClasspathEntry', [ + externalArchive: output.absolutePath, type: '2', path: '5']) + classpath.appendNode('listEntry', [value: groovy.xml.XmlUtil.serialize(entry)]) + } + String manualText = groovy.xml.XmlUtil.serialize(manual).replace('\r\n', '\n') + file('runOreSourcesClient.launch').setText(manualText, 'UTF-8') + file('runTestOreSourcesClient.launch').setText(manualText, 'UTF-8') + } +} + +task verifyForge40EclipseLaunchIsolation { + group = 'verification' + doLast { + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { String launchName -> + File launch = file(launchName) + String launchText = launch.getText('UTF-8') + if (launchText.contains('Mineralogy-') || launchText.contains('biomeIntegrationTest') || + !launchText.contains('org.eclipse.jdt.launching.ATTR_EXCLUDE_TEST_CODE')) { + throw new GradleException("Ordinary Eclipse launch is not isolated from test oracles: ${launch}") + } + } + } +} + +syncForge40EclipseLaunches.finalizedBy verifyForge40EclipseLaunchIsolation + +tasks.matching { it.name == 'genEclipseRuns' }.all { + finalizedBy syncForge40EclipseLaunches +} + +eclipse { + classpath { + downloadSources = true + downloadJavadoc = true + file.whenMerged { classpath -> + classpath.entries.removeAll { entry -> + entry instanceof org.gradle.plugins.ide.eclipse.model.SourceFolder + && ['src/main/resources', 'src/generated/resources'].contains(entry.path) + } + if (!classpath.entries.any { entry -> entry.path == 'build/resources/main' }) { + classpath.entries.add(new org.gradle.plugins.ide.eclipse.model.SourceFolder( + 'build/resources/main', 'bin/main')) + } + } + } + synchronizationTasks 'isolateEclipseProductionRuns' +} +idea { + module { + downloadSources = true + downloadJavadoc = true + } +} +tasks.register('configureEclipseBuildship') { + group = 'ide' + doLast { + File preferencesFile = file('.settings/org.eclipse.buildship.core.prefs') + Properties preferences = new Properties() + [ + 'eclipse.preferences.version' : '1', + 'connection.gradle.distribution': 'GRADLE_DISTRIBUTION(WRAPPER)', + 'connection.gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'connection.project.dir' : '', + 'gradle.user.home' : gradle.gradleUserHomeDir.canonicalPath, + 'override.workspace.settings' : 'true' + ].each { key, value -> preferences.setProperty(key, value) } + preferencesFile.parentFile.mkdirs() + preferencesFile.withOutputStream { + preferences.store(it, 'Generated by OreSpawn Buildship configuration.') + } + } +} +tasks.register('isolateEclipseProductionRuns') { + group = 'ide' + dependsOn tasks.named('genEclipseRuns') + dependsOn syncForge40EclipseLaunches + dependsOn tasks.named('configureEclipseBuildship') + dependsOn prepareEclipseResources + doLast { + [ + 'OreSpawn_Client.launch': 'GradleStart', + 'OreSpawn_Server.launch': 'GradleStartServer' + ].each { String name, String mainClass -> + File launch = file(name) + if (launch.isFile() && launch.getText('UTF-8').contains(mainClass) + && !launch.delete()) { + throw new GradleException("Could not remove obsolete launch ${name}") + } + } + fileTree(project.projectDir) { include 'run*.launch' }.files.each { launch -> + String contents = launch.getText('UTF-8') + contents = contents.replace( + 'key="MC_VERSION" value="${MC_VERSION}"', + "key=\"MC_VERSION\" value=\"${minecraft_version}\"") + launch.setText(contents.replace('\r\n', '\n'), 'UTF-8') + } + } +} +tasks.register('verifyEclipseProductionClasspath') { + group = 'verification' + dependsOn tasks.named('eclipseClasspath') + dependsOn tasks.named('eclipseProject') + dependsOn tasks.named('isolateEclipseProductionRuns') + dependsOn tasks.named('verifyLegacyOracleIsolation') + doLast { + File prefs = file('.settings/org.eclipse.buildship.core.prefs') + if (!prefs.isFile()) throw new GradleException('Missing Buildship preferences') + Properties buildshipPreferences = new Properties() + prefs.withInputStream { buildshipPreferences.load(it) } + String expectedGradleHome = gradle.gradleUserHomeDir.canonicalPath + if (buildshipPreferences.getProperty('connection.gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('gradle.user.home') != expectedGradleHome + || buildshipPreferences.getProperty('override.workspace.settings') != 'true') { + throw new GradleException( + "Eclipse Buildship must use the validated Gradle home ${expectedGradleHome}") + } + Set legacyLwjglArtifacts = configurations.compileClasspath.resolvedConfiguration + .resolvedArtifacts + .findAll { it.moduleVersion.id.group == 'org.lwjgl.lwjgl' } + .collect { "${it.moduleVersion.id.group}:${it.name}:${it.moduleVersion.id.version}" } + .toSet() + if (!legacyLwjglArtifacts.isEmpty()) { + throw new GradleException( + "Forge 1.18 Eclipse classpath contains legacy LWJGL 2 artifacts: ${legacyLwjglArtifacts}") + } + File eclipseClasspath = file('.classpath') + if (!eclipseClasspath.isFile()) { + throw new GradleException('Eclipse .classpath was not generated') + } + String eclipseClasspathText = eclipseClasspath.getText('UTF-8') + if (!eclipseClasspathText.contains('path="build/resources/main"') + || eclipseClasspathText.contains('path="src/main/resources"') + || eclipseClasspathText.contains('path="src/generated/resources"')) { + throw new GradleException( + 'Eclipse must consume only Gradle-processed production resources') + } + [ + 'META-INF/mods.toml', + 'META-INF/orespawn/docs/README.md', + 'META-INF/orespawn/docs/VERSIONS.md' + ].each { relative -> + if (!new File('bin/main', relative).isFile()) { + throw new GradleException("Eclipse output is missing ${relative}") + } + } + File processedModMetadata = new File(sourceSets.main.output.resourcesDir, + 'META-INF/mods.toml') + File eclipseModMetadata = file('bin/main/META-INF/mods.toml') + if (!java.util.Arrays.equals(processedModMetadata.bytes, eclipseModMetadata.bytes) + || eclipseModMetadata.getText('UTF-8').contains('${version}') + || !eclipseModMetadata.getText('UTF-8').contains( + "version=\"${project.version}\"")) { + throw new GradleException( + 'Eclipse output contains unexpanded or stale Forge mod metadata') + } + List forbidden = [ + 'src/test', 'bin/test', 'build/classes/java/test', + 'biomeIntegrationTest', 'clientIntegrationTest', + 'oreSourceMineralsTest', 'oreSourceAdvantageTest', 'worldHashTest', + 'nativeOreIntegrationTest', 'nativeoreprobe', 'native-ore-fixture', + 'worldhashprobe', 'oresource-minerals', 'oresource-advantage', + 'surfaceprobe', 'clientprobe', 'junit-', 'opentest4j-', + 'Mineralogy-1.18.2-5.4.0.jar', 'C:\\Users\\John' + ] + String mainOutput = new File(projectDir, 'bin/main').absolutePath + String expectedModClasses = "${mod_id}%%${mainOutput}" + ['runClient.launch', 'runServer.launch', 'runData.launch'].each { name -> + File launch = file(name) + if (!launch.isFile()) throw new GradleException("Missing ${name}") + String contents = launch.getText('UTF-8') + List leaked = forbidden.findAll { contents.contains(it) } + if (!leaked.isEmpty()) { + throw new GradleException("${name} exposes test/local content: ${leaked}") + } + if (!contents.contains('ATTR_EXCLUDE_TEST_CODE') + || !contents.contains('PROJECT_ATTR" value="OreSpawn"')) { + throw new GradleException("${name} is not a production-only OreSpawn launch") + } + if (!contents.contains("MOD_CLASSES\" value=\"${expectedModClasses}\"")) { + throw new GradleException("${name} lacks Forge 40 merged output discovery") + } + } + } +} + +tasks.register('verifyCommandPortability') { + group = 'verification' + description = 'Rejects shell-specific launch wrappers and hard-coded classpath separators.' + doLast { + String gradleSource = fileTree('gradle') { include '**/*.gradle' }.files + .collect { it.getText('UTF-8') }.join('\n') + List commandSources = [file('build.gradle')] + commandSources.addAll(fileTree('gradle') { include '**/*.gradle' }.files) + commandSources.addAll(fileTree('.github/workflows') { include '*.yml', '*.yaml' }.files) + commandSources.each { File source -> + String text = source.getText('UTF-8') + if (text =~ /(?i)(?:commandLine|executable|run:)\s*[^\n]*(?:cmd(?:\.exe)?\s+\/c|powershell(?:\.exe)?\s+-command|(?:bash|sh)\s+-c)/) { + throw new GradleException("Shell-specific command wrapper in ${source}") + } + } + if (!gradleSource.contains('commandLine(([javaExecutable.absolutePath] + arguments) as List)') + || !gradleSource.contains('join(File.pathSeparator)') + || !gradleSource.contains('${File.pathSeparator}')) { + throw new GradleException('Runtime commands and exploded-mod paths must use native argument/path APIs') + } + } +} + +tasks.named('check') { dependsOn tasks.named('verifyCommandPortability') } +tasks.named('check') { dependsOn tasks.named('verifyMavenCoordinates') } + +// The named test launch inherits the ordinary launch rather than guessing +// Forge's module path, asset locations or Slime Launcher mapping arguments. +project.ext.oreSpawnBuild.oreSourcesClientLaunch = { + def xml = new groovy.xml.XmlParser(false, false).parse(file('runOreSourcesClient.launch')) + def attribute = { String key -> xml.stringAttribute.find { it.@key == key }.@value.toString() } + Map environment = [:] + xml.mapAttribute.find { + it.@key == 'org.eclipse.debug.core.environmentVariables' + }.mapEntry.each { environment[it.@key.toString()] = it.@value.toString() } + List archives = [] + xml.listAttribute.find { + it.@key == 'org.eclipse.jdt.launching.CLASSPATH' + }.listEntry.each { entry -> + def runtime = new groovy.xml.XmlParser(false, false).parseText(entry.@value.toString()) + if (runtime.@externalArchive) archives.add(file(runtime.@externalArchive.toString())) + } + [mainClass: attribute('org.eclipse.jdt.launching.MAIN_TYPE'), + jvmArguments: org.apache.tools.ant.types.Commandline.translateCommandline( + attribute('org.eclipse.jdt.launching.VM_ARGUMENTS')) as List, + arguments: org.apache.tools.ant.types.Commandline.translateCommandline( + attribute('org.eclipse.jdt.launching.PROGRAM_ARGUMENTS')) as List, + environment: environment, archives: archives] +} + +def requiredClientArguments = [ + '--main': 'cpw.mods.bootstraplauncher.BootstrapLauncher', + '--launchTarget': 'forgeclientuserdev', '--fml.forgeVersion': forge_version, + '--fml.mcVersion': minecraft_version, '--fml.forgeGroup': 'net.minecraftforge', + '--fml.mcpVersion': mcp_version +] +def verifyClientBootstrap = { Map launch -> + if (launch.mainClass != 'net.minecraftforge.launcher.Main' + || !launch.jvmArguments.contains('-p') + || !launch.jvmArguments.contains('ALL-MODULE-PATH') + || launch.environment.MCP_MAPPINGS != "${mapping_channel}_${mapping_version}".toString()) { + throw new GradleException('The client needs Forge 40 module, mapping and launcher settings') + } + requiredClientArguments.each { key, expected -> + int index = launch.arguments.indexOf(key) + if (index < 0 || index + 1 >= launch.arguments.size() + || launch.arguments[index + 1] != expected.toString()) { + throw new GradleException("The client needs ${key} ${expected}") + } + } +} + +tasks.register('oreSourcesClientLaunchContractTest') { + group = 'verification' + description = 'Rejects missing or incorrect Forge 40 client bootstrap arguments.' + doLast { + Map valid = [mainClass: 'net.minecraftforge.launcher.Main', + jvmArguments: ['-p', 'modules', '--add-modules', 'ALL-MODULE-PATH'], + arguments: requiredClientArguments.collectMany { key, value -> [key, value.toString()] }, + environment: [MCP_MAPPINGS: "${mapping_channel}_${mapping_version}".toString()]] + verifyClientBootstrap(valid) + requiredClientArguments.keySet().each { key -> + ['missing', 'incorrect'].each { scenario -> + Map invalid = new LinkedHashMap(valid) + invalid.arguments = new ArrayList(valid.arguments) + int index = invalid.arguments.indexOf(key) + if (scenario == 'missing') { + invalid.arguments.remove(index + 1) + invalid.arguments.remove(index) + } else invalid.arguments[index + 1] = 'incorrect' + boolean rejected = false + try { verifyClientBootstrap(invalid) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException("Client launch accepted ${scenario} ${key}") + } + } + } +} + +tasks.register('verifyOreSourcesClientLaunch') { + group = 'verification' + description = 'Checks the shared-ore client stays separate from production and automatic probes.' + dependsOn tasks.named('isolateEclipseProductionRuns') + doLast { + Map launch = project.ext.oreSpawnBuild.oreSourcesClientLaunch() + verifyClientBootstrap(launch) + ['runOreSourcesClient.launch', 'runTestOreSourcesClient.launch'].each { name -> + File source = file(name) + def xml = new groovy.xml.XmlParser(false, false).parse(source) + if (xml.stringAttribute.find { + it.@key == 'org.eclipse.jdt.launching.WORKING_DIRECTORY' + }.@value.toString() != file('run-ore-sources').absolutePath + || launch.environment.MOD_CLASSES != + project.ext.oreSpawnBuild.oreSourcesClientModClasses(file('bin/main'))) { + throw new GradleException('Shared-ore launch must use its separate directory and merged fixture outputs') + } + ['bin/test', 'src/test', 'junit-', 'clientprobe', 'surfaceprobe', + 'oresourceprobe.phase', 'clientprobe.enabled'].each { forbidden -> + if (source.getText('UTF-8').contains(forbidden)) { + throw new GradleException("Manual launch leaked ${forbidden}") + } + } + } + project.ext.oreSpawnBuild.oreSourceFixtureOutputs.values().each { File output -> + if (!new File(output, 'META-INF/mods.toml').isFile() || !launch.archives.contains(output)) { + throw new GradleException("Missing shared-ore fixture output ${output}") + } + } + if (!java.util.Arrays.equals(file('runOreSourcesClient.launch').bytes, + file('runTestOreSourcesClient.launch').bytes)) { + throw new GradleException('Both shared-ore launch names must stay identical') + } + } +} + +// Eclipse copies resources again during a Java rebuild; test that copy too. +tasks.register('eclipseResourceRebuildTest') { + group = 'verification' + description = 'Checks an Eclipse rebuild retains expanded Forge metadata and bundled guides.' + dependsOn tasks.named('eclipseClasspath'), tasks.named('processResources') + doLast { + def xml = new groovy.xml.XmlParser(false, false).parse(file('.classpath')) + List resourceFolders = xml.classpathentry.findAll { entry -> + entry.@kind == 'src' && entry.@output == 'bin/main' + && entry.@path != 'src/main/java' + }.collect { it.@path.toString() } + if (resourceFolders != ['build/resources/main']) { + throw new GradleException("Eclipse must rebuild from processed resources only: ${resourceFolders}") + } + File rebuilt = file("${buildDir}/eclipse-resource-rebuild-test") + project.sync { resourceFolders.each { from file(it) }; into rebuilt } + File metadata = new File(rebuilt, 'META-INF/mods.toml') + File processed = file("${buildDir}/resources/main/META-INF/mods.toml") + if (!metadata.isFile() || metadata.getText('UTF-8').contains('${version}') + || !java.util.Arrays.equals(metadata.bytes, processed.bytes)) { + throw new GradleException('Eclipse rebuild copied unexpanded or stale Forge metadata') + } + ['README.md', 'VERSIONS.md'].each { name -> + if (!new File(rebuilt, "META-INF/orespawn/docs/${name}").isFile()) { + throw new GradleException("Eclipse rebuild lost the bundled guide ${name}") + } + } + } +} +tasks.named('check') { + dependsOn tasks.named('eclipseResourceRebuildTest'), tasks.named('oreSourcesClientLaunchContractTest') +} diff --git a/gradle/release/artifacts.gradle b/gradle/release/artifacts.gradle new file mode 100644 index 00000000..69703616 --- /dev/null +++ b/gradle/release/artifacts.gradle @@ -0,0 +1,395 @@ +import groovy.json.JsonSlurper +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.jar.Manifest +import java.util.zip.ZipFile + +// Check exactly the jars and guides users will receive. +def shared = project.ext.oreSpawnBuild +def expectedMavenGroup = shared.mavenGroup +def expectedMavenArtifact = shared.mavenArtifact +def expectedMavenCoordinate = shared.mavenCoordinate +def archiveTextSuffixes = shared.archiveTextSuffixes +def archiveTextPatterns = shared.archiveTextPatterns +def normalizeArchiveLineEndings = shared.normalizeArchiveLineEndings +def bundledDocumentationDirectory = shared.bundledDocumentationDirectory +def prepareBundledDocumentation = shared.prepareBundledDocumentation +def prepareEclipseResources = shared.prepareEclipseResources +def packagedSurfaceProbeJar = renamer.classes(tasks.named('surfaceIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('surface-integration-fixture/surfaceprobe-reobf.jar') +} + +tasks.named('jar', Jar) { + archiveClassifier = 'deobf' + destinationDirectory = layout.buildDirectory.dir('libs-dev') + manifest { + attributes([ + 'Specification-Title' : 'OreSpawn', + 'Specification-Vendor' : 'SkyBlade1978', + 'Specification-Version' : '1', + 'Implementation-Title' : base.archivesName.get(), + 'Implementation-Version' : project.version, + 'Implementation-Vendor' : 'SkyBlade1978', + 'OreSpawn-API-Version' : '1', + 'FMLAT' : 'accesstransformer.cfg', + 'Maven-Artifact' : expectedMavenCoordinate, + 'Built-On-Java' : '17', + 'Built-On' : "${project.minecraft_version}-${project.forge_version}" + ]) + } +} + +def releaseJar = renamer.classes(tasks.named('jar', Jar)) { + map.from minecraft.dependency.toSrgFile + archiveClassifier = null + accessTransformers = true + output = layout.buildDirectory.file( + "libs/${base.archivesName.get()}-${project.version}.jar") +} + +tasks.named('sourcesJar', Jar) { + dependsOn prepareBundledDocumentation + filteringCharset = 'UTF-8' + includeEmptyDirs = false + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-sources', + 'Implementation-Version': project.version + ]) + } +} +tasks.named('javadocJar', Jar) { + filteringCharset = 'UTF-8' + filesMatching(archiveTextPatterns, normalizeArchiveLineEndings) + manifest { + attributes([ + 'Implementation-Title' : 'OreSpawn-javadoc', + 'Implementation-Version': project.version + ]) + } +} +['apiElements', 'runtimeElements'].each { configurationName -> + configurations.named(configurationName) { artifacts.clear() } + artifacts { add(configurationName, releaseJar) } +} +tasks.named('assemble') { + dependsOn releaseJar + dependsOn tasks.named('sourcesJar') + dependsOn tasks.named('javadocJar') +} + +def expectedReleaseFiles = providers.provider { + String prefix = "${base.archivesName.get()}-${project.version}" + ["${prefix}.jar", "${prefix}-sources.jar", "${prefix}-javadoc.jar"] +} +def preparedReleaseDir = providers.gradleProperty('preparedReleaseDir') + +tasks.register('verifyReleaseConfiguration') { + group = 'verification' + doLast { + if (project.mod_version != '4.1.0.118021' + || project.mod_group != expectedMavenGroup + || project.minecraft_version != '1.18.2' + || project.forge_version != '40.3.0' + || project.mapping_channel != 'official' + || project.mapping_version != '1.18.2') { + throw new GradleException('Unexpected OreSpawn 1.18.2 release identity') + } + if (project.loader_name != 'forge' || project.loader_code != '1' + || project.java_version != '17' || project.gradle_java_version != '17' + || project.java_toolchain_version != '17.0.1+12') { + throw new GradleException('Unexpected dispatcher or Java target metadata') + } + List expectedPublicArtifacts = [ + 'OreSpawn-4.1.0.118021.jar', + 'OreSpawn-4.1.0.118021-sources.jar', + 'OreSpawn-4.1.0.118021-javadoc.jar' + ] + if (base.archivesName.get() != expectedMavenArtifact + || expectedReleaseFiles.get().collect { it.toString() } != expectedPublicArtifacts) { + throw new GradleException('Public artifacts must use the version-only OreSpawn filename contract') + } + String ciWorkflow = file('.github/workflows/ci.yml').getText('UTF-8') + expectedPublicArtifacts.each { artifactName -> + if (!ciWorkflow.contains("build/libs/${artifactName}")) { + throw new GradleException("CI does not upload expected public artifact ${artifactName}") + } + } + [ + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java', + 'src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java', + 'README.md', 'CHANGELOG.txt' + ].each { path -> + if (!file(path).getText('UTF-8').contains('4.1.0.118021')) { + throw new GradleException("Release identity missing from ${path}") + } + } + if (!file('docs/API.md').getText('UTF-8').contains('versionRange="[4.0.6,5.0.0)"')) { + throw new GradleException('Consumer compatibility floor must remain [4.0.6,5.0.0)') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/api/OreSpawnApi.java') + .getText('UTF-8').contains('API_VERSION = 1')) { + throw new GradleException('OreSpawn API major must remain 1') + } + if (!file('src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 8') + || !file('src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java') + .getText('UTF-8').contains('SCHEMA_VERSION = 7')) { + throw new GradleException('Global/world schemas must remain 8/7') + } + def schema = new JsonSlurper().parse(file('docs/schemas/orespawn-provider.schema.json')) + if (!(schema.properties.schema_version.enum as List).contains(5)) { + throw new GradleException('Provider schema must support version 5') + } + } +} + +def trackedDocumentationDirectory = file('docs') +def documentationFiles = { + fileTree(trackedDocumentationDirectory).files.findAll { it.isFile() }.collect { + trackedDocumentationDirectory.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') }.sort() +} +def assertDocumentationTree = { File root, List expected, String label -> + List actual = root.isDirectory() ? fileTree(root).files.findAll { it.isFile() } + .collect { root.canonicalFile.toPath().relativize(it.canonicalFile.toPath()) + .toString().replace('\\', '/') } + .sort() : [] + if (actual != expected) { + throw new GradleException("${label} documentation set ${actual} does not match tracked ${expected}") + } + expected.each { relative -> + byte[] tracked = new File(trackedDocumentationDirectory, relative).bytes + byte[] candidate = new File(root, relative).bytes + if (!java.util.Arrays.equals(tracked, candidate)) { + throw new GradleException("${label}/${relative} differs from tracked documentation") + } + } +} + +def verifyDocumentationParity = tasks.register('verifyDocumentationParity') { + group = 'verification' + dependsOn prepareBundledDocumentation + dependsOn tasks.named('processResources') + dependsOn prepareEclipseResources + dependsOn releaseJar + doLast { + List expected = documentationFiles() + if (expected.size() != 20 || !expected.contains('VERSIONS.md')) { + throw new GradleException("Expected exactly 20 tracked guide files including VERSIONS.md, found ${expected}") + } + assertDocumentationTree(bundledDocumentationDirectory.asFile, + expected, 'generated resources') + assertDocumentationTree(new File(layout.buildDirectory.dir('resources/main').get().asFile, + 'META-INF/orespawn/docs'), expected, 'processed resources') + assertDocumentationTree(file('bin/main/META-INF/orespawn/docs'), + expected, 'Eclipse bin/main') + new ZipFile(releaseJar.get().output.get().asFile).withCloseable { zip -> + expected.each { relative -> + def entry = zip.getEntry("META-INF/orespawn/docs/${relative}") + if (entry == null || !java.util.Arrays.equals( + new File(trackedDocumentationDirectory, relative).bytes, + zip.getInputStream(entry).withCloseable { it.bytes })) { + throw new GradleException("Release jar documentation differs at ${relative}") + } + } + } + } +} + +tasks.register('verifyReleaseArtifacts') { + group = 'verification' + dependsOn tasks.named('verifyReleaseConfiguration') + dependsOn verifyDocumentationParity + dependsOn tasks.named('assemble') + doLast { + File libs = layout.buildDirectory.dir('libs').get().asFile + List jars = (libs.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + if (jars.collect { it.name } != expected) { + throw new GradleException("Expected exactly ${expected}, found ${jars*.name}") + } + jars.each { candidate -> + if (candidate.length() == 0L) throw new GradleException("Empty artifact ${candidate}") + new ZipFile(candidate).withCloseable { zip -> + zip.entries().findAll { entry -> + !entry.isDirectory() && (archiveTextSuffixes.any { entry.name.endsWith(it) } + || entry.name.endsWith('/element-list') + || entry.name.endsWith('/package-list')) + }.each { entry -> + boolean cr = zip.getInputStream(entry).withCloseable { + input -> input.bytes.any { value -> value == 13 } + } + if (cr) throw new GradleException( + "${candidate.name}!/${entry.name} is not LF-normalized") + } + [ + 'src/test/', 'src/biomeIntegrationTest/', 'src/clientIntegrationTest/', + 'src/oreSourceMineralsTest/', 'src/oreSourceAdvantageTest/', + 'src/worldHashTest/', + 'agent-notes/', 'AGENTS.md', 'surfaceprobe', 'clientprobe', + 'oresourceprobe', 'worldhashprobe', 'ci-fixtures/', + 'org/junit/', 'org/mockito/', 'net/bytebuddy/', + 'Mineralogy-1.18.2-5.4.0.jar' + ].each { forbidden -> + if (zip.entries().any { it.name.contains(forbidden) }) { + throw new GradleException( + "${candidate.name} contains forbidden ${forbidden}") + } + } + } + } + + File mainJar = new File(libs, expectedReleaseFiles.get()[0]) + new ZipFile(mainJar).withCloseable { zip -> + List names = zip.entries().collect { it.name } + [ + 'META-INF/mods.toml', + 'META-INF/accesstransformer.cfg', + 'zone/moddev/mc/orespawn/api/OreSpawnApi.class', + 'META-INF/orespawn/docs/VERSIONS.md', + 'META-INF/orespawn/docs/schemas/orespawn-provider.schema.json' + ].each { required -> + if (!names.contains(required)) { + throw new GradleException("Release jar is missing ${required}") + } + } + String metadata = zip.getInputStream(zip.getEntry('META-INF/mods.toml')) + .getText(StandardCharsets.UTF_8.name()) + if (!metadata.contains('modId="orespawn"') + || !metadata.contains("version=\"${project.version}\"") + || !metadata.contains('versionRange="[1.18.2,1.19)"')) { + throw new GradleException('Packaged Forge metadata is incorrect') + } + String transformer = zip.getInputStream( + zip.getEntry('META-INF/accesstransformer.cfg')) + .getText(StandardCharsets.UTF_8.name()) + List actualRules = transformer.readLines() + .collect { it.replaceFirst(/\s*#.*/, '').trim() } + .findAll { !it.isEmpty() } + List expectedRules = [ + 'public-f net.minecraft.world.level.chunk.ChunkGenerator f_62137_', + 'public-f net.minecraft.world.level.chunk.ChunkGenerator f_62138_', + 'public-f net.minecraft.world.level.levelgen.NoiseBasedChunkGenerator f_188607_', + 'public-f net.minecraft.world.level.biome.Biome f_47438_', + 'public net.minecraft.world.level.biome.Biome f_47437_', + 'public-f net.minecraft.world.level.levelgen.feature.configurations.SpringConfiguration f_68128_' + ] + if (actualRules != expectedRules) { + throw new GradleException("Unexpected packaged SRG access transformer: ${actualRules}") + } + def manifestEntry = zip.getEntry('META-INF/MANIFEST.MF') + def manifest = manifestEntry == null ? null : + new Manifest(zip.getInputStream(manifestEntry)).mainAttributes + if (manifest == null + || manifest.getValue('Implementation-Version') != project.mod_version + || manifest.getValue('OreSpawn-API-Version') != '1' + || manifest.getValue('FMLAT') != 'accesstransformer.cfg' + || manifest.getValue('Maven-Artifact') != expectedMavenCoordinate + || manifest.getValue('Implementation-Timestamp') != null) { + throw new GradleException('Release manifest is incorrect or volatile') + } + zip.entries().findAll { it.name.endsWith('.class') }.each { entry -> + byte[] header = new byte[8] + zip.getInputStream(entry).withCloseable { input -> + if (input.read(header) != 8) throw new GradleException("Cannot inspect ${entry.name}") + } + int major = ((header[6] & 0xff) << 8) | (header[7] & 0xff) + if (major != 61) { + throw new GradleException("${entry.name} uses class major ${major}, expected 61") + } + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[1])).withCloseable { zip -> + if (zip.getEntry('zone/moddev/mc/orespawn/OreSpawn.java') == null) { + throw new GradleException('Sources jar is missing OreSpawn.java') + } + } + new ZipFile(new File(libs, expectedReleaseFiles.get()[2])).withCloseable { zip -> + if (zip.getEntry('index.html') == null + || zip.getEntry('zone/moddev/mc/orespawn/api/OreSpawnApi.html') == null) { + throw new GradleException('Javadoc jar is missing its index or public OreSpawn API page') + } + } + } +} + +tasks.register('writeReleaseChecksums') { + group = 'verification' + dependsOn tasks.named('verifyReleaseArtifacts') + inputs.files(expectedReleaseFiles.map { names -> + names.collect { layout.buildDirectory.file("libs/${it}") } + }) + def outputFile = layout.buildDirectory.file('release/SHA256SUMS') + outputs.file(outputFile) + doLast { + File output = outputFile.get().asFile + output.parentFile.mkdirs() + File libs = layout.buildDirectory.dir('libs').get().asFile + String contents = expectedReleaseFiles.get().sort().collect { name -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + new File(libs, name).withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${name}" + }.join('\n') + '\n' + output.setText(contents, 'UTF-8') + } +} + +tasks.register('verifyPreparedReleaseArtifacts') { + group = 'verification' + doLast { + if (!preparedReleaseDir.isPresent()) { + throw new GradleException('preparedReleaseDir is required') + } + File prepared = file(preparedReleaseDir.get()) + // Provider interpolation yields GString values; normalize them before + // comparing with real filesystem String names. + List expected = expectedReleaseFiles.get() + .collect { it.toString() }.sort() + List jars = (prepared.listFiles() ?: [] as File[]) + .findAll { it.name.endsWith('.jar') }.sort { it.name } + List actualNames = jars.collect { it.name.toString() }.sort() + if (actualNames != expected) { + throw new GradleException( + "Prepared release jars ${actualNames} do not match ${expected}") + } + if (jars.any { it.length() == 0L }) { + throw new GradleException('Prepared release contains an empty jar') + } + File checksums = new File(prepared, 'SHA256SUMS') + if (!checksums.isFile() || !new File(prepared, 'CHANGELOG.txt').isFile()) { + throw new GradleException('Prepared release is missing checksums or changelog') + } + List actual = jars.collect { candidate -> + MessageDigest digest = MessageDigest.getInstance('SHA-256') + candidate.withInputStream { input -> + byte[] buffer = new byte[8192] + for (int read = input.read(buffer); read >= 0; read = input.read(buffer)) { + if (read > 0) digest.update(buffer, 0, read) + } + } + "${digest.digest().encodeHex().toString().toUpperCase()} ${candidate.name}" + }.sort() + if (actual != checksums.readLines('UTF-8').findAll { !it.trim().isEmpty() }.sort()) { + throw new GradleException('Prepared release checksums do not match') + } + } +} + +shared.releaseJar = releaseJar +shared.packagedSurfaceProbeJar = packagedSurfaceProbeJar +shared.expectedReleaseFiles = expectedReleaseFiles +shared.preparedReleaseDir = preparedReleaseDir +shared.documentationFiles = documentationFiles +shared.assertDocumentationTree = assertDocumentationTree diff --git a/gradle/release/publishing.gradle b/gradle/release/publishing.gradle new file mode 100644 index 00000000..690e0876 --- /dev/null +++ b/gradle/release/publishing.gradle @@ -0,0 +1,93 @@ +import groovy.xml.XmlSlurper + +// Publishing is opt-in; ordinary builds only verify coordinates and credentials. +def shared = project.ext.oreSpawnBuild +def expectedMavenGroup = shared.mavenGroup +def expectedMavenArtifact = shared.mavenArtifact +def expectedReleaseFiles = shared.expectedReleaseFiles +def preparedReleaseDir = shared.preparedReleaseDir +def releaseJar = shared.releaseJar +def mavenUploadUrl = providers.environmentVariable('MAVEN_UPLOAD_URL') + .orElse('https://invalid.invalid/missing-maven-upload-url') +def mavenUploadUsername = providers.environmentVariable('MAVEN_UPLOAD_USERNAME') +def mavenUploadPassword = providers.environmentVariable('MAVEN_UPLOAD_PASSWORD') +publishing { + publications { + mavenJava(MavenPublication) { + groupId = expectedMavenGroup + artifactId = expectedMavenArtifact + version = project.version.toString() + if (preparedReleaseDir.isPresent()) { + File prepared = file(preparedReleaseDir.get()) + artifact(new File(prepared, expectedReleaseFiles.get()[0])) + artifact(new File(prepared, expectedReleaseFiles.get()[1])) { classifier = 'sources' } + artifact(new File(prepared, expectedReleaseFiles.get()[2])) { classifier = 'javadoc' } + } else { + artifact(releaseJar) + artifact(tasks.named('sourcesJar')) + artifact(tasks.named('javadocJar')) + } + pom { + name = 'MMD OreSpawn' + description = project.mod_description + url = 'https://github.com/MinecraftModDevelopmentMods/OreSpawn' + licenses { + license { + name = 'GNU Lesser General Public License, Version 2.1' + url = 'https://www.gnu.org/licenses/old-licenses/lgpl-2.1.txt' + } + } + } + } + } + repositories { + maven { + name = 'release' + url = uri(mavenUploadUrl.get()) + credentials { + username = mavenUploadUsername.orNull ?: '' + password = mavenUploadPassword.orNull ?: '' + } + } + } +} +tasks.register('verifyMavenCoordinates') { + group = 'verification' + description = 'Verifies the generated POM uses OreSpawn\'s mod-specific Maven namespace.' + dependsOn tasks.named('generatePomFileForMavenJavaPublication') + doLast { + File pomFile = layout.buildDirectory.file( + 'publications/mavenJava/pom-default.xml').get().asFile + if (!pomFile.isFile()) { + throw new GradleException("Generated Maven POM does not exist: ${pomFile}") + } + def pom = new XmlSlurper(false, false).parse(pomFile) + def actual = [pom.groupId.text(), pom.artifactId.text(), pom.version.text()] + def expected = [expectedMavenGroup, expectedMavenArtifact, + project.version.toString()] + if (project.group.toString() != expectedMavenGroup || actual != expected) { + throw new GradleException("Expected Maven coordinate ${expected.join(':')}, " + + "found ${actual.join(':')}") + } + } +} +tasks.register('validateMavenReleaseCredentials') { + group = 'publishing' + doLast { + if (!providers.environmentVariable('MAVEN_UPLOAD_URL').isPresent() + || !mavenUploadUsername.isPresent() || !mavenUploadPassword.isPresent()) { + throw new GradleException( + 'MAVEN_UPLOAD_URL, MAVEN_UPLOAD_USERNAME, and MAVEN_UPLOAD_PASSWORD are required') + } + if (providers.environmentVariable('MAVEN_UPLOAD_URL').get().startsWith('file:')) { + throw new GradleException('Release publication must use a remote repository') + } + } +} +tasks.withType(PublishToMavenRepository).configureEach { + dependsOn tasks.named('validateMavenReleaseCredentials') + dependsOn tasks.named('verifyMavenCoordinates') + dependsOn preparedReleaseDir.isPresent() + ? tasks.named('verifyPreparedReleaseArtifacts') + : tasks.named('verifyReleaseArtifacts') +} diff --git a/gradle/verification/biome-override.gradle b/gradle/verification/biome-override.gradle new file mode 100644 index 00000000..74f41bc4 --- /dev/null +++ b/gradle/verification/biome-override.gradle @@ -0,0 +1,106 @@ +// A disposable world proves that a saved replacement affects new chunks only. +// The probe mod is shared with the benchmark and never joins a release jar. +def biomeOverrideShared = project.ext.oreSpawnBuild +def biomeOverrideRuntime = providers.gradleProperty('packagedForgeServerRuntime') +File biomeOverrideRun = file("${buildDir}/biome-override-run") +File biomeOverrideWorld = new File(biomeOverrideRun, 'biome-override-world') +File biomeOverrideMarker = new File(biomeOverrideWorld, 'biomeoverrideprobe.properties') + +def prepareBiomeOverride = tasks.register('prepareBiomeOverrideProbe') { + dependsOn biomeOverrideShared.releaseJar, tasks.named('renameWorldHashProbeJar') + doLast { + delete biomeOverrideRun + biomeOverrideRun.mkdirs() + biomeOverrideShared.prepareTestForgeConfig(biomeOverrideRun) + copy { + from biomeOverrideShared.releaseJar + from tasks.named('renameWorldHashProbeJar') + into new File(biomeOverrideRun, 'mods') + } + new File(biomeOverrideRun, 'server.properties').setText('''\ +level-name=biome-override-world +level-seed=49110 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(biomeOverrideRun, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def biomeOverrideFresh = tasks.register('biomeOverrideFresh', Exec) { + group = 'verification' + dependsOn prepareBiomeOverride + doFirst { + if (!biomeOverrideRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir biomeOverrideRun + commandLine biomeOverrideShared.forge40ServerCommand(file(biomeOverrideRuntime.get()), + biomeOverrideRun, ['-Dorespawn.biomeOverridePhase=fresh']) + } + doLast { + if (!biomeOverrideMarker.isFile()) { + throw new GradleException('Fresh biome override marker is missing') + } + biomeOverrideShared.assertRuntimeLogsClean(biomeOverrideRun, 'biome override fresh', [] as Set) + } +} + +def writeBiomeOverride = tasks.register('writeBiomeOverride') { + dependsOn biomeOverrideFresh + doLast { + Properties marker = new Properties() + biomeOverrideMarker.withInputStream { marker.load(it) } + String source = marker.getProperty('source') + if (source == null || !source.contains(':')) { + throw new GradleException("Missing source biome in ${biomeOverrideMarker}") + } + String target = source == 'minecraft:desert' ? 'minecraft:plains' : 'minecraft:desert' + File profileFile = new File(biomeOverrideWorld, 'serverconfig/orespawn-worldgen.json') + if (!profileFile.isFile()) throw new GradleException("Missing world profile ${profileFile}") + Map profile = (Map) new groovy.json.JsonSlurper().parse(profileFile) + Map palettes = profile.biome_palettes instanceof Map ? (Map) profile.biome_palettes + : new LinkedHashMap() + profile.biome_palettes = palettes + palettes['orespawn:ui/biome_overrides/minecraft_overworld'] = [ + dimension: 'minecraft:overworld', enabled: true, mode: 'replace', + scope: 'all', region_size: 'tiny', coverage: 1.0D, + fallback_weight: 0.0D, + biomes: [(target): [enabled: true, weight: 1.0D, + similar_biomes: [source]]] + ] + profileFile.setText(groovy.json.JsonOutput.prettyPrint( + groovy.json.JsonOutput.toJson(profile)) + '\n', 'UTF-8') + marker.setProperty('target', target) + biomeOverrideMarker.withOutputStream { marker.store(it, 'Disposable biome override probe') } + } +} + +def biomeOverrideReload = tasks.register('biomeOverrideReload', Exec) { + group = 'verification' + dependsOn writeBiomeOverride + doFirst { + workingDir biomeOverrideRun + commandLine biomeOverrideShared.forge40ServerCommand(file(biomeOverrideRuntime.get()), + biomeOverrideRun, ['-Dorespawn.biomeOverridePhase=reload']) + } + doLast { + Properties marker = new Properties() + biomeOverrideMarker.withInputStream { marker.load(it) } + if (marker.getProperty('reload_verified') != 'true') { + throw new GradleException('Biome override did not pass new-terrain and no-retrogen checks') + } + biomeOverrideShared.assertRuntimeLogsClean(biomeOverrideRun, 'biome override reload', [] as Set) + } +} + +tasks.register('biomeOverrideIntegrationTest') { + group = 'verification' + description = 'Proves an exact replacement changes new chunks without rewriting saved chunks.' + dependsOn biomeOverrideReload +} diff --git a/gradle/verification/client-and-benchmark.gradle b/gradle/verification/client-and-benchmark.gradle new file mode 100644 index 00000000..65e2f972 --- /dev/null +++ b/gradle/verification/client-and-benchmark.gradle @@ -0,0 +1,119 @@ +// Development-only client and surface probes never enter the released mod. +def assertRuntimeLogsClean = project.ext.oreSpawnBuild.assertRuntimeLogsClean +def clientIntegrationClasses = file("${buildDir}/client-integration-fixture/classes") +tasks.register('compileClientIntegrationTestMod', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/clientIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = clientIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' +} +tasks.register('clientIntegrationTestModJar', Jar) { + dependsOn tasks.named('compileClientIntegrationTestMod') + archiveFileName = 'clientprobe.jar' + destinationDirectory = file("${buildDir}/client-integration-fixture") + from clientIntegrationClasses + from 'src/clientIntegrationTest/resources' +} +def packagedClientProbeJar = renamer.classes(tasks.named('clientIntegrationTestModJar', Jar)) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('client-integration-fixture/clientprobe-reobf.jar') +} + +def surfaceIntegrationClasses = file("${buildDir}/surface-integration-fixture/classes") +task compileSurfaceIntegrationTestMod(type: JavaCompile, dependsOn: classes) { + source fileTree('src/biomeIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = surfaceIntegrationClasses + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' +} + +task surfaceIntegrationTestModJar(type: Jar, dependsOn: compileSurfaceIntegrationTestMod) { + archiveFileName = 'surfaceprobe.jar' + destinationDirectory = file("${buildDir}/surface-integration-fixture") + from surfaceIntegrationClasses + from 'src/biomeIntegrationTest/resources' +} + +def surfaceIntegrationRunDirectory = file("${buildDir}/surface-integration-run") +task prepareSurfaceIntegrationTest(dependsOn: surfaceIntegrationTestModJar) { + doLast { + delete surfaceIntegrationRunDirectory + surfaceIntegrationRunDirectory.mkdirs() + project.ext.oreSpawnBuild.prepareTestForgeConfig(surfaceIntegrationRunDirectory) + copy { + from surfaceIntegrationTestModJar.archiveFile + into new File(surfaceIntegrationRunDirectory, 'mods') + } + new File(surfaceIntegrationRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(surfaceIntegrationRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} + +def surfaceIntegrationFreshProcess = tasks.register('surfaceIntegrationFreshProcess') { + group = 'verification' + dependsOn 'runSurfaceIntegrationFresh' +} +tasks.matching { it.name == 'runSurfaceIntegrationFresh' }.all { + dependsOn prepareSurfaceIntegrationTest +} +tasks.matching { it.name == 'runSurfaceIntegrationReload' }.all { + mustRunAfter surfaceIntegrationFreshProcess +} +surfaceIntegrationFreshProcess.configure { + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) { + throw new GradleException("Fresh surface integration completion marker is missing: ${marker}") + } + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration fresh phase', [] as Set) + } +} +def surfaceIntegrationReloadProcess = tasks.register('surfaceIntegrationReloadProcess') { + group = 'verification' + dependsOn surfaceIntegrationFreshProcess + dependsOn 'runSurfaceIntegrationReload' +} +surfaceIntegrationReloadProcess.configure { + doLast { + assertRuntimeLogsClean(surfaceIntegrationRunDirectory, + 'surface integration reload phase', [] as Set) + } +} + +task surfaceIntegrationTest(dependsOn: surfaceIntegrationReloadProcess) { + group = 'verification' + doLast { + File marker = new File(surfaceIntegrationRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("Surface integration reload was not verified: ${marker}") + } + logger.lifecycle('Provider surfaces and exact-biome geology verified: {} dimensions, {} columns each, fresh + reload', + result.getProperty('dimensions'), result.getProperty('columns_per_dimension')) + } +} + +check.dependsOn surfaceIntegrationTest + +project.ext.oreSpawnBuild.surfaceIntegrationClasses = surfaceIntegrationClasses +project.ext.oreSpawnBuild.surfaceIntegrationRunDirectory = surfaceIntegrationRunDirectory +project.ext.oreSpawnBuild.packagedClientProbeJar = packagedClientProbeJar diff --git a/gradle/verification/native-ores.gradle b/gradle/verification/native-ores.gradle new file mode 100644 index 00000000..c035a7f5 --- /dev/null +++ b/gradle/verification/native-ores.gradle @@ -0,0 +1,117 @@ +// A real Nether checks the shipped host tag and explicit block hosts. +// The fixture is compiled separately and never joins a production classpath. +def shared = project.ext.oreSpawnBuild +def classes = layout.buildDirectory.dir('native-ore-fixture/classes') +def compile = tasks.register('compileNativeOreProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/nativeOreIntegrationTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = classes + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' +} +def jar = tasks.register('nativeOreProbeJar', Jar) { + dependsOn compile + archiveFileName = 'nativeoreprobe.jar' + destinationDirectory = layout.buildDirectory.dir('native-ore-fixture') + from classes + from 'src/nativeOreIntegrationTest/resources' +} +def packagedJar = renamer.classes(jar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('native-ore-fixture/nativeoreprobe-reobf.jar') +} +def serverRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def comparisonJar = providers.gradleProperty('nativeOreSpawnJar') +def finalTasks = [] +['Default', 'Explicit'].each { scenario -> + File runDirectory = file("${buildDir}/native-ore-${scenario.toLowerCase()}-run") + def prepare = tasks.register("prepareNativeOre${scenario}") { + dependsOn shared.releaseJar, packagedJar + doLast { + // This directory is build-owned; no manual saves are used here. + delete runDirectory + runDirectory.mkdirs() + shared.prepareTestForgeConfig(runDirectory) + copy { + from comparisonJar.isPresent() ? file(comparisonJar.get()) : shared.releaseJar + from packagedJar + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + new File(runDirectory, 'server.properties').setText('''\ +level-name=native-ore-world +level-seed=4192643144957839458 +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + } + } + def previous = prepare + ['Fresh', 'Reload', 'Edited'].each { phase -> + def dependency = previous + previous = tasks.register("nativeOre${scenario}${phase}", Exec) { + group = 'verification' + description = "Checks managed quartz with ${scenario.toLowerCase()} hosts (${phase.toLowerCase()})." + dependsOn dependency + doFirst { + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir runDirectory + // One generation worker makes independent host-form runs comparable. + // Ordinary runtime and performance checks keep their normal worker count. + commandLine shared.forge40ServerCommand(file(serverRuntime.get()), runDirectory, + ['-XX:ActiveProcessorCount=2', + "-Dnativeoreprobe.phase=${phase.toLowerCase()}", + "-Dnativeoreprobe.host=${scenario.toLowerCase()}"]) + } + doLast { + Properties result = new Properties() + File marker = new File(runDirectory, 'native-ore-world/nativeoreprobe.properties') + if (!marker.isFile()) throw new GradleException("Quartz audit is missing: ${marker}") + marker.withInputStream { result.load(it) } + if (result.getProperty("${phase.toLowerCase()}_verified") != 'true') { + throw new GradleException("Quartz ${phase.toLowerCase()} was not verified") + } + shared.assertRuntimeLogsClean(runDirectory, "Native quartz ${scenario} ${phase}", [] as Set) + if (new File(runDirectory, 'logs/latest.log').getText('UTF-8') + .contains("Ignoring invalid placement rule for OreSpawn-managed ore 'minecraft:nether_quartz_ore'")) { + throw new GradleException('Managed quartz was rejected during startup') + } + copy { from new File(runDirectory, 'logs/latest.log'); into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.log" } } + copy { from marker; into new File(runDirectory, 'audit-logs'); + rename { "${phase.toLowerCase()}.properties" } } + } + } + } + finalTasks.add(previous) +} +tasks.register('nativeOreIntegrationTest') { + group = 'verification' + description = 'Checks quartz defaults, explicit hosts, exact-save reload and new-terrain-only edits.' + dependsOn finalTasks + doLast { + ['fresh', 'reload', 'edited'].each { phase -> + Properties tagged = new Properties() + Properties explicit = new Properties() + file("${buildDir}/native-ore-default-run/audit-logs/${phase}.properties") + .withInputStream { tagged.load(it) } + file("${buildDir}/native-ore-explicit-run/audit-logs/${phase}.properties") + .withInputStream { explicit.load(it) } + ['quartz', 'blocks_sha256', 'biomes_sha256', 'new_terrain_quartz', 'water', 'new_terrain_water'].each { key -> + if (tagged.getProperty(key) != explicit.getProperty(key)) { + throw new GradleException("Tag and explicit quartz hosts differ for ${phase}: ${key}") + } + } + } + logger.lifecycle('Tag and explicit quartz hosts produce identical terrain, fresh/reload/edited') + } +} diff --git a/gradle/verification/ore-sources.gradle b/gradle/verification/ore-sources.gradle new file mode 100644 index 00000000..81bd4050 --- /dev/null +++ b/gradle/verification/ore-sources.gradle @@ -0,0 +1,247 @@ +// These tiny mods represent providers that have not yet been ported to this +// Minecraft version. They live outside sourceSets.main and ship in neither +// OreSpawn nor its published API jar. +def shared = project.ext.oreSpawnBuild +def fixtureClasses = [:] +def fixtureJars = [:] +def fixtureOutputs = [:] +def prepareFixtureOutputs = [] +['Minerals': 'oreSourceMineralsTest', 'Advantage': 'oreSourceAdvantageTest'].each { label, folder -> + File classes = file("${buildDir}/ore-source-fixture/${label.toLowerCase()}-classes") + def compile = tasks.register("compileOreSource${label}Fixture", JavaCompile) { + dependsOn tasks.named('classes') + source fileTree("src/${folder}/java") + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = classes + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' + } + def jar = tasks.register("oreSource${label}FixtureJar", Jar) { + dependsOn compile + archiveFileName = "oresource-${label.toLowerCase()}.jar" + destinationDirectory = file("${buildDir}/ore-source-fixture") + from classes + from "src/${folder}/resources" + } + fixtureClasses[label] = classes + File clientOutput = file("${buildDir}/ore-source-fixture/${label.toLowerCase()}-mod") + prepareFixtureOutputs.add(tasks.register("prepareOreSource${label}Client", Sync) { + dependsOn compile + from classes + from "src/${folder}/resources" + into clientOutput + }) + fixtureOutputs[label] = clientOutput + fixtureJars[label] = renamer.classes(jar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file("ore-source-fixture/oresource-${label.toLowerCase()}-reobf.jar") + } +} + +// Only this named profile loads the dummy providers; it keeps manual saves. +File clientDirectory = file('run-ore-sources') +def clientModClasses = { File mainOutput -> + List entries = ["${mod_id}%%${mainOutput}"] + ['Minerals': 'baseminerals', 'Advantage': 'electricadvantage'].each { label, id -> + entries.add("${id}%%${fixtureOutputs[label]}") + } + entries.join(File.pathSeparator) +} +shared.oreSourceFixtureOutputs = fixtureOutputs +shared.oreSourcesClientModClasses = clientModClasses + +tasks.register('prepareOreSourcesClient') { + group = 'ide' + description = 'Prepares two dummy ore providers without clearing manual settings or saves.' + dependsOn prepareFixtureOutputs, tasks.named('classes') + doLast { + new File(clientDirectory, 'mods').mkdirs() + copy { from sourceSets.main.output; into file('bin/main') } + } +} + +tasks.register('runOreSourcesClient', JavaExec) { + group = 'forgegradle runs' + description = 'Opens a separate client with simulated Base Minerals and Electric Advantage ores.' + dependsOn tasks.named('verifyOreSourcesClientLaunch') + javaLauncher = shared.java17Launcher + doFirst { + def launch = shared.oreSourcesClientLaunch() + mainClass = launch.mainClass + args = launch.arguments + jvmArgs = launch.jvmArguments + classpath = files(sourceSets.main.runtimeClasspath, launch.archives, file('bin/main')) + environment launch.environment + workingDir clientDirectory + File crashes = new File(clientDirectory, 'crash-reports') + ext.priorCrashes = crashes.isDirectory() + ? fileTree(crashes).files.collect { it.absolutePath } as Set : [] as Set + } + doLast { + shared.assertRuntimeLogsClean(clientDirectory, 'ore sources manual client', ext.priorCrashes) + } +} +tasks.register('runTestOreSourcesClient') { + group = 'forgegradle runs' + description = 'Alias for the manual shared-ore client.' + dependsOn tasks.named('runOreSourcesClient') +} + +def clientProbeOutput = file("${buildDir}/ore-source-fixture/client-probe-mod") +def prepareClientProbe = tasks.register('prepareOreSourcesClientProbe', Sync) { + dependsOn tasks.named('compileClientIntegrationTestMod') + from file("${buildDir}/client-integration-fixture/classes") + from 'src/clientIntegrationTest/resources' + into clientProbeOutput +} +tasks.register('oreSourcesClientIntegrationTest', Exec) { + group = 'verification' + description = 'Checks the shared-ore launch and fresh/reloaded rendered worlds.' + dependsOn 'verifyOreSourcesClientLaunch', prepareClientProbe + File directory = file("${buildDir}/ore-sources-client-integration-run") + doFirst { + delete directory + directory.mkdirs() + shared.prepareTestForgeConfig(directory) + def launch = shared.oreSourcesClientLaunch() + workingDir directory + environment launch.environment + environment 'MOD_CLASSES', clientModClasses(file('bin/main')) + + "${File.pathSeparator}clientprobe%%${clientProbeOutput}" + List arguments = new ArrayList<>(launch.jvmArguments) + arguments.addAll(['-Xmx2g', '-Dclientprobe.enabled=true', '-Dclientprobe.hidden=true', + '-Dclientprobe.oreSources=true', '-cp', + files(sourceSets.main.runtimeClasspath, launch.archives, file('bin/main'), clientProbeOutput).files + .collect { it.absolutePath }.join(File.pathSeparator), launch.mainClass]) + arguments.addAll(launch.arguments) + commandLine(([shared.java17Launcher.get().executablePath.asFile.absolutePath] + arguments) as List) + } + doLast { + Properties result = new Properties() + File marker = new File(directory, 'client-smoke-pass.properties') + if (!marker.isFile()) throw new GradleException('Shared-ore client did not complete') + marker.withInputStream { result.load(it) } + ['world_settings_opened', 'long_editor_roundtrip', 'first_world_rendered', 'reload_rendered'].each { + if (result.getProperty(it) != 'true') throw new GradleException("Shared-ore client failed ${it}") + } + shared.assertRuntimeLogsClean(directory, 'shared-ore development client', [] as Set) + } +} + +def serverRuntime = providers.gradleProperty('packagedForgeServerRuntime') +['One': ['Minerals'], 'Both': ['Minerals', 'Advantage']].each { scenario, labels -> + File runDirectory = file("${buildDir}/ore-source-${scenario.toLowerCase()}-run") + def prepare = tasks.register("prepareOreSource${scenario}") { + dependsOn shared.releaseJar + labels.each { dependsOn fixtureJars[it] } + doLast { + delete runDirectory + runDirectory.mkdirs() + shared.prepareTestForgeConfig(runDirectory) + copy { + from shared.releaseJar + labels.each { from fixtureJars[it] } + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=ore-source-world +level-seed=49110 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + def fresh = tasks.register("oreSource${scenario}Fresh", Exec) { + group = 'verification' + dependsOn prepare + doFirst { + if (!serverRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir runDirectory + commandLine shared.forge40ServerCommand(file(serverRuntime.get()), runDirectory, + ['-Doresourceprobe.phase=fresh']) + } + doLast { + File marker = new File(runDirectory, 'ore-source-world/oresourceprobe.properties') + if (!marker.isFile()) throw new GradleException("Fresh ore-source marker is missing: ${marker}") + shared.assertRuntimeLogsClean(runDirectory, "${scenario} ore-source fresh", [] as Set) + } + } + def reload = tasks.register("oreSource${scenario}Reload", Exec) { + group = 'verification' + dependsOn fresh + doFirst { + workingDir runDirectory + commandLine shared.forge40ServerCommand(file(serverRuntime.get()), runDirectory, + ['-Doresourceprobe.phase=reload']) + } + doLast { + File marker = new File(runDirectory, 'ore-source-world/oresourceprobe.properties') + Properties result = new Properties() + marker.withInputStream { result.load(it) } + if (result.getProperty('reload_verified') != 'true') { + throw new GradleException("${scenario} ore-source reload did not verify") + } + shared.assertRuntimeLogsClean(runDirectory, "${scenario} ore-source reload", [] as Set) + } + } + tasks.register("oreSource${scenario}IntegrationTest") { + group = 'verification' + dependsOn reload + } +} + +File bothRun = file("${buildDir}/ore-source-both-run") +File advantageJar = new File(bothRun, 'mods/oresource-advantage-reobf.jar') +File parkedJar = new File(bothRun, 'oresource-advantage-reobf.jar') +def missing = tasks.register('oreSourceBothMissing', Exec) { + group = 'verification' + dependsOn tasks.named('oreSourceBothReload') + doFirst { + java.nio.file.Files.move(advantageJar.toPath(), parkedJar.toPath()) + workingDir bothRun + commandLine shared.forge40ServerCommand(file(serverRuntime.get()), bothRun, + ['-Doresourceprobe.phase=missing']) + } + doLast { + Properties result = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { result.load(it) } + if (result.getProperty('missing_verified') != 'true') { + throw new GradleException('Missing-provider reload did not verify') + } + shared.assertRuntimeLogsClean(bothRun, 'Both ore-source provider missing', [] as Set, + ['This world was saved with mod electricadvantage which appears to be missing']) + } +} +tasks.register('oreSourceBothRestored', Exec) { + group = 'verification' + dependsOn missing + doFirst { + java.nio.file.Files.move(parkedJar.toPath(), advantageJar.toPath()) + workingDir bothRun + commandLine shared.forge40ServerCommand(file(serverRuntime.get()), bothRun, + ['-Doresourceprobe.phase=restored']) + } + doLast { + Properties result = new Properties() + new File(bothRun, 'ore-source-world/oresourceprobe.properties').withInputStream { result.load(it) } + if (result.getProperty('restored_verified') != 'true') { + throw new GradleException('Restored-provider reload did not verify') + } + shared.assertRuntimeLogsClean(bothRun, 'Both ore-source provider restored', [] as Set) + } +} + +tasks.register('oreSourceFixtureIntegrationTest') { + group = 'verification' + description = 'Checks one and two future MMD providers, then removes and restores one on the same save.' + dependsOn tasks.named('oreSourceOneIntegrationTest'), tasks.named('oreSourceBothRestored') +} diff --git a/gradle/verification/packaged-forge.gradle b/gradle/verification/packaged-forge.gradle new file mode 100644 index 00000000..083abdbc --- /dev/null +++ b/gradle/verification/packaged-forge.gradle @@ -0,0 +1,289 @@ +// Launch the finished jar in disposable official Forge installations. +def shared = project.ext.oreSpawnBuild +def java17Launcher = shared.java17Launcher +def assertRuntimeLogsClean = shared.assertRuntimeLogsClean +def releaseJar = shared.releaseJar +def packagedSurfaceProbeJar = shared.packagedSurfaceProbeJar +def packagedClientProbeJar = shared.packagedClientProbeJar +def documentationFiles = shared.documentationFiles +def assertDocumentationTree = shared.assertDocumentationTree +def packagedForgeServerRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def packagedForgeClientRuntime = providers.gradleProperty('packagedForgeClientRuntime') +def packagedClientJavaExecutable = providers.gradleProperty('packagedClientJavaExecutable') + +def requireRuntimeDirectory = { Provider configuredPath, String propertyName -> + if (!configuredPath.isPresent()) { + throw new GradleException("Pass -P${propertyName}=") + } + File runtime = file(configuredPath.get()) + if (!runtime.isDirectory()) { + throw new GradleException("${propertyName} does not name a directory: ${runtime}") + } + runtime +} + +def packagedSurfaceRunDirectory = file("${buildDir}/packaged-surface-run") +tasks.register('preparePackagedSurfaceIntegration') { + dependsOn releaseJar + dependsOn packagedSurfaceProbeJar + doLast { + delete packagedSurfaceRunDirectory + packagedSurfaceRunDirectory.mkdirs() + shared.prepareTestForgeConfig(packagedSurfaceRunDirectory) + copy { + from releaseJar + from packagedSurfaceProbeJar + into new File(packagedSurfaceRunDirectory, 'mods') + } + new File(packagedSurfaceRunDirectory, 'server.properties').setText('''\ +level-name=surface-integration-world +level-seed=zsjpxah +level-type=default +online-mode=false +server-port=0 +allow-nether=true +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(packagedSurfaceRunDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } +} +def packagedSurfaceFresh = tasks.register('packagedSurfaceFresh', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedSurfaceIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, + 'net/minecraftforge/forge/1.18.2-40.3.0') + File launcher = new File(forgeDirectory, 'forge-1.18.2-40.3.0-server.jar') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + [launcher, sourceArguments, libraries].each { + if (!it.exists()) throw new GradleException("Incomplete official server runtime: ${it}") + } + File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + workingDir packagedSurfaceRunDirectory + commandLine java17Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=fresh', + "@${absoluteArguments.absolutePath}", 'nogui' + } + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + if (!marker.isFile()) throw new GradleException('Packaged fresh surface marker is missing') + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface fresh', [] as Set) + } +} +def packagedSurfaceReload = tasks.register('packagedSurfaceReload', Exec) { + group = 'verification' + dependsOn packagedSurfaceFresh + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeServerRuntime, + 'packagedForgeServerRuntime') + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, + 'net/minecraftforge/forge/1.18.2-40.3.0') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + File absoluteArguments = new File(packagedSurfaceRunDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + workingDir packagedSurfaceRunDirectory + commandLine java17Launcher.get().executablePath.asFile.absolutePath, + '-Xms512m', '-Xmx2g', '-Dsurfaceprobe.integrationPhase=reload', + "@${absoluteArguments.absolutePath}", 'nogui' + } + doLast { + assertRuntimeLogsClean(packagedSurfaceRunDirectory, + 'packaged surface reload', [] as Set) + } +} +tasks.register('packagedSurfaceIntegrationTest') { + group = 'verification' + dependsOn packagedSurfaceReload + doLast { + File marker = new File(packagedSurfaceRunDirectory, + 'surface-integration-world/surfaceprobe-integration.properties') + Properties values = new Properties() + marker.withInputStream { values.load(it) } + if (values.getProperty('reload_verified') != 'true') { + throw new GradleException('Packaged surface reload was not verified') + } + } +} + +def packagedClientRunDirectory = file("${buildDir}/packaged-client-run") +tasks.register('preparePackagedClientIntegration') { + dependsOn releaseJar + dependsOn packagedClientProbeJar + doLast { + delete packagedClientRunDirectory + packagedClientRunDirectory.mkdirs() + shared.prepareTestForgeConfig(packagedClientRunDirectory) + copy { + from releaseJar + from packagedClientProbeJar + into new File(packagedClientRunDirectory, 'mods') + } + new File(packagedClientRunDirectory, 'options.txt').setText( + 'fullscreen:false\nlang:en_us\n', 'UTF-8') + File configDirectory = new File(packagedClientRunDirectory, 'config') + configDirectory.mkdirs() + new File(configDirectory, 'orespawn-common.toml').setText('''\ +# Bootstrap fallbacks. Detailed settings live in orespawn-worldgen.json. +[worldgen] + # Master switch for configured terrain replacement. + place_terrain = true + # Allowed Values: GEOME, LEGACY + fallback_geology_mode = "GEOME" + # Range: 4 ~ 32767 + cyano_region_size = 256 + # Range: 1.0 ~ 32767.0 + cyano_layer_reach = 32.0 + # Range: 1 ~ 255 + cyano_layer_thickness = 8 +''', 'UTF-8') + } +} +def packagedClientProcess = tasks.register('packagedClientProcess', Exec) { + group = 'verification' + dependsOn tasks.named('preparePackagedClientIntegration') + doFirst { + File runtime = requireRuntimeDirectory(packagedForgeClientRuntime, + 'packagedForgeClientRuntime') + String forgeVersionId = ['1.18.2-forge-40.3.0', 'forge-40.3.0'].find { candidate -> + new File(runtime, "versions/${candidate}/${candidate}.json").isFile() + } + if (forgeVersionId == null) { + throw new GradleException('Official client runtime has no Forge 40.3.0 profile') + } + File forgeJsonFile = new File(runtime, + "versions/${forgeVersionId}/${forgeVersionId}.json") + File baseJsonFile = new File(runtime, 'versions/1.18.2/1.18.2.json') + File baseJar = new File(runtime, 'versions/1.18.2/1.18.2.jar') + File nativesDirectory = new File(runtime, "natives/${forgeVersionId}") + [forgeJsonFile, baseJsonFile, baseJar, new File(runtime, 'libraries'), + new File(runtime, 'assets'), nativesDirectory].each { + if (!it.exists()) throw new GradleException("Incomplete official client runtime: ${it}") + } + + def slurper = new groovy.json.JsonSlurper() + Map forgeJson = (Map) slurper.parse(forgeJsonFile) + Map baseJson = (Map) slurper.parse(baseJsonFile) + Map classpathByModule = new LinkedHashMap<>() + [baseJson, forgeJson].each { Map metadata -> + ((List) metadata.libraries).each { Map library -> + List rules = (List) library.rules + boolean allowed = rules == null || rules.isEmpty() + if (rules != null) { + rules.each { Map rule -> + Map os = (Map) rule.os + boolean matches = os == null + || (os.name == 'windows' + && (os.arch == null || os.arch == System.getProperty('os.arch'))) + if (matches) allowed = rule.action == 'allow' + } + } + if (!allowed) return + String relative = (String) ((Map) ((Map) library.downloads).artifact).path + File artifact = new File(runtime, "libraries/${relative}") + if (!artifact.isFile()) { + throw new GradleException("Missing official client library: ${artifact}") + } + List coordinates = ((String) library.name).split(':') as List + String module = coordinates.size() >= 2 + ? "${coordinates[0]}:${coordinates[1]}" : (String) library.name + classpathByModule.put(module, artifact) + } + } + List classpathFiles = new ArrayList<>(classpathByModule.values()) + classpathFiles.add(baseJar) + + String libraryDirectory = new File(runtime, 'libraries').absolutePath + List forgeJvmArguments = ((List) ((Map) forgeJson.arguments).jvm) + .collect { String argument -> + argument.replace('${library_directory}', libraryDirectory) + .replace('${classpath_separator}', File.pathSeparator) + .replace('${version_name}', (String) forgeJson.inheritsFrom) + } + + List gameArguments = [] + gameArguments.addAll((List) ((Map) forgeJson.arguments).game) + gameArguments.addAll([ + '--username', 'OreSpawnValidation', + '--version', (String) forgeJson.id, + '--gameDir', packagedClientRunDirectory.absolutePath, + '--assetsDir', new File(runtime, 'assets').absolutePath, + '--assetIndex', (String) ((Map) baseJson.assetIndex).id, + '--uuid', '00000000-0000-0000-0000-000000000001', + '--accessToken', 'validation-token', + '--userType', 'legacy', + '--versionType', 'release', + '--width', '854', '--height', '480' + ]) + workingDir packagedClientRunDirectory + File clientJava = packagedClientJavaExecutable.isPresent() + ? file(packagedClientJavaExecutable.get()) + : java17Launcher.get().executablePath.asFile + if (!clientJava.isFile()) { + throw new GradleException("packagedClientJavaExecutable does not name a Java executable: ${clientJava}") + } + List clientCommand = [ + clientJava.absolutePath, + '-Xms512m', '-Xmx2g', '-Dclientprobe.enabled=true', '-Dclientprobe.hidden=true', + "-Djava.library.path=${nativesDirectory.absolutePath}", + '-Dminecraft.launcher.brand=orespawn-validation', + '-Dminecraft.launcher.version=1' + ] + clientCommand.addAll(forgeJvmArguments) + clientCommand.addAll([ + '-cp', classpathFiles.collect { it.absolutePath }.join(File.pathSeparator), + (String) forgeJson.mainClass + ]) + clientCommand.addAll(gameArguments) + commandLine(clientCommand) + } +} +tasks.register('packagedClientIntegrationTest') { + group = 'verification' + dependsOn packagedClientProcess + doLast { + File marker = new File(packagedClientRunDirectory, 'client-smoke-pass.properties') + if (!marker.isFile()) { + throw new GradleException('Packaged client completion marker is missing') + } + Properties values = new Properties() + marker.withInputStream { values.load(it) } + ['world_settings_opened', 'long_editor_roundtrip', + 'first_world_rendered', 'reload_rendered'].each { key -> + if (values.getProperty(key) != 'true') { + throw new GradleException("Packaged client failed ${key}: ${values}") + } + } + assertDocumentationTree(new File(packagedClientRunDirectory, + 'config/orespawn-guide'), documentationFiles(), 'runtime guide export') + assertRuntimeLogsClean(packagedClientRunDirectory, + 'packaged client', [] as Set) + } +} + +tasks.register('packagedRuntimeIntegrationTest') { + group = 'verification' + description = 'Runs the exact reobfuscated jars in official Forge 40 server and client runtimes.' + dependsOn tasks.named('packagedSurfaceIntegrationTest') + dependsOn tasks.named('packagedClientIntegrationTest') +} diff --git a/gradle/verification/performance.gradle b/gradle/verification/performance.gradle new file mode 100644 index 00000000..b46d2013 --- /dev/null +++ b/gradle/verification/performance.gradle @@ -0,0 +1,182 @@ +// Each mode gets a fresh world with the same seed and Java runtime. The game +// measures separate 81-chunk areas after its own short warm-up. +def benchmarkRuntime = providers.gradleProperty('packagedForgeServerRuntime') +def baselineJar = providers.gradleProperty('benchmarkOreSpawnJar') +def repetitions = providers.gradleProperty('benchmarkRepetitions').orElse('3') +def withWorldHash = providers.gradleProperty('benchmarkWorldHash') + .map { it.toBoolean() }.orElse(false).get() +def runLabel = baselineJar.isPresent() ? 'baseline-' : '' +def benchmarkModes = ['vanilla', 'cyano', 'sky'] +def previousRun = null +// Either sibling may be available on a target; keep the benchmark usable when +// only Mineralogy has a finished jar for this Minecraft version. + +def hashClasses = file("${buildDir}/world-hash-fixture/classes") +def compileHash = tasks.register('compileWorldHashProbe', JavaCompile) { + dependsOn tasks.named('classes') + source fileTree('src/worldHashTest/java') + classpath = files(sourceSets.main.output, sourceSets.main.compileClasspath) + destinationDirectory = hashClasses + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + options.encoding = 'UTF-8' +} +def hashJar = tasks.register('worldHashProbeJar', Jar) { + dependsOn compileHash + archiveFileName = 'worldhashprobe.jar' + destinationDirectory = file("${buildDir}/world-hash-fixture") + from hashClasses + from 'src/worldHashTest/resources' +} +def packagedHashJar = renamer.classes(hashJar) { + map.from minecraft.dependency.toSrgFile + output = layout.buildDirectory.file('world-hash-fixture/worldhashprobe-reobf.jar') +} + +benchmarkModes.each { String mode -> + File runDirectory = file("${buildDir}/worldgen-benchmark-${runLabel}${mode}") + def priorRun = previousRun + def prepare = tasks.register("prepareWorldgenBenchmark${mode.capitalize()}") { + if (!baselineJar.isPresent()) dependsOn project.ext.oreSpawnBuild.releaseJar + if (withWorldHash) dependsOn packagedHashJar + doLast { + delete runDirectory + runDirectory.mkdirs() + project.ext.oreSpawnBuild.prepareTestForgeConfig(runDirectory) + copy { + if (baselineJar.isPresent()) { + File oreSpawn = file(baselineJar.get()) + if (!oreSpawn.isFile()) throw new GradleException("Missing baseline jar: ${oreSpawn}") + from oreSpawn + } else { + from project.ext.oreSpawnBuild.releaseJar + } + ['packagedBaseMetalsJar', 'packagedMineralogyJar'].each { property -> + if (providers.gradleProperty(property).isPresent()) { + File sibling = file(providers.gradleProperty(property).get()) + if (!sibling.isFile()) throw new GradleException("Missing ${property}: ${sibling}") + from sibling + } + } + if (withWorldHash) from packagedHashJar + into new File(runDirectory, 'mods') + } + new File(runDirectory, 'server.properties').setText('''\ +level-name=benchmark-world +level-seed=-4965128775892001975 +level-type=default +online-mode=false +server-port=0 +allow-nether=false +generate-structures=false +spawn-protection=0 +max-tick-time=-1 +''', 'UTF-8') + new File(runDirectory, 'eula.txt').setText('eula=true\n', 'UTF-8') + } + } + def run = tasks.register("worldgenBenchmark${mode.capitalize()}", Exec) { + group = 'verification' + dependsOn prepare + if (priorRun != null) dependsOn priorRun + doFirst { + if (!benchmarkRuntime.isPresent()) { + throw new GradleException('Pass -PpackagedForgeServerRuntime=') + } + workingDir runDirectory + commandLine project.ext.oreSpawnBuild.forge40ServerCommand( + file(benchmarkRuntime.get()), runDirectory, + ["-Dorespawn.worldgenBenchmarkMode=${mode}", + '-Dorespawn.worldgenBenchmarkStopServer=true', + "-Dorespawn.worldgenBenchmarkRepetitions=${repetitions.get()}"]) + } + doLast { + File log = new File(runDirectory, 'logs/latest.log') + if (!log.isFile() || !log.text.contains("ORESPAWN_BENCHMARK summary mode=${mode}")) { + throw new GradleException("No completed ${mode} benchmark summary") + } + if (withWorldHash && !new File(runDirectory, + 'benchmark-world/worldhashprobe.properties').isFile()) { + throw new GradleException("No completed ${mode} world hash") + } + project.ext.oreSpawnBuild.assertRuntimeLogsClean(runDirectory, + "${mode} packaged benchmark", [] as Set) + } + } + previousRun = run +} + +tasks.register('packagedWorldgenPerformanceTest') { + group = 'verification' + description = 'Compares vanilla, Cyano and Sky worldgen on matching fresh worlds.' + dependsOn previousRun + doLast { + Map medians = [:] + benchmarkModes.each { String mode -> + File log = file("${buildDir}/worldgen-benchmark-${runLabel}${mode}/logs/latest.log") + def match = log.text =~ /ORESPAWN_BENCHMARK summary mode=${mode} .*median_ms_per_chunk=([0-9.]+)/ + if (!match.find()) throw new GradleException("Missing ${mode} median") + medians[mode] = Double.parseDouble(match.group(1)) + } + ['vanilla', 'cyano'].each { String control -> + double overhead = ((medians.sky / medians[control]) - 1.0D) * 100.0D + logger.lifecycle('Sky worldgen versus {}: {}% ({} versus {} ms/chunk)', + control, String.format(java.util.Locale.ROOT, '%.2f', overhead), + medians.sky, medians[control]) + if (overhead > 25.0D) throw new GradleException( + "Sky worldgen exceeds the 25% ceiling versus ${control}") + if (overhead > 10.0D) logger.warn( + 'Sky worldgen exceeds the 10% investigation threshold versus {}', control) + } + } +} + +tasks.register('verifyWorldgenParity') { + group = 'verification' + description = 'Compares biome maps, managed ore inventories and geology distributions with 4.0.16.' + doLast { + benchmarkModes.each { String mode -> + File oldMarker = file("${buildDir}/worldgen-benchmark-baseline-${mode}/benchmark-world/worldhashprobe.properties") + File newMarker = file("${buildDir}/worldgen-benchmark-${mode}/benchmark-world/worldhashprobe.properties") + if (!oldMarker.isFile() || !newMarker.isFile()) { + throw new GradleException("Run both world-hash benchmarks before comparing ${mode}") + } + Properties oldHashes = new Properties() + Properties newHashes = new Properties() + oldMarker.withInputStream { oldHashes.load(it) } + newMarker.withInputStream { newHashes.load(it) } + if (oldHashes.getProperty('chunks') != '81' || newHashes.getProperty('chunks') != '81' + || oldHashes.getProperty('biomes_sha256') != newHashes.getProperty('biomes_sha256')) { + throw new GradleException("${mode} biome map differs from 4.0.16") + } + // On Forge 40, two runs of the same 4.0.16 jar can finish with + // different individual block positions after decoration. The + // managed ore counts stay exact; rock and oil counts vary slightly. + long oldManaged = Long.parseLong(oldHashes.getProperty('managed_block_count')) + long newManaged = Long.parseLong(newHashes.getProperty('managed_block_count')) + if (Math.abs(newManaged - oldManaged) > Math.max(1L, Math.ceil(oldManaged * 0.0025D))) { + throw new GradleException("${mode} managed-block total moved beyond control-run variation") + } + Set oldTypes = oldHashes.stringPropertyNames().findAll { + it.startsWith('count.mineralogy:') || it.startsWith('count.orespawn:') + } as Set + Set newTypes = newHashes.stringPropertyNames().findAll { + it.startsWith('count.mineralogy:') || it.startsWith('count.orespawn:') + } as Set + if (oldTypes != newTypes) { + throw new GradleException("${mode} managed block identities differ from 4.0.16") + } + oldTypes.each { String type -> + long oldCount = Long.parseLong(oldHashes.getProperty(type)) + long newCount = Long.parseLong(newHashes.getProperty(type)) + if (type.endsWith('_ore')) { + if (oldCount != newCount) throw new GradleException( + "${mode} managed ore inventory differs for ${type}") + } else if (Math.abs(newCount - oldCount) > Math.max(75L, Math.ceil(oldCount * 0.01D))) { + throw new GradleException("${mode} managed geology differs for ${type}") + } + } + logger.lifecycle('{} biome map and ore inventories match; geology is within same-version variation', mode) + } + } +} diff --git a/gradle/verification/support.gradle b/gradle/verification/support.gradle new file mode 100644 index 00000000..0ed55231 --- /dev/null +++ b/gradle/verification/support.gradle @@ -0,0 +1,196 @@ +// A Forge process is not green merely because it returns exit code zero. The +// loader can log a worldgen/linkage failure and still shut down normally. +def acceptedForge40LogNoise = [ + ~/FML appears to be missing any signature data/, + ~/Found multiple arguments for option fml\.mcVersion/, + ~/Found multiple arguments for option fml\.forgeVersion/, + ~/\/ERROR\] \[net\.minecraft\.client\.Minecraft\/\]: Failed to verify authentication$/, + ~/\/(?:ERROR|FATAL)\] \[net\.minecraftforge\.fml\.network\.simple\.IndexedMessageCodec\/SIMPLENET\]: Received empty payload on channel fml:handshake$/, + ~/\/FATAL\] \[net\.minecraftforge\.common\.ForgeConfig\/CORE\]: Forge config just got changed on the file system!$/, + ~/\/FATAL\] \[net\.minecraftforge\.fml\.packs\.ModFileResourcePack\/\]: Failed to clean up tempdir / +] + +def runtimeCrashSnapshot = { File runDirectory -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (!crashDirectory.isDirectory()) return [] as Set + return fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() }.collect { it.absolutePath } as Set +} + +def assertRuntimeLogsClean = { File runDirectory, String context, Set priorCrashes, + List expectedErrors = [] -> + File crashDirectory = new File(runDirectory, 'crash-reports') + if (crashDirectory.isDirectory()) { + def crashes = fileTree(crashDirectory) { include '**/*' }.files + .findAll { it.isFile() && !priorCrashes.contains(it.absolutePath) } + if (!crashes.isEmpty()) { + throw new GradleException("${context} produced crash report ${crashes.first()}") + } + } + + File logsDirectory = new File(runDirectory, 'logs') + if (!logsDirectory.isDirectory()) return + def failures = [] + [new File(logsDirectory, 'latest.log'), new File(logsDirectory, 'debug.log')] + .findAll { it.isFile() }.each { File log -> + int lineNumber = 0 + log.eachLine('UTF-8') { String line -> + lineNumber++ + boolean unexpectedSeverity = line ==~ /.*\/(?:ERROR|FATAL)\].*/ + boolean knownNoise = acceptedForge40LogNoise.any { line =~ it } || + expectedErrors.any { line.contains(it) } + boolean fatalText = line.contains('Encountered an unexpected exception') || + line.contains('Exception stopping the server') || + line.contains('Migration audit failed') || + line.contains('java.lang.Error:') || + line.contains('NoSuchMethodError') || + line.contains('NoClassDefFoundError') || + line.contains('ExceptionInInitializerError') || + line.contains('com.electronwill.nightconfig.core.io.ParsingException:') || + line.contains('Tried to assign a mutable BlockPos') || + line.contains('causing cascading worldgen lag') + if ((unexpectedSeverity && !knownNoise) || fatalText) { + failures.add("${log.name}:${lineNumber}: ${line}") + } + } + } + if (!failures.isEmpty()) { + throw new GradleException("${context} logged unexpected errors:\n" + + failures.take(20).join('\n')) + } +} + +// Complete first-run configs avoid a race with NightConfig's file watcher. +// Existing configs and manual runs are left alone. +def prepareTestForgeConfig = { File directory -> + String buildRoot = layout.buildDirectory.get().asFile.canonicalPath + File.separator + if (!directory.canonicalPath.startsWith(buildRoot)) { + throw new GradleException('Test configuration must stay inside the build directory') + } + File config = new File(directory, 'config/fml.toml') + if (!config.exists()) { + config.parentFile.mkdirs() + config.setText('''\ +versionCheck = true +splashscreen = true +defaultConfigPath = "defaultconfigs" +maxThreads = -1 +''', 'UTF-8') + } +} +project.ext.oreSpawnBuild.prepareTestForgeConfig = prepareTestForgeConfig + +task runtimeLogScannerTest { + group = 'verification' + description = 'Proves runtime log validation accepts documented Forge noise and rejects real failures.' + doLast { + File probe = file("${buildDir}/runtime-log-scanner-test") + delete probe + File logs = new File(probe, 'logs'); logs.mkdirs() + new File(logs, 'latest.log').setText( + '[main/ERROR] [FML]: FML appears to be missing any signature data\n' + + '[Render thread/ERROR] [net.minecraft.client.Minecraft/]: Failed to verify authentication\n' + + '[Client thread/ERROR] [net.minecraftforge.fml.network.simple.IndexedMessageCodec/SIMPLENET]: Received empty payload on channel fml:handshake\n' + + '[Server thread/INFO] [FML]: Done\n', 'UTF-8') + assertRuntimeLogsClean(probe, 'scanner-accepted-noise-probe', [] as Set) + new File(logs, 'latest.log').setText( + '[Server thread/WARN]: Tried to assign a mutable BlockPos to tick data...\n', 'UTF-8') + boolean rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-mutable-position-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a mutable BlockPos leak') + new File(logs, 'latest.log').setText( + '[Server thread/DEBUG] [FML]: Minecraft loaded a new chunk while populating another, causing cascading worldgen lag.\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-cascading-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted cascading worldgen') + new File(logs, 'latest.log').setText( + '[Server thread/ERROR] [example]: Unexpected fixture failure\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-severity-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted an unexpected ERROR line') + new File(logs, 'latest.log').setText( + '[Thread-1/INFO] [STDERR]: com.electronwill.nightconfig.core.io.ParsingException: Invalid TOML data\n', 'UTF-8') + rejected = false + try { assertRuntimeLogsClean(probe, 'scanner-config-parse-probe', [] as Set) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Runtime log scanner accepted a configuration parsing exception') + prepareTestForgeConfig(probe) + File fmlConfig = new File(probe, 'config/fml.toml') + String defaults = fmlConfig.getText('UTF-8') + ['versionCheck = true', 'splashscreen = true', + 'defaultConfigPath = "defaultconfigs"', 'maxThreads = -1'].each { String value -> + if (defaults.readLines().count(value) != 1) throw new GradleException("Missing Forge default: ${value}") + } + fmlConfig.setText('maxThreads = 3\n', 'UTF-8') + prepareTestForgeConfig(probe) + if (fmlConfig.getText('UTF-8') != 'maxThreads = 3\n') { + throw new GradleException('Test preparation overwrote an existing Forge configuration') + } + rejected = false + try { prepareTestForgeConfig(file('run')) } + catch (GradleException expected) { rejected = true } + if (!rejected) throw new GradleException('Test configuration escaped the build directory') + delete probe + } +} + +check.dependsOn runtimeLogScannerTest + +task verifyMineralogyOracleIsolation { + group = 'verification' + description = 'Prevents published Mineralogy engines from leaking into Gradle configurations or ordinary Eclipse launches.' + doLast { + configurations.each { configuration -> + if (configuration.canBeResolved && + configuration.files.any { it.name ==~ /Mineralogy-.*\.jar/ }) { + throw new GradleException("Mineralogy oracle leaked into Gradle configuration ${configuration.name}") + } + } + } +} + +check.dependsOn verifyMineralogyOracleIsolation + +['runClient', 'runServer', 'runData'].each { String taskName -> + tasks.matching { it.name == taskName }.all { JavaExec runTask -> + doFirst { + new File(runTask.workingDir, 'mods').mkdirs() + runTask.ext.oreSpawnCrashSnapshot = runtimeCrashSnapshot(runTask.workingDir) + } + doLast { + assertRuntimeLogsClean(runTask.workingDir, taskName, + runTask.ext.oreSpawnCrashSnapshot as Set) + } + } +} + +project.ext.oreSpawnBuild.assertRuntimeLogsClean = assertRuntimeLogsClean + +// Fixture servers use the same official Forge argument file as packaged checks. +project.ext.oreSpawnBuild.forge40ServerCommand = { File runtime, File runDirectory, + List jvmOptions -> + File libraries = new File(runtime, 'libraries') + File forgeDirectory = new File(libraries, 'net/minecraftforge/forge/1.18.2-40.3.0') + File launcher = new File(forgeDirectory, 'forge-1.18.2-40.3.0-server.jar') + File sourceArguments = new File(forgeDirectory, + System.getProperty('os.name').toLowerCase(Locale.ROOT).contains('windows') + ? 'win_args.txt' : 'unix_args.txt') + [launcher, sourceArguments, libraries].each { + if (!it.exists()) throw new GradleException("Incomplete official Forge 40 runtime: ${it}") + } + File absoluteArguments = new File(runDirectory, 'forge-server-args.txt') + String libraryPath = libraries.canonicalPath.replace('\\', '/') + '/' + absoluteArguments.setText(sourceArguments.getText('UTF-8') + .replace('libraries/', libraryPath) + .replace('-DlibraryDirectory=libraries', + "-DlibraryDirectory=${libraries.canonicalPath}"), 'UTF-8') + List command = [project.ext.oreSpawnBuild.java17Launcher.get() + .executablePath.asFile.absolutePath, '-Xms512m', '-Xmx2g'] + command.addAll(jvmOptions) + command.add("@${absoluteArguments.absolutePath}") + command.add('nogui') + command +} diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java index 3c9f694f..fa82e592 100644 --- a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/ClientProbeTestMod.java @@ -102,6 +102,10 @@ private void handleClientTick() { Minecraft minecraft = Minecraft.getInstance(); if (++stateTicks > 3600) fail(minecraft, "Timed out in client probe state " + state); try { + if (Boolean.getBoolean("clientprobe.hidden")) { + Class.forName("org.lwjgl.glfw.GLFW").getMethod("glfwHideWindow", long.class) + .invoke(null, minecraft.getWindow().getWindow()); + } switch (state) { case 0: if (minecraft.screen instanceof TitleScreen) { @@ -132,6 +136,7 @@ && isWorldSettingsButton((AbstractWidget) child)) { case 2: if (minecraft.screen instanceof OreSpawnWorldSettingsScreen && editorFrames >= 2) { worldSettingsOpened = true; + if (Boolean.getBoolean("clientprobe.oreSources")) SharedOreClientProbe.verify(); validateCaptions((OreSpawnWorldSettingsScreen) minecraft.screen); validateLongEditorRoundTrip(minecraft, minecraft.screen); nextState(3); @@ -200,7 +205,7 @@ && isWorldSettingsButton((AbstractWidget) child)) { default: break; } - } catch (RuntimeException | IOException failure) { + } catch (RuntimeException | IOException | ReflectiveOperationException failure) { fail(minecraft, failure.toString()); } } @@ -220,16 +225,30 @@ private Button nextNavigationButton(OreSpawnWorldSettingsScreen root) { private static void validateCaptions(Screen screen) { for (AbstractWidget widget : widgets(screen)) { + // Text fields carry their value rather than a caption. Compact lists + // and cogs explain themselves through row text, icons, and tooltips. + if (!(widget instanceof Button) && !(widget instanceof CycleButton)) continue; + if (isVisualControl(widget)) continue; String caption = ChatFormatting.stripFormatting(widget.getMessage().getString()); if (caption == null || caption.trim().isEmpty() || caption.contains("options.generic_value") || caption.startsWith("button.orespawn.") || caption.startsWith("option.orespawn.")) { - throw new IllegalStateException("Invalid client caption: " + widget.getMessage()); + throw new IllegalStateException("Invalid client caption in " + + screen.getClass().getSimpleName() + " / " + + widget.getClass().getName() + ": " + widget.getMessage()); } } } + private static boolean isVisualControl(AbstractWidget widget) { + for (Class type = widget.getClass(); type != null; type = type.getSuperclass()) { + String name = type.getSimpleName(); + if ("CogButton".equals(name) || "CompactScrollList".equals(name)) return true; + } + return false; + } + private void validateLongEditorRoundTrip(Minecraft minecraft, Screen parent) { JsonObject root = WorldGeologyProfile.recommended(true).rootCopy(); JsonObject ores = new JsonObject(); diff --git a/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/SharedOreClientProbe.java b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/SharedOreClientProbe.java new file mode 100644 index 00000000..535a5220 --- /dev/null +++ b/src/clientIntegrationTest/java/zone/moddev/mc/orespawn/clientprobe/SharedOreClientProbe.java @@ -0,0 +1,49 @@ +package zone.moddev.mc.orespawn.clientprobe; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import zone.moddev.mc.orespawn.worldgen.GeomeConfig; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; + +/** Keeps test access separate from OreSpawn's packages in Forge 40's module layer. */ +final class SharedOreClientProbe { + private SharedOreClientProbe() { } + + static void verify() { + try { + Class editor = Class.forName("zone.moddev.mc.orespawn.client.GeologyEditorSession"); + java.lang.reflect.Constructor constructor = editor.getDeclaredConstructor(WorldGeologyProfile.class); + constructor.setAccessible(true); + Object session = constructor.newInstance(GeomeConfig.globalProfile()); + Method groups = editor.getDeclaredMethod("oreSourceGroups"); + groups.setAccessible(true); + for (Object group : (List) groups.invoke(session)) { + if (!"orespawn:sulfur".equals(field(group, "material"))) continue; + Set owners = new HashSet<>(); + for (Object candidate : (List) field(group, "candidates")) { + owners.add((String) field(candidate, "owner")); + } + Method outputs = group.getClass().getDeclaredMethod("outputCandidates"); + outputs.setAccessible(true); + if (!owners.contains("baseminerals") || !owners.contains("electricadvantage") + || ((List) outputs.invoke(group)).size() != 3) { + throw new IllegalStateException("The profile must expose both providers and all three outputs"); + } + System.out.println("ORE_SOURCES_CLIENT_PROBE providers=2 outputs=3 verified=true"); + return; + } + throw new IllegalStateException("Shared Sulfur group is missing"); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not inspect the shared-ore editor", failure); + } + } + + private static Object field(Object object, String name) throws ReflectiveOperationException { + Field field = object.getClass().getDeclaredField(name); + field.setAccessible(true); + return field.get(object); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java index 7e88c29d..bae7b203 100644 --- a/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java +++ b/src/main/java/zone/moddev/mc/orespawn/OreSpawn.java @@ -65,6 +65,7 @@ public OreSpawn() { MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onChunkLoad); MinecraftForge.EVENT_BUS.addListener(WorldMaterialWeather::onWorldTick); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerAboutToStart); + MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStarting); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onWorldLoad); MinecraftForge.EVENT_BUS.addListener(WorldGeologyProfileManager::onServerStopped); MinecraftForge.EVENT_BUS.addListener(OreRetrogenManager::onChunkLoad); diff --git a/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java new file mode 100644 index 00000000..88be3e79 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/OreGenerationContext.java @@ -0,0 +1,46 @@ +package zone.moddev.mc.orespawn.api; + +import java.util.Optional; + +import net.minecraft.resources.ResourceLocation; + +/** + * Extended placement context supplied by OreSpawn 4.1 and later. + * + *

The identity values are stable for the current chunk in both ordinary + * generation and supported retrogen. Patterns should combine them with their + * own stable definition identifier and salt instead of relying on invocation + * order or the mutable state of {@link #random()}.

+ * + *

This is a secondary interface so implementations compiled against the + * original {@link OrePlacementContext} contract remain binary compatible.

+ */ +public interface OreGenerationContext extends OrePlacementContext { + /** + * Attempts placement using a stable identity shared by every slice of one + * logical deposit body. Implementations compiled before this overload retain + * the original placement behaviour. + */ + default boolean tryPlace(int x, int y, int z, long outputIdentity) { + return tryPlace(x, y, z); + } + + /** Stable seed of the world being generated. */ + long worldSeed(); + + /** Stable namespaced identity of the dimension being generated. */ + ResourceLocation dimension(); + + /** X coordinate of the current chunk, not its minimum block coordinate. */ + int chunkX(); + + /** Z coordinate of the current chunk, not its minimum block coordinate. */ + int chunkZ(); + + /** + * Returns the active allocation-light geology sampler when the dimension has + * an OreSpawn geology configuration. Pattern implementations should cache no + * world or implementation objects beyond the placement call. + */ + Optional geologySampler(); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java index a44c8b6a..9b9c2d68 100644 --- a/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java +++ b/src/main/java/zone/moddev/mc/orespawn/api/WorldgenProvider.java @@ -9,11 +9,13 @@ import java.util.Map; import java.util.Map.Entry; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.function.Consumer; import com.google.gson.JsonArray; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; import net.minecraft.resources.ResourceLocation; @@ -45,7 +47,7 @@ public int revision() { return revision; } - /** Returns a defensive JSON representation matching provider schema 4. */ + /** Returns a defensive JSON representation matching provider schema 5. */ public JsonObject toJson() { return definition.deepCopy(); } @@ -207,7 +209,7 @@ public WorldgenProvider build() { requireOwned(dimensionMaterials.keySet(), "dimension materials"); requireOwned(templates.keySet(), "template"); JsonObject root = new JsonObject(); - root.addProperty("schema_version", 4); + root.addProperty("schema_version", 5); root.addProperty("provider_modid", modId); root.addProperty("provider_revision", revision); root.add("rocks", object(rocks)); @@ -345,6 +347,7 @@ public RockDefinition build() { public static final class OreDefinition implements JsonDefinition { private final ResourceLocation id; private final ResourceLocation block; + private final ResourceLocation material; private final boolean enabled; private final boolean nativeGeneration; private final ResourceLocation deepOutput; @@ -358,6 +361,7 @@ public static final class OreDefinition implements JsonDefinition { private OreDefinition(Builder builder) { id = builder.id; block = builder.block; + material = builder.material; enabled = builder.enabled; nativeGeneration = builder.nativeGeneration; deepOutput = builder.deepOutput; @@ -373,6 +377,8 @@ private OreDefinition(Builder builder) { public static Builder builder(ResourceLocation id, ResourceLocation block) { return new Builder(id, block); } public ResourceLocation id() { return id; } public ResourceLocation block() { return block; } + /** Material shared by interchangeable ore outputs. */ + public Optional material() { return Optional.ofNullable(material); } public boolean enabled() { return enabled; } public List outputs() { return outputs; } public boolean suppressVanilla() { return suppressVanilla; } @@ -384,6 +390,7 @@ private OreDefinition(Builder builder) { public JsonObject toJson() { JsonObject json = new JsonObject(); json.addProperty("block", block.toString()); + if (material != null) json.addProperty("material", material.toString()); json.addProperty("enabled", enabled); json.addProperty("native_generation", nativeGeneration); json.addProperty("suppress_vanilla", suppressVanilla); @@ -409,6 +416,7 @@ public JsonObject toJson() { public static final class Builder { private final ResourceLocation id; private final ResourceLocation block; + private ResourceLocation material; private boolean enabled = true; private boolean nativeGeneration; private ResourceLocation deepOutput; @@ -425,6 +433,7 @@ private Builder(ResourceLocation id, ResourceLocation block) { this.block = Objects.requireNonNull(block, "block"); } public Builder enabled(boolean value) { enabled = value; return this; } + public Builder material(ResourceLocation value) { material = Objects.requireNonNull(value); return this; } public Builder nativeGeneration(boolean value) { nativeGeneration = value; return this; } public Builder suppressVanilla(boolean value) { suppressVanilla = value; return this; } public Builder retrogen(boolean value) { retrogen = value; return this; } @@ -516,6 +525,7 @@ public static final class OreDimensionDefinition implements JsonDefinition { private final int maxQuantity; private final OrePattern pattern; private final ResourceLocation patternType; + private final ResourceLocation placementChannel; private final JsonObject patternSettings; private final OreHeightDistribution heightDistribution; private final double discardChanceOnAirExposure; @@ -543,7 +553,10 @@ private OreDimensionDefinition(Builder builder) { maxQuantity = builder.maxQuantity; pattern = builder.pattern; patternType = builder.patternType; - patternSettings = builder.patternSettings.deepCopy(); + placementChannel = builder.placementChannel != null ? builder.placementChannel + : builder.patternType != null ? builder.patternType + : new ResourceLocation("orespawn", "standard"); + patternSettings = JsonCopies.copy(builder.patternSettings); heightDistribution = builder.heightDistribution; discardChanceOnAirExposure = builder.discardChanceOnAirExposure; spread = builder.spread; @@ -574,7 +587,9 @@ private OreDimensionDefinition(Builder builder) { public int maxQuantity() { return maxQuantity; } public OrePattern pattern() { return pattern; } public ResourceLocation patternType() { return patternType; } - public JsonObject patternSettings() { return patternSettings.deepCopy(); } + /** Placement frequency, shape and host budget are selected per channel. */ + public ResourceLocation placementChannel() { return placementChannel; } + public JsonObject patternSettings() { return JsonCopies.copy(patternSettings); } public OreHeightDistribution heightDistribution() { return heightDistribution; } public double discardChanceOnAirExposure() { return discardChanceOnAirExposure; } public int spread() { return spread; } @@ -612,6 +627,7 @@ public JsonObject toJson() { configuredPattern.add("settings", patternSettings.deepCopy()); json.add("pattern", configuredPattern); } + json.addProperty("placement_channel", placementChannel.toString()); json.addProperty("height_distribution", heightDistribution.configName()); json.addProperty("discard_chance_on_air_exposure", discardChanceOnAirExposure); json.addProperty("spread", spread); @@ -640,6 +656,7 @@ public static final class Builder { private int maxQuantity = 8; private OrePattern pattern = OrePattern.VEIN; private ResourceLocation patternType; + private ResourceLocation placementChannel; private JsonObject patternSettings = new JsonObject(); private OreHeightDistribution heightDistribution = OreHeightDistribution.UNIFORM; private double discardChanceOnAirExposure; @@ -676,6 +693,10 @@ public Builder pattern(ResourceLocation type, JsonObject settings) { patternSettings = Objects.requireNonNull(settings, "settings").deepCopy(); return this; } + public Builder placementChannel(ResourceLocation value) { + placementChannel = Objects.requireNonNull(value); + return this; + } public Builder heightDistribution(OreHeightDistribution value) { heightDistribution = Objects.requireNonNull(value); return this; } public Builder discardChanceOnAirExposure(double value) { discardChanceOnAirExposure = value; return this; } public Builder spread(int horizontal, int vertical) { spread = horizontal; verticalSpread = vertical; return this; } diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java new file mode 100644 index 00000000..439c733d --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtension.java @@ -0,0 +1,32 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.Objects; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Immutable registered action displayed by OreSpawn's world-settings screen. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtension { + private final ResourceLocation id; + private final String buttonTranslationKey; + private final WorldSettingsScreenFactory screenFactory; + + WorldSettingsExtension(ResourceLocation id, String buttonTranslationKey, + WorldSettingsScreenFactory screenFactory) { + this.id = Objects.requireNonNull(id, "id"); + this.buttonTranslationKey = Objects.requireNonNull(buttonTranslationKey, + "buttonTranslationKey"); + this.screenFactory = Objects.requireNonNull(screenFactory, "screenFactory"); + } + + public ResourceLocation id() { return id; } + public String buttonTranslationKey() { return buttonTranslationKey; } + + /** Creates the extension screen with OreSpawn's Mods directory as its parent. */ + public Screen createScreen(Screen parent) { + return screenFactory.create(Objects.requireNonNull(parent, "parent")); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java new file mode 100644 index 00000000..3db684ce --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistry.java @@ -0,0 +1,79 @@ +package zone.moddev.mc.orespawn.api.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Pattern; + +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Client-only registry for add-on screens in OreSpawn's Mods directory. */ +@OnlyIn(Dist.CLIENT) +public final class WorldSettingsExtensionRegistry { + private static final Pattern MOD_ID = Pattern.compile("^[a-z][a-z0-9_.-]{1,63}$"); + private static final Map EXTENSIONS = + new LinkedHashMap<>(); + + private WorldSettingsExtensionRegistry() { + } + + /** + * Registers the one optional OreSpawn-linked configuration screen owned by a + * loaded mod. Registration must occur during client initialization. + * + * @throws IllegalArgumentException if the mod identifier is not canonical + * @throws IllegalStateException if the mod already registered a screen + */ + public static synchronized WorldSettingsExtension registerConfigScreen(String modId, + WorldSettingsScreenFactory screenFactory) { + validateModId(modId); + return registerOwned(modId, new ResourceLocation(modId, "configuration"), + "button.orespawn.mod.configure", screenFactory); + } + + /** + * Retains the original translated world-settings registration descriptor for + * existing add-ons. The identifier namespace owns the one permitted screen. + * Registration must occur during client initialization. + * + * @throws IllegalStateException if the identifier is already registered + */ + public static synchronized WorldSettingsExtension register(ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(id, "id"); + if (buttonTranslationKey == null || buttonTranslationKey.trim().isEmpty()) { + throw new IllegalArgumentException("buttonTranslationKey cannot be blank"); + } + String modId = id.getNamespace(); + validateModId(modId); + return registerOwned(modId, id, buttonTranslationKey, screenFactory); + } + + private static WorldSettingsExtension registerOwned(String modId, ResourceLocation id, + String buttonTranslationKey, WorldSettingsScreenFactory screenFactory) { + Objects.requireNonNull(screenFactory, "screenFactory"); + if (EXTENSIONS.containsKey(modId)) { + throw new IllegalStateException("Duplicate OreSpawn configuration screen for mod: " + modId); + } + WorldSettingsExtension extension = new WorldSettingsExtension(id, buttonTranslationKey, + screenFactory); + EXTENSIONS.put(modId, extension); + return extension; + } + + private static void validateModId(String modId) { + if (modId == null || !MOD_ID.matcher(modId).matches()) { + throw new IllegalArgumentException("modId must be a canonical lower-case mod identifier"); + } + } + + /** Returns an immutable snapshot in deterministic registration order. */ + public static synchronized List extensions() { + return Collections.unmodifiableList(new ArrayList<>(EXTENSIONS.values())); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java new file mode 100644 index 00000000..03d3f7d5 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/api/client/WorldSettingsScreenFactory.java @@ -0,0 +1,12 @@ +package zone.moddev.mc.orespawn.api.client; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.api.distmarker.OnlyIn; + +/** Creates an add-on configuration screen whose Done action returns to OreSpawn's mod directory. */ +@FunctionalInterface +@OnlyIn(Dist.CLIENT) +public interface WorldSettingsScreenFactory { + Screen create(Screen parent); +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java new file mode 100644 index 00000000..16e85f1a --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModel.java @@ -0,0 +1,344 @@ +package zone.moddev.mc.orespawn.client; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; +import zone.moddev.mc.orespawn.util.JsonCopies; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.biome.Biome; +import net.minecraftforge.common.BiomeDictionary; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.forgespi.language.IModInfo; +import net.minecraftforge.registries.ForgeRegistries; + +/** Immutable projection of loaded biomes, profile palettes, and provider defaults. */ +final class BiomeDirectoryModel { + private static final String OVERWORLD = "minecraft:overworld"; + private static final String NETHER = "minecraft:the_nether"; + private static final String END = "minecraft:the_end"; + private static final Method BIOME_NAME = findBiomeNameMethod(); + + private BiomeDirectoryModel() { } + + static Snapshot snapshot(JsonObject profile, BiomeProviderDefaultsSnapshot defaults, + Iterable dimensions) { + List loaded = new ArrayList<>(); + Map mods = new LinkedHashMap<>(); + for (IModInfo mod : ModList.get().getMods()) { + mods.put(mod.getModId(), new ModInfo(mod.getModId(), mod.getDisplayName(), + mod.getVersion().toString())); + } + mods.putIfAbsent("minecraft", new ModInfo("minecraft", "Minecraft", "1.18.2")); + for (Entry, Biome> entry : ForgeRegistries.BIOMES.getEntries()) { + ResourceLocation id = entry.getKey().location(); + ModInfo owner = mods.get(id.getNamespace()); + loaded.add(new LoadedBiome(id.toString(), biomeName(id, entry.getValue()), + owner == null ? id.getNamespace() : owner.name, + owner == null ? "?" : owner.version, routineDimension(entry.getKey()))); + } + return assemble(profile, defaults.biomePalettesCopy(), + defaults.activeProviderIds(), loaded, dimensions); + } + + private static String biomeName(ResourceLocation id, Biome biome) { + if (BIOME_NAME == null) return friendly(id.toString()); + try { + return (String) BIOME_NAME.invoke(biome); + } catch (IllegalAccessException | InvocationTargetException exception) { + return friendly(id.toString()); + } + } + + private static Method findBiomeNameMethod() { + for (String name : new String[] { "getBiomeName", "func_185359_l" }) { + try { + Method method = Biome.class.getDeclaredMethod(name); + method.setAccessible(true); + return method; + } catch (NoSuchMethodException ignored) { + // Use the registry name when this runtime has no public biome-name method. + } + } + return null; + } + + static Snapshot assemble(JsonObject profile, JsonObject providerPalettes, + Set activeProviders, List loadedBiomes, + Iterable requestedDimensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedBiome biome : loadedBiomes) loaded.put(biome.id, biome); + Set dimensions = new LinkedHashSet<>(); + for (String dimension : requestedDimensions) dimensions.add(dimension); + JsonObject profilePalettes = object(profile, "biome_palettes"); + List palettes = new ArrayList<>(); + int order = 0; + for (Entry entry : profilePalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", "minecraft:overworld"); + dimensions.add(dimension); + palettes.add(new Palette(entry.getKey(), dimension, + string(palette, "source_provider", owner(entry.getKey())), + order++, bool(palette, "enabled", true), + BiomeReplacementRules.isOverridePalette(entry.getKey()), + palette.has("source_provider"), palette)); + } + + Map> providerBiomes = providerBiomes(providerPalettes); + Map> declaredDimensions = declaredDimensions(palettes, providerBiomes); + List entries = new ArrayList<>(); + for (String dimension : dimensions) { + Map> placements = placements(palettes, dimension); + Map replacements = BiomeReplacementRules.read(profile, dimension); + Set ids = new LinkedHashSet<>(); + for (LoadedBiome biome : loaded.values()) { + Set declared = declaredDimensions.getOrDefault(biome.id, Collections.emptySet()); + if (declared.isEmpty() ? dimension.equals(biome.routineDimension) + : declared.contains(dimension)) ids.add(biome.id); + } + ids.addAll(placements.keySet()); + ids.addAll(providerBiomes.getOrDefault(dimension, Collections.emptySet())); + ids.addAll(replacements.keySet()); + ids.addAll(replacements.values()); + for (String id : ids) { + LoadedBiome biome = loaded.get(id); + List rules = placements.getOrDefault(id, Collections.emptyList()); + boolean managed = providerBiomes.getOrDefault(dimension, Collections.emptySet()).contains(id); + boolean enabled = false; + boolean missingProvider = false; + for (Placement placement : rules) enabled |= placement.enabled; + for (Placement placement : rules) missingProvider |= placement.providerOwned + && !activeProviders.contains(placement.owner); + Status status; + if (biome == null || missingProvider) status = Status.MISSING; + else if (replacements.containsKey(id)) status = Status.USER_REPLACED; + else if (rules.size() > 1) status = Status.LAYERED; + else if (!rules.isEmpty() && !enabled) status = Status.DISABLED; + else if (managed || !rules.isEmpty()) status = Status.PROVIDER_MANAGED; + else status = Status.UNMANAGED; + entries.add(new BiomeEntry(dimension, id, + biome == null ? friendly(id) : biome.name, + biome == null ? owner(id) : biome.ownerName, + biome == null ? "?" : biome.ownerVersion, + status, rules, replacements.get(id), managed)); + } + } + Comparator entryOrder = Comparator + .comparingInt((BiomeEntry entry) -> entry.status.priority) + .thenComparing(entry -> entry.name.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.id) + .thenComparing(entry -> entry.dimension); + entries.sort(entryOrder); + palettes.sort(Comparator.comparingInt(palette -> palette.order)); + return new Snapshot(entries, palettes, dimensions, activeProviders); + } + + static String routineDimension(ResourceKey biome) { + if (BiomeDictionary.hasType(biome, BiomeDictionary.Type.NETHER)) return NETHER; + if (BiomeDictionary.hasType(biome, BiomeDictionary.Type.END)) return END; + return OVERWORLD; + } + + private static Map> declaredDimensions(List palettes, + Map> providerBiomes) { + Map> result = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (palette.override) continue; + for (String id : JsonCopies.keys(object(palette.definition, "biomes"))) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(palette.dimension); + } + } + for (Entry> dimension : providerBiomes.entrySet()) { + for (String id : dimension.getValue()) { + result.computeIfAbsent(id, ignored -> new LinkedHashSet<>()).add(dimension.getKey()); + } + } + return result; + } + + private static Map> providerBiomes(JsonObject providerPalettes) { + Map> result = new LinkedHashMap<>(); + for (Entry entry : providerPalettes.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject palette = entry.getValue().getAsJsonObject(); + String dimension = string(palette, "dimension", ""); + Set values = result.computeIfAbsent(dimension, ignored -> new LinkedHashSet<>()); + for (Entry biome : object(palette, "biomes").entrySet()) { + values.add(biome.getKey()); + } + } + return result; + } + + private static Map> placements(List palettes, String dimension) { + Map> result = new LinkedHashMap<>(); + Map lastSurface = new LinkedHashMap<>(); + for (Palette palette : palettes) { + if (!dimension.equals(palette.dimension) || palette.override) continue; + for (Entry entry : object(palette.definition, "biomes").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject placement = entry.getValue().getAsJsonObject(); + List list = result.computeIfAbsent(entry.getKey(), ignored -> new ArrayList<>()); + boolean hasSurface = placement.has("surface") && placement.get("surface").isJsonObject() + && !placement.getAsJsonObject("surface").entrySet().isEmpty(); + list.add(new Placement(palette.id, palette.owner, palette.order, + palette.enabled && bool(placement, "enabled", true), hasSurface, false, + palette.providerOwned, placement)); + if (palette.enabled && bool(placement, "enabled", true) && hasSurface) { + lastSurface.put(entry.getKey(), list.size() - 1); + } + } + } + for (Entry> entry : result.entrySet()) { + Integer effective = lastSurface.get(entry.getKey()); + if (effective == null) continue; + List updated = new ArrayList<>(); + for (int i = 0; i < entry.getValue().size(); i++) { + Placement value = entry.getValue().get(i); + updated.add(value.withEffectiveSurface(i == effective)); + } + entry.setValue(updated); + } + return result; + } + + private static String owner(String id) { + try { return new ResourceLocation(id).getNamespace(); } + catch (RuntimeException ignored) { return "unknown"; } + } + + private static String friendly(String id) { + String value; + try { value = new ResourceLocation(id).getPath(); } + catch (RuntimeException ignored) { value = id; } + StringBuilder result = new StringBuilder(); + for (String part : value.replace('/', '_').split("_")) { + if (part.isEmpty()) continue; + if (result.length() > 0) result.append(' '); + result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1)); + } + return result.length() == 0 ? id : result.toString(); + } + + private static JsonObject object(JsonObject root, String key) { + return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject(); + } + private static String string(JsonObject root, String key, String fallback) { + return root.has(key) ? root.get(key).getAsString() : fallback; + } + private static boolean bool(JsonObject root, String key, boolean fallback) { + return root.has(key) ? root.get(key).getAsBoolean() : fallback; + } + + enum Status { + USER_REPLACED(0), LAYERED(1), DISABLED(2), MISSING(3), PROVIDER_MANAGED(4), UNMANAGED(5); + final int priority; + Status(int priority) { this.priority = priority; } + } + + static final class Snapshot { + final List entries; + final List palettes; + final Set dimensions; + final Set activeProviders; + Snapshot(List entries, List palettes, Set dimensions, + Set activeProviders) { + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + this.palettes = Collections.unmodifiableList(new ArrayList<>(palettes)); + this.dimensions = Collections.unmodifiableSet(new LinkedHashSet<>(dimensions)); + this.activeProviders = Collections.unmodifiableSet(new LinkedHashSet<>(activeProviders)); + } + List entries(String dimension, boolean showAll) { + List result = new ArrayList<>(); + for (BiomeEntry entry : entries) { + if (dimension.equals(entry.dimension) && (showAll || entry.status != Status.UNMANAGED)) result.add(entry); + } + return Collections.unmodifiableList(result); + } + List palettes(String dimension) { + List result = new ArrayList<>(); + for (Palette palette : palettes) if (dimension.equals(palette.dimension)) result.add(palette); + return Collections.unmodifiableList(result); + } + } + + static final class LoadedBiome { + final String id, name, ownerName, ownerVersion, routineDimension; + LoadedBiome(String id, String name, String ownerName, String ownerVersion) { + this(id, name, ownerName, ownerVersion, OVERWORLD); + } + LoadedBiome(String id, String name, String ownerName, String ownerVersion, + String routineDimension) { + this.id = id; this.name = name; this.ownerName = ownerName; this.ownerVersion = ownerVersion; + this.routineDimension = routineDimension; + } + } + + static final class BiomeEntry { + final String dimension, id, name, ownerName, ownerVersion; + final Status status; + final List placements; + final String replacementTarget; + final boolean providerDeclared; + BiomeEntry(String dimension, String id, String name, String ownerName, String ownerVersion, + Status status, List placements, String replacementTarget, + boolean providerDeclared) { + this.dimension = dimension; this.id = id; this.name = name; + this.ownerName = ownerName; this.ownerVersion = ownerVersion; this.status = status; + this.placements = Collections.unmodifiableList(new ArrayList<>(placements)); + this.replacementTarget = replacementTarget; this.providerDeclared = providerDeclared; + } + } + + static final class Palette { + final String id, dimension, owner; + final int order; + final boolean enabled, override, providerOwned; + final JsonObject definition; + Palette(String id, String dimension, String owner, int order, boolean enabled, + boolean override, boolean providerOwned, JsonObject definition) { + this.id = id; this.dimension = dimension; this.owner = owner; this.order = order; + this.enabled = enabled; this.override = override; this.providerOwned = providerOwned; + this.definition = JsonCopies.copy(definition); + } + } + + static final class Placement { + final String paletteId, owner; + final int order; + final boolean enabled, hasSurface, effectiveSurface, providerOwned; + final JsonObject definition; + Placement(String paletteId, String owner, int order, boolean enabled, + boolean hasSurface, boolean effectiveSurface, boolean providerOwned, + JsonObject definition) { + this.paletteId = paletteId; this.owner = owner; this.order = order; + this.enabled = enabled; this.hasSurface = hasSurface; + this.effectiveSurface = effectiveSurface; this.providerOwned = providerOwned; + this.definition = JsonCopies.copy(definition); + } + Placement withEffectiveSurface(boolean value) { + return new Placement(paletteId, owner, order, enabled, hasSurface, value, + providerOwned, definition); + } + } + + private static final class ModInfo { + final String id, name, version; + ModInfo(String id, String name, String version) { this.id = id; this.name = name; this.version = version; } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java index b1ccf10c..4160ab4f 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteScreen.java @@ -1,23 +1,29 @@ package zone.moddev.mc.orespawn.client; +import java.util.ArrayList; import java.util.List; -import com.mojang.blaze3d.vertex.PoseStack; -import net.minecraft.client.gui.components.Button; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Palette; + +import net.minecraft.client.Minecraft; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.CommonComponents; import net.minecraft.network.chat.TextComponent; import net.minecraft.network.chat.TranslatableComponent; -final class BiomePaletteScreen extends Screen { +/** Lists every palette in effective profile order for one dimension. */ +final class BiomePaletteScreen extends OreSpawnScreen { private final Screen parent; private final GeologyEditorSession session; private final String dimension; - private int page; + private CompactScrollList list; + private int scroll; + private String armedPalette; + private int resetScope; BiomePaletteScreen(Screen parent, GeologyEditorSession session, String dimension) { - super(new TranslatableComponent("screen.orespawn.biome_palette")); + super(new TranslatableComponent("screen.orespawn.biome_palettes")); this.parent = parent; this.session = session; this.dimension = dimension; @@ -25,56 +31,92 @@ final class BiomePaletteScreen extends Screen { @Override protected void init() { - int contentWidth = Math.min(390, Math.max(280, width - 24)); + OreSpawnScreenLayout.beginHelp(this); + int contentWidth = Math.min(520, Math.max(300, width - 20)); int left = (width - contentWidth) / 2; - int removeWidth = 70; - int listTop = 48; - int controlsY = height - 52; - List ids = session.biomePlacementIds(dimension); - int pageSize = Math.max(1, (controlsY - listTop) / 24); - int pageCount = Math.max(1, (ids.size() + pageSize - 1) / pageSize); - page = Math.max(0, Math.min(page, pageCount - 1)); - int start = page * pageSize; - for (int i = 0; i < pageSize && start + i < ids.size(); i++) { - String id = ids.get(start + i); - int y = listTop + i * 24; - addRenderableWidget(OreSpawnScreenLayout.button(this, font, left, y, - contentWidth - removeWidth - 5, 20, new TextComponent(id), - button -> minecraft.setScreen(new BiomePlacementScreen(this, session, - dimension, id)))); - addRenderableWidget(new Button(left + contentWidth - removeWidth, y, removeWidth, 20, - new TranslatableComponent("button.orespawn.remove"), - button -> { session.removeBiomePlacement(dimension, id); rebuildWidgets(); })); - } - Button previous = addRenderableWidget(new Button(left, controlsY, 45, 20, - new TextComponent("<"), button -> { page--; rebuildWidgets(); })); - Button next = addRenderableWidget(new Button(left + 50, controlsY, 45, 20, - new TextComponent(">"), button -> { page++; rebuildWidgets(); })); - previous.active = page > 0; - next.active = page + 1 < pageCount; - addRenderableWidget(OreSpawnScreenLayout.button(this, font, - left + contentWidth - 150, controlsY, 150, 20, - new TranslatableComponent("button.orespawn.add_biome"), - button -> minecraft.setScreen(new BiomePickerScreen(this, session, id -> { - session.addBiomePlacement(dimension, id); - minecraft.setScreen(new BiomePlacementScreen(this, session, dimension, id)); - })))); - addRenderableWidget(new Button(width / 2 - 75, height - 28, 150, 20, + List palettes = session.biomeDirectory().palettes(dimension); + int listTop = 38; + int listHeight = Math.max(32, height - listTop - 62); + list = addButton(new CompactScrollList(this, left, listTop, contentWidth, listHeight) { + @Override protected int size() { return palettes.size(); } + @Override protected String rowText(int index) { + Palette palette = palettes.get(index); + return (palette.enabled ? "[x] " : "[ ] ") + (palette.order + 1) + ". " + palette.id; + } + @Override protected boolean rowEnabled(int index) { return !palettes.get(index).override; } + @Override protected int rowColor(int index) { + return palettes.get(index).override ? 0x55FFFF : palettes.get(index).enabled ? 0xFFFFFF : 0x888888; + } + @Override protected void onRowPressed(int index) { + Palette palette = palettes.get(index); + if (!palette.override) minecraft.setScreen(new BiomePaletteSettingsScreen( + BiomePaletteScreen.this, session, palette.id)); + } + @Override protected int rowActionWidth(int index) { return 18; } + @Override protected void drawRowAction(Minecraft minecraft, int index, int x, int y, + int width, int height, boolean hovered) { + drawCenteredString(font, armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "!" : "R", x + width / 2, y + 4, hovered ? 0xFFFFFF : 0xAAAAAA); + } + @Override protected void onRowActionPressed(int index) { resetPalette(palettes.get(index)); } + @Override protected List rowTooltip(int index) { + Palette palette = palettes.get(index); + List lines = new ArrayList<>(); + lines.add(palette.id); + lines.add(net.minecraft.client.resources.language.I18n.get( + "label.orespawn.biome.palette_owner_order", palette.owner, palette.order + 1)); + if (palette.override) lines.add(net.minecraft.client.resources.language.I18n.get( + "tooltip.orespawn.biome.override_palette")); + return lines; + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList(net.minecraft.client.resources.language.I18n.get( + armedPalette != null && armedPalette.equals(palettes.get(index).id) + ? "button.orespawn.biome.confirm_reset_palette" + : "button.orespawn.biome.reset_palette")); + } + }); + list.setFirstIndex(scroll); + int half = (contentWidth - 4) / 2; + int resetY = height - 52; + addButton(OreSpawnScreenLayout.button(this, font, left, resetY, half, 20, + new TranslatableComponent(resetScope == 1 ? "button.orespawn.biome.confirm_reset_dimension" + : "button.orespawn.biome.reset_dimension"), button -> resetDimension())); + addButton(OreSpawnScreenLayout.button(this, font, left + half + 4, resetY, half, 20, + new TranslatableComponent(resetScope == 2 ? "button.orespawn.biome.confirm_reset_all" + : "button.orespawn.biome.reset_all"), button -> resetAll())); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, CommonComponents.GUI_DONE, button -> onClose())); } - private void rebuildWidgets() { - clearWidgets(); - init(); + private void resetPalette(Palette palette) { + resetScope = 0; + if (!palette.id.equals(armedPalette)) { armedPalette = palette.id; rebuild(); return; } + session.resetBiomePalette(palette.id); armedPalette = null; rebuild(); + } + + private void resetDimension() { + armedPalette = null; + if (resetScope != 1) { resetScope = 1; rebuild(); return; } + session.resetBiomeDimension(dimension); resetScope = 0; rebuild(); } + private void resetAll() { + armedPalette = null; + if (resetScope != 2) { resetScope = 2; rebuild(); return; } + session.resetAllBiomeManagement(); resetScope = 0; rebuild(); + } + + private void rebuild() { if (list != null) scroll = list.firstIndex(); clearWidgets(); init(); } + @Override public void onClose() { minecraft.setScreen(parent); } @Override - public void render(PoseStack poseStack, int mouseX, int mouseY, float partialTick) { - renderBackground(poseStack); - drawCenteredString(poseStack, font, title, width / 2, 12, 0xFFFFFF); - drawCenteredString(poseStack, font, new TextComponent(dimension), width / 2, 28, 0xCCCCCC); - super.render(poseStack, mouseX, mouseY, partialTick); + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 9, 0xFFFFFF); + drawCenteredString(font, new TextComponent(dimension), width / 2, 23, 0xAAAAAA); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java new file mode 100644 index 00000000..57a5ee2a --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePaletteSettingsScreen.java @@ -0,0 +1,166 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.Arrays; +import java.util.LinkedHashSet; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.components.CycleButton; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; + +/** Edits the complete set of controls already supported by one biome palette. */ +final class BiomePaletteSettingsScreen extends OreSpawnScreen { + private final Screen parent; + private final GeologyEditorSession session; + private final String paletteId; + private TextFieldWidget coverage; + private TextFieldWidget fallback; + private TextFieldWidget included; + private TextFieldWidget excluded; + + BiomePaletteSettingsScreen(Screen parent, GeologyEditorSession session, String paletteId) { + super(new TranslatableComponent("screen.orespawn.biome_palette_settings")); + this.parent = parent; + this.session = session; + this.paletteId = paletteId; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + JsonObject palette = session.biomePaletteById(paletteId); + if (palette == null) { onClose(); return; } + int contentWidth = Math.min(430, Math.max(300, width - 20)); + int left = (width - contentWidth) / 2; + int half = (contentWidth - 4) / 2; + int y = 38; + addButton(CycleButton.onOffBuilder(bool(palette, "enabled", true)).create( + left, y, contentWidth, 20, new TranslatableComponent("option.orespawn.enabled"), + (button, value) -> palette.addProperty("enabled", value))); + y += 24; + addButton(CycleButton.builder(this::modeName) + .withValues(Arrays.asList("augment", "replace")) + .withInitialValue(string(palette, "mode", "augment")) + .create(left, y, half, 20, new TranslatableComponent("option.orespawn.biome_mode"), + (button, value) -> palette.addProperty("mode", value))); + addButton(CycleButton.builder(this::scopeName) + .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) + .withInitialValue(string(palette, "scope", "minecraft_only")) + .create(left + half + 4, y, half, 20, + new TranslatableComponent("option.orespawn.biome_scope"), + (button, value) -> palette.addProperty("scope", value))); + y += 24; + addButton(CycleButton.builder(this::regionName) + .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) + .withInitialValue(string(palette, "region_size", "average")) + .create(left, y, contentWidth, 20, + new TranslatableComponent("option.orespawn.biome_region_size"), + (button, value) -> palette.addProperty("region_size", value))); + y += 24; + coverage = field(left, y, half, decimal(palette, "coverage", 1.0D), + "option.orespawn.biome.coverage"); + fallback = field(left + half + 4, y, half, decimal(palette, "fallback_weight", 1.0D), + "option.orespawn.biome.fallback_weight"); + y += 24; + included = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + new TranslatableComponent("option.orespawn.biome.include_namespaces"))); + included.setValue(join(palette.get("include_namespaces"))); + y += 24; + excluded = addButton(new TextFieldWidget(font, left, y, contentWidth, 20, + new TranslatableComponent("option.orespawn.biome.exclude_namespaces"))); + excluded.setValue(join(palette.get("exclude_namespaces"))); + y += 24; + Button info = addButton(OreSpawnScreenLayout.explainedButton(this, font, left, y, + contentWidth, 20, new TranslatableComponent("label.orespawn.biome.palette_entries", + session.biomePlacementIdsForPalette(paletteId).size()), button -> { }, + "tooltip.orespawn.biome.palette_entries_read_only")); + info.active = false; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private TextFieldWidget field(int x, int y, int width, double value, String label) { + TextFieldWidget field = addButton(new TextFieldWidget(font, x, y, width, 20, + new TranslatableComponent(label))); + field.setValue(Double.toString(value)); + return field; + } + + private void save() { + JsonObject palette = session.biomePaletteById(paletteId); + if (palette == null) return; + putDecimal(palette, "coverage", coverage, 0.0D, 1.0D); + putDecimal(palette, "fallback_weight", fallback, 0.0D, 1000.0D); + palette.add("include_namespaces", namespaces(included)); + palette.add("exclude_namespaces", namespaces(excluded)); + } + + private static void putDecimal(JsonObject root, String key, TextFieldWidget field, + double min, double max) { + try { + double value = Double.parseDouble(field.getValue().trim()); + if (Double.isFinite(value)) root.addProperty(key, Math.max(min, Math.min(max, value))); + } catch (RuntimeException ignored) { } + } + + private static JsonArray namespaces(TextFieldWidget field) { + Set values = new LinkedHashSet<>(); + if (field != null) for (String raw : field.getValue().split(",")) { + String value = raw.trim().toLowerCase(java.util.Locale.ROOT); + if (value.matches("[a-z][a-z0-9_.-]{0,63}")) values.add(value); + } + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String join(JsonElement element) { + if (element == null || !element.isJsonArray()) return ""; + StringBuilder result = new StringBuilder(); + for (JsonElement value : element.getAsJsonArray()) { + if (result.length() > 0) result.append(", "); + result.append(value.getAsString()); + } + return result.toString(); + } + + private Component modeName(String value) { + return new TranslatableComponent("value.orespawn.biome_mode." + value); + } + private Component scopeName(String value) { + return new TranslatableComponent("value.orespawn.biome_scope." + value); + } + private Component regionName(String value) { + return new TranslatableComponent("value.orespawn.preset." + value); + } + + @Override public void onClose() { save(); minecraft.setScreen(parent); } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawCenteredString(font, new TextComponent(paletteId), width / 2, 22, 0xAAAAAA); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } + + private static String string(JsonObject root, String key, String fallback) { + return root.has(key) ? root.get(key).getAsString() : fallback; + } + private static boolean bool(JsonObject root, String key, boolean fallback) { + return root.has(key) ? root.get(key).getAsBoolean() : fallback; + } + private static double decimal(JsonObject root, String key, double fallback) { + return root.has(key) ? root.get(key).getAsDouble() : fallback; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java index e17e69c2..00f933c4 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomePlacementScreen.java @@ -21,6 +21,7 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } private final Screen parent; private final GeologyEditorSession session; private final String dimension; + private final String paletteId; private final String biomeId; private Tab tab = Tab.PLACEMENT; private EditBox weight; @@ -32,10 +33,21 @@ private enum Tab { PLACEMENT, CLIMATE, SURFACE } BiomePlacementScreen(Screen parent, GeologyEditorSession session, String dimension, String biomeId) { + this(parent, session, dimension, paletteId(session, dimension), biomeId); + } + + private static String paletteId(GeologyEditorSession session, String dimension) { + if (session.biomePaletteId(dimension) == null) session.biomePalette(dimension, true); + return session.biomePaletteId(dimension); + } + + BiomePlacementScreen(Screen parent, GeologyEditorSession session, + String dimension, String paletteId, String biomeId) { super(new TranslatableComponent("screen.orespawn.biome_placement")); this.parent = parent; this.session = session; this.dimension = dimension; + this.paletteId = paletteId; this.biomeId = biomeId; } @@ -54,7 +66,7 @@ protected void init() { selected -> { saveFields(); tab = value; rebuildWidgets(); })); button.active = value != tab; } - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.onOffBuilder( bool(placement, "enabled", true)) .create(left, 64, contentWidth, 20, @@ -68,7 +80,7 @@ protected void init() { half, 20, CommonComponents.GUI_DONE, button -> { saveFields(); onClose(); })); addRenderableWidget(new Button(left + half + 5, OreSpawnScreenLayout.footerY(height), half, 20, new TranslatableComponent("button.orespawn.remove"), button -> { - session.removeBiomePlacement(dimension, biomeId); + session.removeBiomePlacementByPalette(paletteId, biomeId); onClose(); })); } @@ -158,7 +170,7 @@ private void label(int x, int y, int labelWidth, String key) { } private void saveFields() { - JsonObject placement = session.biomePlacement(dimension, biomeId); + JsonObject placement = session.biomePlacementByPalette(paletteId, biomeId); if (weight != null) putDouble(placement, "weight", weight, 0.0D, 1000.0D); if (minTemperature != null) putDouble(placement, "min_temperature", minTemperature, -2.0D, 2.0D); if (maxTemperature != null) putDouble(placement, "max_temperature", maxTemperature, -2.0D, 2.0D); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java new file mode 100644 index 00000000..ec7f0dfa --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeReplacementRules.java @@ -0,0 +1,153 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.resources.ResourceLocation; + +/** Encodes exact new-terrain biome replacements in a terminal UI palette. */ +final class BiomeReplacementRules { + static final String PREFIX = "orespawn:ui/biome_overrides/"; + + private BiomeReplacementRules() { } + + static String paletteId(String dimension) { + return PREFIX + dimension.toLowerCase(java.util.Locale.ROOT) + .replace(':', '_').replaceAll("[^a-z0-9_./-]", "_"); + } + + static boolean isOverridePalette(String id) { + return id != null && id.startsWith(PREFIX); + } + + static Map read(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes == null) return Collections.emptyMap(); + JsonElement value = palettes.get(paletteId(dimension)); + if (value == null || !value.isJsonObject()) return Collections.emptyMap(); + JsonObject palette = value.getAsJsonObject(); + Map result = new LinkedHashMap<>(); + JsonObject biomes = object(palette, "biomes", false); + if (biomes == null) return Collections.emptyMap(); + for (Entry target : biomes.entrySet()) { + if (!target.getValue().isJsonObject()) continue; + JsonElement sources = target.getValue().getAsJsonObject().get("similar_biomes"); + if (sources == null || !sources.isJsonArray()) continue; + for (JsonElement source : sources.getAsJsonArray()) { + try { result.put(new ResourceLocation(source.getAsString()).toString(), + new ResourceLocation(target.getKey()).toString()); } + catch (RuntimeException ignored) { } + } + } + return Collections.unmodifiableMap(result); + } + + static void set(JsonObject root, String dimension, String source, String target) { + String normalizedSource = new ResourceLocation(source).toString(); + String normalizedTarget = new ResourceLocation(target).toString(); + if (normalizedSource.equals(normalizedTarget)) { + throw new IllegalArgumentException("A biome cannot replace itself"); + } + Map mappings = new LinkedHashMap<>(read(root, dimension)); + String terminal = terminal(mappings, normalizedTarget, normalizedSource); + for (Entry entry : new ArrayList<>(mappings.entrySet())) { + if (normalizedSource.equals(entry.getValue())) mappings.put(entry.getKey(), terminal); + } + mappings.put(normalizedSource, terminal); + flatten(mappings); + write(root, dimension, mappings); + } + + static void remove(JsonObject root, String dimension, String source) { + Map mappings = new LinkedHashMap<>(read(root, dimension)); + mappings.remove(source); + write(root, dimension, mappings); + } + + static void clearDimension(JsonObject root, String dimension) { + JsonObject palettes = object(root, "biome_palettes", false); + if (palettes != null) palettes.remove(paletteId(dimension)); + } + + private static String terminal(Map mappings, String start, String rejected) { + Set seen = new LinkedHashSet<>(); + String current = start; + while (mappings.containsKey(current)) { + if (!seen.add(current) || rejected.equals(current)) { + throw new IllegalArgumentException("Biome replacement cycle"); + } + current = mappings.get(current); + } + if (rejected.equals(current)) throw new IllegalArgumentException("Biome replacement cycle"); + return current; + } + + private static void flatten(Map mappings) { + for (String source : new ArrayList<>(mappings.keySet())) { + mappings.put(source, terminal(mappings, mappings.get(source), source)); + } + } + + private static void write(JsonObject root, String dimension, Map mappings) { + JsonObject palettes = object(root, "biome_palettes", true); + String id = paletteId(dimension); + if (mappings.isEmpty()) { + palettes.remove(id); + return; + } + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", dimension); + palette.addProperty("enabled", true); + palette.addProperty("mode", "replace"); + palette.addProperty("scope", "all"); + palette.addProperty("region_size", "tiny"); + palette.addProperty("coverage", 1.0D); + palette.addProperty("fallback_weight", 0.0D); + palette.add("include_namespaces", new JsonArray()); + palette.add("exclude_namespaces", new JsonArray()); + JsonObject biomes = new JsonObject(); + Map> byTarget = new LinkedHashMap<>(); + List sources = new ArrayList<>(mappings.keySet()); + Collections.sort(sources); + for (String source : sources) { + byTarget.computeIfAbsent(mappings.get(source), ignored -> new ArrayList<>()).add(source); + } + List targets = new ArrayList<>(byTarget.keySet()); + Collections.sort(targets); + for (String target : targets) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray similar = new JsonArray(); + for (String source : byTarget.get(target)) similar.add(new JsonPrimitive(source)); + placement.add("similar_biomes", similar); + placement.add("required_similar_biomes", new JsonArray()); + placement.addProperty("min_temperature", -2.0D); + placement.addProperty("max_temperature", 2.0D); + placement.addProperty("min_downfall", 0.0D); + placement.addProperty("max_downfall", 1.0D); + biomes.add(target, placement); + } + palette.add("biomes", biomes); + palettes.add(id, palette); + } + + private static JsonObject object(JsonObject root, String key, boolean create) { + if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key); + if (!create) return null; + JsonObject value = new JsonObject(); + root.add(key, value); + return value; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java index cfb7e5e6..224227d0 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/BiomeWorldMaterialsScreen.java @@ -1,27 +1,46 @@ package zone.moddev.mc.orespawn.client; -import java.util.Arrays; +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; import java.util.List; -import com.google.gson.JsonObject; -import com.mojang.blaze3d.vertex.PoseStack; -import net.minecraft.client.gui.components.Button; -import net.minecraft.client.gui.components.CycleButton; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.BiomeEntry; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Placement; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Snapshot; +import zone.moddev.mc.orespawn.client.BiomeDirectoryModel.Status; + import net.minecraft.client.gui.screens.Screen; -import net.minecraft.network.chat.CommonComponents; +import net.minecraft.client.resources.language.I18n; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.TextComponent; import net.minecraft.network.chat.TranslatableComponent; -/** Dimension-level entry point for biome placement and world materials. */ -final class BiomeWorldMaterialsScreen extends Screen { +/** Responsive directory for biome placement, exact replacements, and diagnostics. */ +final class BiomeWorldMaterialsScreen extends OreSpawnScreen { + static final int TWO_PANE_MINIMUM = 400; + private static final int CONTENT_TOP = 48; private final Screen parent; private final GeologyEditorSession session; private String dimension; + private String selectedId; + private boolean showAll; + private boolean compactDetail; + private boolean resetArmed; + private String message; + private int biomeScroll; + private int ruleScroll; + private CompactScrollList biomeList; + private CompactScrollList ruleList; + private int detailX; + private int detailWidth; + private int detailRuleTop; + private int detailMessageTop; + private List detailMessageLines = java.util.Collections.emptyList(); BiomeWorldMaterialsScreen(Screen parent, GeologyEditorSession session) { - super(new TranslatableComponent("screen.orespawn.biomes_world_materials")); + super(new TranslatableComponent("screen.orespawn.biomes")); this.parent = parent; this.session = session; this.dimension = session.availableDimensionIds().get(0); @@ -30,126 +49,299 @@ final class BiomeWorldMaterialsScreen extends Screen { @Override protected void init() { OreSpawnScreenLayout.beginHelp(this); - int contentWidth = Math.min(390, Math.max(280, width - 24)); - int left = (width - contentWidth) / 2; - int half = (contentWidth - 5) / 2; - List dimensions = session.availableDimensionIds(); + Snapshot snapshot = session.biomeDirectory(); + List dimensions = new ArrayList<>(snapshot.dimensions); + if (dimensions.isEmpty()) dimensions.add("minecraft:overworld"); if (!dimensions.contains(dimension)) dimension = dimensions.get(0); - OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::dimensionName) - .withValues(dimensions).withInitialValue(dimension) - .create(left, 34, contentWidth, 20, - new TranslatableComponent("option.orespawn.dimension"), - (button, value) -> { dimension = value; rebuildWidgets(); })), - "tooltip.orespawn.biome.dimension"); - - JsonObject palette = session.biomePalette(dimension, false); - boolean active = palette != null && bool(palette, "enabled", false); - String mode = palette == null ? "augment" : string(palette, "mode", "augment"); - String scope = palette == null ? "minecraft_only" : string(palette, "scope", "minecraft_only"); - String size = palette == null ? "average" : string(palette, "region_size", "average"); - int y = 64; - OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.onOffBuilder(active) - .create(left, y, contentWidth, 20, - new TranslatableComponent("option.orespawn.biome_palette"), - (button, value) -> setPaletteEnabled(value))), - "tooltip.orespawn.biome.palette_enabled"); - y += 26; - OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::modeName) - .withValues(Arrays.asList("augment", "replace")).withInitialValue(mode) - .create(left, y, half, 20, - new TranslatableComponent("option.orespawn.biome_mode"), - (button, value) -> setPalette("mode", value))), - "tooltip.orespawn.biome.mode"); - OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::scopeName) - .withValues(Arrays.asList("all", "minecraft_only", "selected_namespaces")) - .withInitialValue(scope) - .create(left + half + 5, y, half, 20, - new TranslatableComponent("option.orespawn.biome_scope"), - (button, value) -> setPalette("scope", value))), - "tooltip.orespawn.biome.scope"); - y += 26; - OreSpawnScreenLayout.explain(this, addRenderableWidget(CycleButton.builder(this::regionName) - .withValues(Arrays.asList("tiny", "small", "average", "large", "huge")) - .withInitialValue(size) - .create(left, y, contentWidth, 20, - new TranslatableComponent("option.orespawn.biome_region_size"), - (button, value) -> setPalette("region_size", value))), - "tooltip.orespawn.biome.region_size"); - y += 30; - addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, y, half, 20, - new TranslatableComponent("button.orespawn.biome_palette_count", - session.biomePlacementIds(dimension).size()), - button -> minecraft.setScreen(new BiomePaletteScreen(this, session, dimension)), - "tooltip.orespawn.biome.entries")); - addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left + half + 5, y, half, 20, - new TranslatableComponent("button.orespawn.dimension_materials"), - button -> minecraft.setScreen(new DimensionMaterialsScreen(this, session, dimension)), + List entries = snapshot.entries(dimension, showAll); + if (selectedId == null || find(entries, selectedId) == null) { + selectedId = entries.isEmpty() ? null : entries.get(0).id; + } + boolean compact = width < TWO_PANE_MINIMUM; + if (compact && compactDetail && selectedId != null) initDetail(snapshot, true); + else initDirectory(snapshot, entries, compact); + } + + private void initDirectory(Snapshot snapshot, List entries, boolean compact) { + int contentWidth = contentWidth(width); + int left = (width - contentWidth) / 2; + int leftWidth = compact ? contentWidth : leftPaneWidth(width); + int headerWidth = Math.max(76, Math.min(leftWidth - 74, 150)); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, 24, headerWidth, 20, + dimensionSelectorLabel(dimension), button -> cycleDimension(snapshot), + "tooltip.orespawn.biome.dimension")); + addButton(OreSpawnScreenLayout.button(this, font, left + leftWidth - 68, 24, 68, 20, + new TranslatableComponent(showAll ? "button.orespawn.biome.hide_routine" + : "button.orespawn.show_all"), button -> { + showAll = !showAll; selectedId = null; biomeScroll = 0; rebuild(); + })); + int listHeight = listHeight(height); + biomeList = addButton(new CompactScrollList(this, left, CONTENT_TOP, leftWidth, listHeight) { + @Override protected int size() { return entries.size(); } + @Override protected String rowText(int index) { + BiomeEntry entry = entries.get(index); + return statusMarker(entry.status) + ' ' + entry.name; + } + @Override protected int rowColor(int index) { return statusColor(entries.get(index).status); } + @Override protected boolean rowSelected(int index) { return entries.get(index).id.equals(selectedId); } + @Override protected void onRowPressed(int index) { + selectedId = entries.get(index).id; + if (compact) compactDetail = true; + ruleScroll = 0; resetArmed = false; clearMessage(); rebuild(); + } + @Override protected List rowTooltip(int index) { return biomeTooltip(entries.get(index)); } + }); + biomeList.setFirstIndex(biomeScroll); + addButton(OreSpawnScreenLayout.explainedButton(this, font, left, + materialsButtonY(height), leftWidth, 20, + new TranslatableComponent("button.orespawn.dimension_materials_named", + dimensionName(dimension)), + button -> minecraft.setScreen( + new DimensionMaterialsScreen(this, session, dimension)), "tooltip.orespawn.biome.dimension_materials")); - y += 26; - addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, y, contentWidth, 20, - new TranslatableComponent("button.orespawn.geome_influences"), - button -> minecraft.setScreen(new GeomeBiomeScreen(this, session)), - "tooltip.orespawn.biome.geome_influences")); - addRenderableWidget(new Button(width / 2 - 75, OreSpawnScreenLayout.footerY(height), - 150, 20, CommonComponents.GUI_DONE, button -> onClose())); - } - - private void setPaletteEnabled(boolean enabled) { - JsonObject palette = session.biomePalette(dimension, true); - if (enabled && session.biomePlacementIds(dimension).isEmpty()) { - minecraft.setScreen(new BiomePickerScreen(this, session, - id -> { - session.addBiomePlacement(dimension, id); - minecraft.setScreen(new BiomePlacementScreen(this, session, dimension, id)); + if (!compact) initDetail(snapshot, false); + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void initDetail(Snapshot snapshot, boolean compact) { + BiomeEntry selected = find(snapshot.entries(dimension, true), selectedId); + if (selected == null) return; + int contentWidth = contentWidth(width); + int contentLeft = (width - contentWidth) / 2; + int leftWidth = compact ? 0 : leftPaneWidth(width); + int x = compact ? contentLeft : contentLeft + leftWidth + 10; + int paneWidth = compact ? contentWidth : contentWidth - leftWidth - 10; + detailX = x; + detailWidth = paneWidth; + int y = detailTop(compact); + if (compact) { + addButton(OreSpawnScreenLayout.button(this, font, contentLeft, 8, 48, 20, + new TranslatableComponent("button.orespawn.back"), button -> { + captureScroll(); compactDetail = false; rebuild(); })); - return; } - palette.addProperty("enabled", enabled); + Component replacement = selected.replacementTarget == null + ? new TranslatableComponent("button.orespawn.biome.replace_new_terrain") + : new TranslatableComponent("button.orespawn.biome.replace_with", selected.replacementTarget); + addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 35, + paneWidth, 20, replacement, button -> chooseReplacement(selected), + "tooltip.orespawn.biome.no_retrogen")); + if (selected.replacementTarget != null) { + addButton(OreSpawnScreenLayout.explainedButton(this, font, x, y + 59, + paneWidth, 20, + new TranslatableComponent("button.orespawn.biome.leave_original"), + button -> { session.leaveBiomeOriginal(dimension, selected.id); clearMessage(); rebuild(); }, + "tooltip.orespawn.biome.no_retrogen")); + } + detailMessageLines = wrapMessage(message, paneWidth - 6); + detailMessageTop = detailMessageTop(compact, selected.replacementTarget != null); + int ruleTop = detailRuleTop(compact, selected.replacementTarget != null, + detailMessageLines.size()); + detailRuleTop = ruleTop; + int controlsTop = controlsTop(height, ruleTop); + int ruleHeight = Math.max(16, controlsTop - ruleTop - 4); + List placements = selected.placements; + ruleList = addButton(new CompactScrollList(this, x, ruleTop, paneWidth, ruleHeight) { + @Override protected int size() { return placements.size(); } + @Override protected String rowText(int index) { + Placement rule = placements.get(index); + return (rule.enabled ? "[x] " : "[ ] ") + rule.paletteId + " #" + (rule.order + 1) + + (rule.effectiveSurface ? " *" : ""); + } + @Override protected int rowColor(int index) { return placements.get(index).enabled ? 0xFFFFFF : 0x888888; } + @Override protected void onRowPressed(int index) { + Placement rule = placements.get(index); + minecraft.setScreen(new BiomePlacementScreen(BiomeWorldMaterialsScreen.this, + session, dimension, rule.paletteId, selected.id)); + } + @Override protected List rowTooltip(int index) { return placementTooltip(placements.get(index)); } + }); + ruleList.setFirstIndex(ruleScroll); + int half = (paneWidth - 4) / 2; + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop, half, 20, + new TranslatableComponent("button.orespawn.biome.palettes", snapshot.palettes(dimension).size()), + button -> minecraft.setScreen(new BiomePaletteScreen(this, session, dimension)))); + addButton(OreSpawnScreenLayout.button(this, font, x + half + 4, controlsTop, half, 20, + new TranslatableComponent(resetArmed ? "button.orespawn.biome.confirm_reset" + : "button.orespawn.biome.reset_selected"), button -> resetSelected(selected))); + addButton(OreSpawnScreenLayout.button(this, font, x, controlsTop + 24, paneWidth, 20, + new TranslatableComponent("button.orespawn.geome_influences"), + button -> minecraft.setScreen(new GeomeBiomeScreen(this, session)))); + } + + private void chooseReplacement(BiomeEntry selected) { + captureScroll(); + minecraft.setScreen(new BiomePickerScreen(this, session, target -> { + try { + session.replaceBiome(dimension, selected.id, target); + clearMessage(); + } catch (IllegalArgumentException failure) { + message = failure.getMessage(); + } + minecraft.setScreen(this); + })); } - private void setPalette(String key, String value) { - session.biomePalette(dimension, true).addProperty(key, value); + private void resetSelected(BiomeEntry selected) { + if (!resetArmed) { resetArmed = true; rebuild(); return; } + session.resetBiome(dimension, selected.id); + resetArmed = false; clearMessage(); rebuild(); } - private Component dimensionName(String value) { - return new TextComponent(value); + private void cycleDimension(Snapshot snapshot) { + dimension = nextDimension(snapshot.dimensions, dimension); + selectedId = null; compactDetail = false; biomeScroll = 0; ruleScroll = 0; clearMessage(); rebuild(); } - private Component modeName(String value) { - return new TranslatableComponent("value.orespawn.biome_mode." + value); + private void clearMessage() { + message = null; } - private Component scopeName(String value) { - return new TranslatableComponent("value.orespawn.biome_scope." + value); + private List wrapMessage(String text, int maximumWidth) { + if (text == null || text.isEmpty()) return java.util.Collections.emptyList(); + int width = Math.max(40, maximumWidth); + List wrapped = new ArrayList<>(); + String remaining = text.trim(); + while (!remaining.isEmpty() && wrapped.size() < 2) { + String line = font.plainSubstrByWidth(remaining, width); + if (line.length() == remaining.length()) { + wrapped.add(line); + break; + } + int space = line.lastIndexOf(' '); + if (space > 0) line = line.substring(0, space); + wrapped.add(line); + remaining = remaining.substring(line.length()).trim(); + } + if (!remaining.isEmpty() && !wrapped.isEmpty()) { + int last = wrapped.size() - 1; + wrapped.set(last, OreSpawnScreenLayout.fit(font, + new TextComponent(wrapped.get(last) + "..."), width).getString()); + } + return wrapped; } - private Component regionName(String value) { - return new TranslatableComponent("value.orespawn.preset." + value); + static String nextDimension(Iterable available, String current) { + List dimensions = new ArrayList<>(); + for (String candidate : available) dimensions.add(candidate); + if (dimensions.isEmpty()) return "minecraft:overworld"; + int index = dimensions.indexOf(current); + return dimensions.get((Math.max(-1, index) + 1) % dimensions.size()); } - private void rebuildWidgets() { - clearWidgets(); - init(); + private static BiomeEntry find(List entries, String id) { + if (id != null) for (BiomeEntry entry : entries) if (id.equals(entry.id)) return entry; + return null; } - @Override - public void onClose() { - minecraft.setScreen(parent); + static Component dimensionName(String dimension) { + switch (dimension) { + case "minecraft:overworld": return new TranslatableComponent("value.orespawn.dimension.overworld"); + case "minecraft:the_nether": return new TranslatableComponent("value.orespawn.dimension.the_nether"); + case "minecraft:the_end": return new TranslatableComponent("value.orespawn.dimension.the_end"); + default: return new TextComponent(dimension); + } } + private static Component dimensionSelectorLabel(String dimension) { + return new TextComponent("< " + dimensionName(dimension).getString() + " >"); + } + + private static String statusMarker(Status status) { + switch (status) { + case USER_REPLACED: return "R"; + case LAYERED: return "!"; + case DISABLED: return "-"; + case MISSING: return "?"; + case PROVIDER_MANAGED: return "+"; + default: return " "; + } + } + + private static int statusColor(Status status) { + switch (status) { + case MISSING: return 0xFF5555; + case USER_REPLACED: return 0x55FFFF; + case LAYERED: return 0xFFFF55; + case DISABLED: return 0xAAAAAA; + case PROVIDER_MANAGED: return 0x55FF55; + default: return 0xFFFFFF; + } + } + + private static List biomeTooltip(BiomeEntry entry) { + List lines = new ArrayList<>(); + lines.add(entry.name + " (" + entry.id + ")"); + lines.add(entry.ownerName + " " + entry.ownerVersion); + lines.add(I18n.get("value.orespawn.biome.status." + entry.status.name().toLowerCase(java.util.Locale.ROOT))); + lines.add(I18n.get("label.orespawn.biome.rule_count", entry.placements.size())); + if (entry.replacementTarget != null) lines.add(I18n.get("label.orespawn.biome.replaced_by", entry.replacementTarget)); + return lines; + } + + private static List placementTooltip(Placement rule) { + List lines = new ArrayList<>(); + lines.add(rule.paletteId); + lines.add(I18n.get("label.orespawn.biome.palette_owner_order", rule.owner, rule.order + 1)); + if (rule.hasSurface) lines.add(I18n.get(rule.effectiveSurface + ? "label.orespawn.biome.surface_effective" : "label.orespawn.biome.surface_overridden")); + return lines; + } + + private void captureScroll() { + if (biomeList != null) biomeScroll = biomeList.firstIndex(); + if (ruleList != null) ruleScroll = ruleList.firstIndex(); + } + + private void rebuild() { captureScroll(); clearWidgets(); init(); } + + @Override public void onClose() { minecraft.setScreen(parent); } + @Override - public void render(PoseStack poseStack, int mouseX, int mouseY, float partialTick) { - renderBackground(poseStack); - drawCenteredString(poseStack, font, title, width / 2, 14, 0xFFFFFF); - super.render(poseStack, mouseX, mouseY, partialTick); - OreSpawnScreenLayout.renderExplanations(this, poseStack, mouseX, mouseY); + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 9, 0xFFFFFF); + BiomeEntry selected = find(session.biomeDirectory().entries(dimension, true), selectedId); + if (selected != null && (width >= TWO_PANE_MINIMUM || compactDetail)) { + int contentWidth = contentWidth(width); + int x = width < TWO_PANE_MINIMUM ? (width - contentWidth) / 2 + : (width - contentWidth) / 2 + leftPaneWidth(width) + 10; + int y = detailTop(width < TWO_PANE_MINIMUM); + drawString(font, new TextComponent(selected.name), x + 3, y, 0xFFFFFF); + drawString(font, new TextComponent(selected.id), x + 3, y + 11, 0xAAAAAA); + drawString(font, new TranslatableComponent("value.orespawn.biome.status." + + selected.status.name().toLowerCase(java.util.Locale.ROOT)), x + 3, y + 22, + statusColor(selected.status)); + for (int line = 0; line < detailMessageLines.size(); line++) { + drawString(font, new TextComponent(detailMessageLines.get(line)), + detailX + 3, detailMessageTop + (line * 10), 0xFF5555); + } + drawString(font, new TranslatableComponent("label.orespawn.biome.placement_rules"), + detailX + 3, detailRuleTop - 10, 0xCCCCCC); + } + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } + + static int contentWidth(int width) { return Math.min(520, Math.max(300, width - 20)); } + static int leftPaneWidth(int width) { return Math.max(132, (contentWidth(width) * 43) / 100); } + static int listHeight(int height) { return Math.max(32, materialsButtonY(height) - CONTENT_TOP - 4); } + static int materialsButtonY(int height) { return height - 52; } + static int detailTop(boolean compact) { return compact ? 34 : 24; } + static int detailRuleTop(boolean compact, boolean hasReplacement) { + return detailTop(compact) + (hasReplacement ? 95 : 71); + } + + static int detailMessageTop(boolean compact, boolean hasReplacement) { + return detailRuleTop(compact, hasReplacement) - 12; } - private static String string(JsonObject root, String key, String fallback) { - return root.has(key) ? root.get(key).getAsString() : fallback; + static int detailRuleTop(boolean compact, boolean hasReplacement, int messageLines) { + return detailRuleTop(compact, hasReplacement) + (Math.max(0, Math.min(2, messageLines)) * 10); } - private static boolean bool(JsonObject root, String key, boolean fallback) { - return root.has(key) ? root.get(key).getAsBoolean() : fallback; + static int controlsTop(int height, int ruleTop) { + return Math.max(ruleTop + 34, height - 76); } } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/Button.java b/src/main/java/zone/moddev/mc/orespawn/client/Button.java new file mode 100644 index 00000000..301222cc --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/Button.java @@ -0,0 +1,70 @@ +package zone.moddev.mc.orespawn.client; + +import com.mojang.blaze3d.vertex.PoseStack; + +import net.minecraft.client.gui.GuiComponent; +import net.minecraft.client.gui.Font; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.TextComponent; + +/** Small bridge for editor controls drawn by Minecraft's pose-stack API. */ +class Button extends net.minecraft.client.gui.components.Button { + private final Tooltip tooltip; + private PoseStack currentPoseStack; + + Button(int x, int y, int width, int height, String message, OnPress onPress) { + this(x, y, width, height, new TextComponent(message), onPress, null); + } + + Button(int x, int y, int width, int height, Component message, OnPress onPress) { + this(x, y, width, height, message, onPress, null); + } + + Button(int x, int y, int width, int height, String message, OnPress onPress, + Tooltip tooltip) { + this(x, y, width, height, new TextComponent(message), onPress, tooltip); + } + + Button(int x, int y, int width, int height, Component message, OnPress onPress, + Tooltip tooltip) { + super(x, y, width, height, message, onPress); + this.tooltip = tooltip; + } + + @Override + public void render(PoseStack poseStack, int mouseX, int mouseY, float partialTick) { + currentPoseStack = poseStack; + try { + render(mouseX, mouseY, partialTick); + } finally { + currentPoseStack = null; + } + } + + public void render(int mouseX, int mouseY, float partialTick) { + super.render(currentPoseStack, mouseX, mouseY, partialTick); + } + + @Override + public void renderButton(PoseStack poseStack, int mouseX, int mouseY, float partialTick) { + super.renderButton(poseStack, mouseX, mouseY, partialTick); + if (tooltip != null && isHoveredOrFocused()) tooltip.render(this, mouseX, mouseY); + } + + protected final PoseStack currentPoseStack() { + return currentPoseStack; + } + + protected final void fill(int x1, int y1, int x2, int y2, int color) { + GuiComponent.fill(currentPoseStack, x1, y1, x2, y2, color); + } + + protected final void drawCenteredString(Font font, String text, + int x, int y, int color) { + GuiComponent.drawCenteredString(currentPoseStack, font, text, x, y, color); + } + + interface Tooltip { + void render(Button button, int mouseX, int mouseY); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java new file mode 100644 index 00000000..3aa1a7fe --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CogButton.java @@ -0,0 +1,28 @@ +package zone.moddev.mc.orespawn.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import net.minecraft.client.gui.GuiComponent; + +/** Compact settings button used in the Mods directory. */ +final class CogButton extends Button { + CogButton(int x, int y, OnPress onPress, Tooltip tooltip) { + super(x, y, 22, 20, "", onPress, tooltip); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + super.render(mouseX, mouseY, partialTick); + if (!visible) return; + drawCog(currentPoseStack(), x + 11, y + 10, active); + } + + static void drawCog(PoseStack poseStack, int centerX, int centerY, boolean active) { + int color = active ? 0xFFE0E0E0 : 0xFF777777; + GuiComponent.fill(poseStack, centerX - 4, centerY - 4, centerX + 5, centerY + 5, color); + GuiComponent.fill(poseStack, centerX - 1, centerY - 6, centerX + 2, centerY - 4, color); + GuiComponent.fill(poseStack, centerX - 1, centerY + 5, centerX + 2, centerY + 7, color); + GuiComponent.fill(poseStack, centerX - 6, centerY - 1, centerX - 4, centerY + 2, color); + GuiComponent.fill(poseStack, centerX + 5, centerY - 1, centerX + 7, centerY + 2, color); + GuiComponent.fill(poseStack, centerX - 2, centerY - 2, centerX + 3, centerY + 3, 0xFF303030); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java new file mode 100644 index 00000000..91a6f27c --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/CompactScrollList.java @@ -0,0 +1,294 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.Collections; +import java.util.List; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.Font; + +/** + * Compact text list with independent row and scrollbar input. + * It deliberately avoids GuiSlot, whose legacy coordinate handling assumes a + * full-width screen and is unsuitable for two adjacent panes. + */ +abstract class CompactScrollList extends Button { + static final int DEFAULT_ROW_HEIGHT = 16; + private static final int SCROLLBAR_WIDTH = 10; + private static final int ARROW_HEIGHT = 9; + private static final int MIN_THUMB_HEIGHT = 8; + + private final OreSpawnScreen owner; + private final int rowHeight; + private int firstIndex; + private boolean dragging; + private int dragOffset; + private int hoveredIndex = -1; + private boolean hoveredAction; + private boolean pointerInside; + + CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height) { + this(owner, x, y, width, height, DEFAULT_ROW_HEIGHT); + } + + CompactScrollList(OreSpawnScreen owner, int x, int y, int width, int height, int rowHeight) { + super(x, y, width, height, "", null); + this.owner = owner; + this.rowHeight = Math.max(12, rowHeight); + } + + protected abstract int size(); + + protected abstract String rowText(int index); + + protected void onRowPressed(int index) { } + + protected boolean rowEnabled(int index) { + return true; + } + + protected boolean rowSelected(int index) { + return false; + } + + protected int rowColor(int index) { + return rowEnabled(index) ? 0xFFFFFF : 0x777777; + } + + protected List rowTooltip(int index) { + return Collections.singletonList(rowText(index)); + } + + protected int rowActionWidth(int index) { + return 0; + } + + protected void drawRowAction(Minecraft minecraft, int index, int x, int y, + int width, int height, boolean hovered) { } + + protected void onRowActionPressed(int index) { } + + protected List rowActionTooltip(int index) { + return rowTooltip(index); + } + + @Override + public void render(int mouseX, int mouseY, float partialTicks) { + if (!visible) return; + Minecraft minecraft = Minecraft.getInstance(); + clampFirstIndex(); + pointerInside = contains(mouseX, mouseY); + + fill(x, y, x + width, y + height, 0x76000000); + hoveredIndex = rowAt(mouseX, mouseY); + hoveredAction = hoveredIndex >= 0 && isInRowAction(hoveredIndex, mouseX); + Font font = minecraft.font; + int rows = visibleRows(); + int contentRight = contentRight(); + for (int row = 0; row < rows; row++) { + int index = firstIndex + row; + if (index >= size()) break; + int rowY = y + (row * rowHeight); + boolean hovered = index == hoveredIndex; + if (rowSelected(index)) { + fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0xA052648C); + } else if (hovered) { + fill(x + 1, rowY, contentRight, rowY + rowHeight - 1, 0x80404040); + } + int actionWidth = Math.max(0, rowActionWidth(index)); + int textWidth = Math.max(0, contentRight - x - actionWidth - 8); + String text = fit(font, rowText(index), textWidth); + font.drawShadow(currentPoseStack(), text, x + 4, + rowY + Math.max(2, (rowHeight - 8) / 2), rowColor(index)); + if (actionWidth > 0) { + int actionX = contentRight - actionWidth; + drawRowAction(minecraft, index, actionX, rowY, + actionWidth, rowHeight - 1, hovered && mouseX >= actionX); + } + } + drawScrollbar(minecraft, mouseX, mouseY); + } + + @Override + public boolean mouseClicked(double mouseX, double mouseY, int button) { + if (button != 0 || !visible || !active || !contains((int) mouseX, (int) mouseY)) return false; + int x = (int) mouseX; + int y = (int) mouseY; + if (x >= contentRight()) { + pressScrollbar(y); + return true; + } + int index = rowAt(x, y); + if (index < 0) return true; + if (isInRowAction(index, x)) onRowActionPressed(index); + else if (rowEnabled(index)) onRowPressed(index); + return true; + } + + @Override + public boolean mouseReleased(double mouseX, double mouseY, int button) { + dragging = false; + return button == 0 && contains((int) mouseX, (int) mouseY); + } + + @Override + public boolean mouseDragged(double mouseX, double mouseY, int button, + double deltaX, double deltaY) { + if (!dragging || button != 0) return false; + dragTo((int) mouseY - dragOffset); + return true; + } + + void renderTooltip(int mouseX, int mouseY) { + if (!visible || hoveredIndex < 0 || !contains(mouseX, mouseY)) return; + List lines = hoveredAction ? rowActionTooltip(hoveredIndex) + : rowTooltip(hoveredIndex); + if (lines != null && !lines.isEmpty()) owner.renderStringTooltip(lines, mouseX, mouseY); + } + + boolean mouseWheel(int mouseX, int mouseY, int delta) { + if (!visible || delta == 0 || !contains(mouseX, mouseY)) return false; + scrollBy(delta > 0 ? -3 : 3); + return true; + } + + @Override + public boolean mouseScrolled(double mouseX, double mouseY, double delta) { + if (!visible || !active || !contains((int) mouseX, (int) mouseY) || delta == 0.0D) return false; + scrollBy(delta > 0.0D ? -3 : 3); + return true; + } + + void scrollBy(int rows) { + setFirstIndex(firstIndex + rows); + } + + int firstIndex() { + return firstIndex; + } + + void setFirstIndex(int index) { + firstIndex = Math.max(0, Math.min(index, maxFirst(size(), visibleRows()))); + } + + void ensureVisible(int index) { + if (index < firstIndex) setFirstIndex(index); + else if (index >= firstIndex + visibleRows()) setFirstIndex(index - visibleRows() + 1); + } + + int visibleRows() { + return visibleRows(height, rowHeight); + } + + int rowY(int index) { + return index < firstIndex || index >= firstIndex + visibleRows() + ? -1 : y + ((index - firstIndex) * rowHeight); + } + + int contentRight() { + return x + width - SCROLLBAR_WIDTH; + } + + private boolean contains(int mouseX, int mouseY) { + return mouseX >= x && mouseX < x + width + && mouseY >= y && mouseY < y + height; + } + + private int rowAt(int mouseX, int mouseY) { + if (!contains(mouseX, mouseY) || mouseX >= contentRight()) return -1; + int index = firstIndex + ((mouseY - y) / rowHeight); + return index >= 0 && index < size() ? index : -1; + } + + private boolean isInRowAction(int index, int mouseX) { + int actionWidth = Math.max(0, rowActionWidth(index)); + return actionWidth > 0 && mouseX >= contentRight() - actionWidth; + } + + private void pressScrollbar(int mouseY) { + if (mouseY < y + ARROW_HEIGHT) { + scrollBy(-1); + return; + } + if (mouseY >= y + height - ARROW_HEIGHT) { + scrollBy(1); + return; + } + int thumbY = thumbY(); + int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight()); + if (mouseY >= thumbY && mouseY < thumbY + thumbHeight) { + dragging = true; + dragOffset = mouseY - thumbY; + } else { + scrollBy(mouseY < thumbY ? -visibleRows() : visibleRows()); + } + } + + private void dragTo(int thumbTop) { + int maximum = maxFirst(size(), visibleRows()); + int track = trackHeight(); + int thumb = thumbHeight(size(), visibleRows(), track); + int travel = Math.max(1, track - thumb); + int relative = Math.max(0, Math.min(travel, thumbTop - (y + ARROW_HEIGHT))); + setFirstIndex(firstFromThumb(relative, travel, maximum)); + } + + private int trackHeight() { + return Math.max(1, height - (ARROW_HEIGHT * 2)); + } + + private int thumbY() { + int maximum = maxFirst(size(), visibleRows()); + int track = trackHeight(); + int thumb = thumbHeight(size(), visibleRows(), track); + int travel = Math.max(0, track - thumb); + return y + ARROW_HEIGHT + (maximum == 0 ? 0 + : Math.round((float) firstIndex * travel / maximum)); + } + + private void drawScrollbar(Minecraft minecraft, int mouseX, int mouseY) { + int left = contentRight(); + int right = x + width; + fill(left, y, right, y + height, 0xB0202020); + Font font = minecraft.font; + drawCenteredString(currentPoseStack(), font, "^", left + (SCROLLBAR_WIDTH / 2), y, 0xDDDDDD); + drawCenteredString(currentPoseStack(), font, "v", left + (SCROLLBAR_WIDTH / 2), + y + height - ARROW_HEIGHT, 0xDDDDDD); + int thumbY = thumbY(); + int thumbHeight = thumbHeight(size(), visibleRows(), trackHeight()); + fill(left + 1, thumbY, right - 1, thumbY + thumbHeight, + mouseX >= left && mouseX < right && mouseY >= thumbY + && mouseY < thumbY + thumbHeight ? 0xFFE0E0E0 : 0xFF909090); + } + + private void clampFirstIndex() { + setFirstIndex(firstIndex); + } + + private static String fit(Font font, String text, int width) { + if (width <= 0) return ""; + if (font.width(text) <= width) return text; + String suffix = "..."; + return font.plainSubstrByWidth(text, + Math.max(0, width - font.width(suffix))) + suffix; + } + + static int visibleRows(int height, int rowHeight) { + return Math.max(1, Math.max(1, height) / Math.max(1, rowHeight)); + } + + static int maxFirst(int entries, int visibleRows) { + return Math.max(0, Math.max(0, entries) - Math.max(1, visibleRows)); + } + + static int thumbHeight(int entries, int visibleRows, int trackHeight) { + if (entries <= visibleRows) return Math.max(1, trackHeight); + return Math.max(MIN_THUMB_HEIGHT, + Math.min(trackHeight, (trackHeight * Math.max(1, visibleRows)) / Math.max(1, entries))); + } + + static int firstFromThumb(int relative, int travel, int maximum) { + if (travel <= 0 || maximum <= 0) return 0; + return Math.max(0, Math.min(maximum, + Math.round((float) relative * maximum / travel))); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java index 8cbd83c3..663e3d05 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyEditorSession.java @@ -3,7 +3,9 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.HashMap; import java.util.LinkedHashMap; +import java.util.LinkedHashSet; import java.util.HashSet; import java.util.List; import java.util.Locale; @@ -15,6 +17,8 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; +import com.google.gson.JsonPrimitive; import zone.moddev.mc.orespawn.worldgen.OreHeightDistribution; import zone.moddev.mc.orespawn.worldgen.OrePattern; import zone.moddev.mc.orespawn.worldgen.GeomeConfig; @@ -22,6 +26,8 @@ import zone.moddev.mc.orespawn.init.OreSpawnPatterns; import zone.moddev.mc.orespawn.api.OreDimensionSelector; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.BiomeProviderDefaultsSnapshot; import net.minecraft.resources.ResourceLocation; import net.minecraft.core.BlockPos; @@ -32,6 +38,7 @@ import net.minecraft.world.level.block.LiquidBlock; import net.minecraft.world.item.Items; import net.minecraftforge.registries.ForgeRegistries; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Mutable client-side copy used until the Create World settings are accepted. */ final class GeologyEditorSession { @@ -56,6 +63,8 @@ enum MaterialTab { private final WorldGeologyProfile originalProfile; private final JsonObject original; private final JsonObject root; + private JsonObject oreSourceOriginal; + private final BiomeProviderDefaultsSnapshot biomeProviderDefaults; private final Set availableDimensionIds = new TreeSet<>(); GeologyEditorSession(WorldGeologyProfile profile) { @@ -63,11 +72,19 @@ enum MaterialTab { } GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions) { + this(profile, dimensions, WorldgenIntegrationManager.biomeProviderDefaults()); + } + + GeologyEditorSession(WorldGeologyProfile profile, Iterable dimensions, + BiomeProviderDefaultsSnapshot defaults) { originalProfile = profile; + biomeProviderDefaults = defaults; original = profile.rootCopy(); root = profile.rootCopy(); normalizeRegistrySections(original); normalizeRegistrySections(root); + oreSourceOriginal = profile.rootCopy(); + normalizeRegistrySections(oreSourceOriginal); rememberDimension("minecraft:overworld"); rememberDimension("minecraft:the_nether"); rememberDimension("minecraft:the_end"); @@ -90,6 +107,44 @@ void applyProfile(WorldGeologyProfile profile) { normalizeRegistrySections(root); } + void setManageVanillaOres(boolean enabled) { + WorldGeologyProfile current = profile(); + JsonObject refreshed = current.rootCopy(); + refreshed.addProperty("manage_vanilla_ores", enabled); + applyProfile(WorldGeologyProfile.fromGlobalConfig(refreshed, + current.geologyMode(), current.placeFluidDeposits())); + synchronizeVanillaPlacementSources(enabled); + } + + private void synchronizeVanillaPlacementSources(boolean enabled) { + JsonObject ores = section("ores"); + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + JsonObject placements = objectEntry(policy, "placement_sources"); + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + if (!ores.has(sourceId) || !ores.get(sourceId).isJsonObject() + || !bool(ores.getAsJsonObject(sourceId), "native_generation", false)) continue; + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (enabled && bool(candidate, "loaded", false) + && bool(candidate, "placement_active", false) + && !bool(candidate, "external", false) + && !bool(candidate, "enrichment", false) + && !placements.has(channel)) { + placements.addProperty(channel, sourceId); + } else if (!enabled && placements.has(channel) + && sourceId.equals(string(placements, channel, ""))) { + placements.remove(channel); + } + } + refreshOreSourceStatus(policy); + } + } + JsonObject section(String key) { return object(root, key); } @@ -120,6 +175,13 @@ List availableDimensionIds() { if (validResource(id)) result.add(new ResourceLocation(id).toString()); } } + for (String sectionName : Arrays.asList("biome_palettes", "dimension_materials")) { + for (Entry entry : section(sectionName).entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + String id = string(entry.getValue().getAsJsonObject(), "dimension", ""); + if (validResource(id)) result.add(new ResourceLocation(id).toString()); + } + } List ordered = new ArrayList<>(); for (String vanilla : Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")) { ordered.add(vanilla); @@ -344,6 +406,775 @@ void disableOrRemoveOre(String id) { } } + List oreSourceGroups() { + List result = new ArrayList<>(); + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + String material = string(policy, "material", ""); + String domain = string(policy, "domain", ""); + if (!validResource(material) || !validResource(domain)) continue; + JsonObject groupDefinitions = section("ore_material_groups"); + JsonObject groupDefinition = groupDefinitions.has(material) + && groupDefinitions.get(material).isJsonObject() + ? groupDefinitions.getAsJsonObject(material) : new JsonObject(); + String displayName = string(groupDefinition, "display_name", displayName(material)); + List groupDictionary = stringList(groupDefinition, "block_tag_entries"); + boolean curated = "orespawn:sulfur".equals(material) || "orespawn:aluminum".equals(material); + List candidates = new ArrayList<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String registryId = string(candidate, "registry_id", ""); + if (sourceId.isEmpty() || !validResource(channel) || !validResource(registryId)) continue; + List dictionary = new ArrayList<>(); + if (candidate.has("block_tags") && candidate.get("block_tags").isJsonArray()) { + for (JsonElement name : candidate.getAsJsonArray("block_tags")) { + try { dictionary.add(name.getAsString()); } + catch (RuntimeException ignored) { } + } + } + candidates.add(new OreSourceCandidate(sourceId, + string(candidate, "owner", ""), string(candidate, "owner_name", ""), + string(candidate, "owner_version", ""), registryId, + integer(candidate, "metadata", 0), channel, dictionary, + bool(candidate, "loaded", false), bool(candidate, "placement_active", true), + bool(candidate, "external", false), + bool(candidate, "enrichment", false), + bool(candidate, "review_required", false))); + } + } + Collections.sort(candidates, (left, right) -> { + int owner = left.owner.compareTo(right.owner); + return owner != 0 ? owner : left.sourceId.compareTo(right.sourceId); + }); + JsonObject viewPolicy = JsonCopies.copy(policy); + refreshOreSourceStatus(viewPolicy); + OreSourceGroup group = new OreSourceGroup(entry.getKey(), material, domain, + displayName, groupDictionary, curated, + string(policy, "mode", "keep_separate"), outputMode(policy), + string(viewPolicy, "status", "review_required"), + candidates, decimalMap(policy, "outputs"), stringMap(policy, "placement_sources")); + if (!bool(policy, "dormant", false)) result.add(group); + } + Collections.sort(result, (left, right) -> { + int name = left.displayName.compareToIgnoreCase(right.displayName); + if (name != 0) return name; + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return Collections.unmodifiableList(result); + } + + String addOreMaterialGroup() { + JsonObject groups = section("ore_material_groups"); + JsonObject policies = section("ore_source_policies"); + String base = "orespawn:custom/custom_group"; + String id = base; + for (int suffix = 2; groups.has(id) + || policies.has(id + "|minecraft:overworld"); suffix++) id = base + '_' + suffix; + JsonObject definition = new JsonObject(); + definition.addProperty("display_name", "Custom Group"); + definition.add("block_tag_entries", new JsonArray()); + groups.add(id, definition); + String key = id + "|minecraft:overworld"; + JsonObject policy = objectEntry(policies, key); + policy.remove("dormant"); + policy.addProperty("material", id); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + policy.addProperty("status", "separate"); + policy.addProperty("review_required", false); + policy.add("outputs", new JsonObject()); + policy.add("placement_sources", new JsonObject()); + policy.add("candidates", new JsonArray()); + return key; + } + + void renameOreMaterialGroup(String material, String displayName) { + if (!validResource(material)) return; + String value = displayName == null ? "" : displayName.trim(); + if (value.isEmpty()) return; + objectEntry(section("ore_material_groups"), material) + .addProperty("display_name", value.length() > 64 ? value.substring(0, 64) : value); + } + + String blockTagOwner(String alias) { + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + if (stringList(entry.getValue().getAsJsonObject(), "block_tag_entries").contains(alias)) { + return entry.getKey(); + } + } + return null; + } + + boolean vanillaOreManagementRequiredForAliasMove(String material, String alias) { + if (bool(root, "manage_vanilla_ores", false)) return false; + String owner = blockTagOwner(alias); + if (owner == null || owner.equals(material)) return false; + JsonObject ores = section("ores"); + for (OreSourceGroup group : oreSourceGroups()) { + if (!owner.equals(group.material)) continue; + for (OreSourceCandidate candidate : group.candidates) { + if (!candidate.loaded || candidate.external || !candidate.blockTag.contains(alias) + || !ores.has(candidate.sourceId) || !ores.get(candidate.sourceId).isJsonObject()) continue; + if (bool(ores.getAsJsonObject(candidate.sourceId), "native_generation", false)) return true; + } + } + return false; + } + + boolean addOreMaterialAlias(String material, String alias, boolean moveExisting) { + if (!validResource(material) || !validBlockTagName(alias)) return false; + String owner = blockTagOwner(alias); + if (owner != null && !owner.equals(material)) { + if (!moveExisting) return false; + if (vanillaOreManagementRequiredForAliasMove(material, alias)) return false; + removeOreMaterialAlias(owner, alias); + } + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (!names.contains(alias)) names.add(alias); + Collections.sort(names); + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + return true; + } + + void removeOreMaterialAlias(String material, String alias) { + if (!validResource(material)) return; + JsonObject definition = objectEntry(section("ore_material_groups"), material); + List names = stringList(definition, "block_tag_entries"); + if (names.remove(alias)) { + definition.add("block_tag_entries", strings(names)); + rebuildOreSourcePolicies(); + } + } + + void resetOreMaterialGroup(String material) { + if ("orespawn:sulfur".equals(material)) { + setOreMaterialGroup(material, "Sulfur", + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")); + } else if ("orespawn:aluminum".equals(material)) { + setOreMaterialGroup(material, "Aluminum", + Arrays.asList("forge:ores/aluminum", "forge:ores/aluminium")); + } + rebuildOreSourcePolicies(); + } + + boolean deleteEmptyOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject() + || !stringList(groups.getAsJsonObject(material), "block_tag_entries").isEmpty()) { + return false; + } + groups.remove(material); + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + boolean dissolveOreMaterialGroup(String material) { + if (!isCustomMaterial(material)) return false; + JsonObject groups = section("ore_material_groups"); + if (!groups.has(material) || !groups.get(material).isJsonObject()) return false; + List aliases = stringList(groups.getAsJsonObject(material), "block_tag_entries"); + if (aliases.isEmpty()) return false; + groups.remove(material); + for (String alias : aliases) { + String restoredMaterial = inferredMaterialForAlias(alias); + JsonObject definition = objectEntry(groups, restoredMaterial); + if (!definition.has("display_name")) { + definition.addProperty("display_name", displayName(restoredMaterial)); + } + List restoredAliases = stringList(definition, "block_tag_entries"); + if (!restoredAliases.contains(alias)) restoredAliases.add(alias); + Collections.sort(restoredAliases); + definition.add("block_tag_entries", strings(restoredAliases)); + } + markMaterialPoliciesDormant(material); + rebuildOreSourcePolicies(); + return true; + } + + private void markMaterialPoliciesDormant(String material) { + for (Entry entry : section("ore_source_policies").entrySet()) { + if (entry.getValue().isJsonObject() + && material.equals(string(entry.getValue().getAsJsonObject(), "material", ""))) { + entry.getValue().getAsJsonObject().addProperty("dormant", true); + } + } + } + + boolean oreMaterialGroupsChanged() { + return !sectionCopy(original, "ore_material_groups").equals(sectionCopy(root, "ore_material_groups")); + } + + JsonObject oreMaterialGroupsCopy() { + return sectionCopy(root, "ore_material_groups"); + } + + void resetOreSourcesToDefaults() { + WorldGeologyProfile current = profile(); + JsonObject reset = current.rootCopy(); + reset.remove("ore_material_groups"); + reset.remove("ore_source_policies"); + WorldGeologyProfile defaults = WorldGeologyProfile.fromGlobalConfig(reset, + current.geologyMode(), current.placeFluidDeposits()); + applyProfile(defaults); + oreSourceOriginal = defaults.rootCopy(); + normalizeRegistrySections(oreSourceOriginal); + } + + private void setOreMaterialGroup(String material, String displayName, List aliases) { + JsonObject definition = objectEntry(section("ore_material_groups"), material); + definition.addProperty("display_name", displayName); + definition.add("block_tag_entries", strings(aliases)); + } + + private void rebuildOreSourcePolicies() { + JsonObject policies = section("ore_source_policies"); + Map> previousMembers = new LinkedHashMap<>(); + Map candidates = new LinkedHashMap<>(); + for (Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + Set members = new LinkedHashSet<>(); + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) continue; + boolean hasCandidates = policy.getAsJsonArray("candidates").size() > 0; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = JsonCopies.copy(element.getAsJsonObject()); + String identity = candidateIdentity(candidate, string(policy, "domain", "")); + members.add(identity); + candidates.put(identity, candidate); + } + previousMembers.put(entry.getKey(), members); + String material = string(policy, "material", ""); + if (!hasCandidates && section("ore_material_groups").has(material)) { + policy.remove("dormant"); + } else { + policy.addProperty("dormant", true); + } + } + Map> regrouped = new LinkedHashMap<>(); + for (JsonObject candidate : candidates.values()) { + String material = string(candidate, "material", ""); + if (!bool(candidate, "material_declared", false)) { + material = materialForAliases(stringList(candidate, "block_tags")); + if (!material.isEmpty()) candidate.addProperty("material", material); + } + String domain = string(candidate, "domain", "minecraft:overworld"); + if (!validResource(material) || !validResource(domain)) continue; + regrouped.computeIfAbsent(material + '|' + domain, ignored -> new ArrayList<>()).add(candidate); + } + for (Entry> entry : regrouped.entrySet()) { + int split = entry.getKey().indexOf('|'); + String material = entry.getKey().substring(0, split); + String domain = entry.getKey().substring(split + 1); + JsonObject policy = policies.has(entry.getKey()) && policies.get(entry.getKey()).isJsonObject() + ? policies.getAsJsonObject(entry.getKey()) : new JsonObject(); + policy.remove("dormant"); + policy.addProperty("material", material); + policy.addProperty("domain", domain); + if (!policy.has("mode")) policy.addProperty("mode", "keep_separate"); + if (!policy.has("output_mode")) policy.addProperty("output_mode", "custom"); + JsonArray array = new JsonArray(); + Set currentMembers = new LinkedHashSet<>(); + for (JsonObject candidate : entry.getValue()) { + array.add(candidate); + currentMembers.add(candidateIdentity(candidate, domain)); + } + policy.add("candidates", array); + reconcileOreSourceOutputs(policy); + reconcileOreSourcePlacements(policy); + Set previous = previousMembers.get(entry.getKey()); + boolean membershipChanged = previous == null || !previous.equals(currentMembers); + if (membershipChanged) { + policy.addProperty("review_required", outputCandidateJson(policy).size() > 1); + } + refreshOreSourceStatus(policy); + policies.add(entry.getKey(), policy); + } + } + + private static void reconcileOreSourceOutputs(JsonObject policy) { + List candidates = outputCandidateJson(policy); + Set candidateIds = allOutputCandidateIds(policy); + JsonObject outputs = objectEntry(policy, "outputs"); + for (String sourceId : new ArrayList<>(JsonCopies.keys(outputs))) { + if (!candidateIds.contains(sourceId)) outputs.remove(sourceId); + } + String mode = string(policy, "mode", "keep_separate"); + String outputMode = outputMode(policy); + if ("keep_separate".equals(mode) || "balanced".equals(outputMode)) { + for (JsonObject candidate : candidates) { + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty() && bool(candidate, "loaded", false)) { + outputs.addProperty(sourceId, 1.0D); + } + } + } else if ("single".equals(outputMode)) { + String selected = ""; + for (String sourceId : JsonCopies.keys(outputs)) { + if (candidateIds.contains(sourceId)) { + selected = sourceId; + break; + } + } + if (selected.isEmpty()) { + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + } + + private static void reconcileOreSourcePlacements(JsonObject policy) { + Map> retainedChoices = new LinkedHashMap<>(); + Map> loadedChoices = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "external", false) || bool(candidate, "enrichment", false)) continue; + String channel = string(candidate, "placement_channel", "orespawn:standard"); + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) { + retainedChoices.computeIfAbsent(channel, ignored -> new LinkedHashSet<>()).add(sourceId); + } + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false)) { + loadedChoices.computeIfAbsent(channel, ignored -> new ArrayList<>()).add(candidate); + } + } + } + JsonObject placements = objectEntry(policy, "placement_sources"); + for (String channel : new ArrayList<>(JsonCopies.keys(placements))) { + String selected = string(placements, channel, ""); + if (!retainedChoices.getOrDefault(channel, Collections.emptySet()).contains(selected)) { + placements.remove(channel); + } + } + for (Entry> entry : loadedChoices.entrySet()) { + if (!placements.has(entry.getKey()) && !entry.getValue().isEmpty()) { + placements.addProperty(entry.getKey(), string(entry.getValue().get(0), "source_id", "")); + } + } + } + + private static Set allOutputCandidateIds(JsonObject policy) { + Set result = new LinkedHashSet<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) result.add(sourceId); + } + } + return result; + } + + private static String candidateIdentity(JsonObject candidate, String fallbackDomain) { + return string(candidate, "source_id", "") + '|' + + string(candidate, "placement_channel", "orespawn:standard") + '|' + + string(candidate, "domain", fallbackDomain); + } + + private String materialForAliases(List aliases) { + Set materials = new LinkedHashSet<>(); + for (String alias : aliases) { + String owner = blockTagOwner(alias); + if (owner != null) materials.add(owner); + else if (validBlockTagName(alias)) { + materials.add("orespawn:" + oreTagMaterial(alias)); + } + } + return materials.size() == 1 ? materials.iterator().next() + : materials.size() > 1 ? provisionalMaterialForAliases(aliases) : ""; + } + + private static String provisionalMaterialForAliases(List aliases) { + Set unique = new LinkedHashSet<>(); + for (String alias : aliases) if (validBlockTagName(alias)) unique.add(alias); + List exact = new ArrayList<>(unique); + Collections.sort(exact); + String token = exact.isEmpty() ? "unknown" : oreTagMaterial(exact.get(0)); + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return "orespawn:review/" + token + '-' + padded.append(value).toString(); + } + + private static boolean isCustomMaterial(String material) { + return material != null && material.startsWith("orespawn:custom/") && validResource(material); + } + + private static String inferredMaterialForAlias(String alias) { + if ("forge:ores/sulfur".equals(alias) || "forge:ores/sulphur".equals(alias)) return "orespawn:sulfur"; + if ("forge:ores/aluminum".equals(alias) || "forge:ores/aluminium".equals(alias)) return "orespawn:aluminum"; + return "orespawn:" + oreTagMaterial(alias); + } + + void setOreSourceMode(String key, boolean consolidated) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("mode", consolidated ? "consolidated" : "keep_separate"); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void acceptOreSourcePolicy(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void restoreOreSourceOriginalMode(String key) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject originalPolicies = object(oreSourceOriginal, "ore_source_policies"); + JsonObject originalPolicy = originalPolicies.has(key) && originalPolicies.get(key).isJsonObject() + ? originalPolicies.getAsJsonObject(key) : null; + policy.addProperty("mode", "keep_separate"); + if (originalPolicy != null) { + policy.addProperty("output_mode", outputMode(originalPolicy)); + policy.add("outputs", sectionCopy(originalPolicy, "outputs")); + policy.add("placement_sources", sectionCopy(originalPolicy, "placement_sources")); + } else { + JsonObject outputs = new JsonObject(); + JsonObject placements = new JsonObject(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) + || !bool(candidate, "placement_active", false) + || bool(candidate, "external", false) + || bool(candidate, "enrichment", false)) continue; + String sourceId = string(candidate, "source_id", ""); + String channel = string(candidate, "placement_channel", "orespawn:standard"); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + if (!sourceId.isEmpty() && !placements.has(channel)) { + placements.addProperty(channel, sourceId); + } + } + } + policy.addProperty("output_mode", "custom"); + policy.add("outputs", outputs); + policy.add("placement_sources", placements); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutputMode(String key, String outputMode) { + if (!"balanced".equals(outputMode) && !"single".equals(outputMode) + && !"custom".equals(outputMode)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + if (!hasManagedPlacementSource(policy)) return; + policy.addProperty("mode", "consolidated"); + policy.addProperty("output_mode", outputMode); + JsonObject outputs = objectEntry(policy, "outputs"); + if ("balanced".equals(outputMode)) { + outputs.entrySet().clear(); + for (JsonObject candidate : outputCandidateJson(policy)) { + if (bool(candidate, "external", false)) continue; + String sourceId = string(candidate, "source_id", ""); + if (!sourceId.isEmpty()) outputs.addProperty(sourceId, 1.0D); + } + } else if ("single".equals(outputMode)) { + String selected = preferredSingleOutput(policy, outputs); + if (!loadedOutput(policy, selected)) { + List candidates = outputCandidateJson(policy); + selected = candidates.isEmpty() ? "" : string(candidates.get(0), "source_id", ""); + } + outputs.entrySet().clear(); + if (!selected.isEmpty()) outputs.addProperty(selected, 1.0D); + } + policy.addProperty("review_required", false); + refreshOreSourceStatus(policy); + } + + private static String preferredSingleOutput(JsonObject policy, JsonObject outputs) { + JsonObject placements = objectEntry(policy, "placement_sources"); + for (Entry entry : placements.entrySet()) { + try { + String sourceId = entry.getValue().getAsString(); + if (outputs.has(sourceId) && loadedOutput(policy, sourceId)) return sourceId; + } catch (RuntimeException ignored) { } + } + return outputs.entrySet().isEmpty() ? "" : outputs.entrySet().iterator().next().getKey(); + } + + void setOreSourcePlacement(String key, String channel, String sourceId) { + if (!validResource(channel)) return; + JsonObject policy = objectEntry(section("ore_source_policies"), key); + objectEntry(policy, "placement_sources").addProperty(channel, sourceId); + refreshOreSourceStatus(policy); + } + + void setOreSourceOutput(String key, String sourceId, boolean enabled, double weight) { + JsonObject policy = objectEntry(section("ore_source_policies"), key); + JsonObject outputs = objectEntry(policy, "outputs"); + String outputMode = outputMode(policy); + if (!enabled) outputs.remove(sourceId); + else if ("single".equals(outputMode)) { + outputs.entrySet().clear(); + outputs.addProperty(sourceId, 1.0D); + } else outputs.addProperty(sourceId, "balanced".equals(outputMode) ? 1.0D + : Math.max(0.001D, Math.min(1000000.0D, weight))); + refreshOreSourceStatus(policy); + } + + private static List outputCandidateJson(JsonObject policy) { + Map unique = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!bool(candidate, "loaded", false) || bool(candidate, "enrichment", false)) continue; + String identity = string(candidate, "registry_id", "") + '|' + + integer(candidate, "metadata", 0); + JsonObject previous = unique.get(identity); + if (previous == null || (!bool(previous, "placement_active", false) + && bool(candidate, "placement_active", false)) + || (bool(previous, "external", false) && !bool(candidate, "external", false))) { + unique.put(identity, candidate); + } + } + } + return new ArrayList<>(unique.values()); + } + + private static boolean loadedOutput(JsonObject policy, String sourceId) { + if (sourceId == null || sourceId.isEmpty()) return false; + for (JsonObject candidate : outputCandidateJson(policy)) { + if (sourceId.equals(string(candidate, "source_id", ""))) return true; + } + return false; + } + + private static boolean hasManagedPlacementSource(JsonObject policy) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "loaded", false) && bool(candidate, "placement_active", false) + && !bool(candidate, "external", false) + && !bool(candidate, "enrichment", false)) return true; + } + return false; + } + + private static void refreshOreSourceStatus(JsonObject policy) { + Set missing = new HashSet<>(); + Set inactivePlacements = new HashSet<>(); + boolean external = false; + boolean review = bool(policy, "review_required", false); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + String sourceId = string(candidate, "source_id", ""); + if (!bool(candidate, "loaded", false)) missing.add(sourceId); + if (!bool(candidate, "placement_active", false)) inactivePlacements.add(sourceId); + external |= bool(candidate, "external", false) && bool(candidate, "loaded", false); + } + } + Map outputs = decimalMap(policy, "outputs"); + Map placements = stringMap(policy, "placement_sources"); + if ("consolidated".equals(string(policy, "mode", "keep_separate")) + && (outputs.isEmpty() || placements.isEmpty())) { + policy.addProperty("status", "missing_source"); + return; + } + for (String id : outputs.keySet()) { + if (missing.contains(id)) { policy.addProperty("status", "missing_source"); return; } + } + for (String id : placements.values()) { + if (missing.contains(id) || inactivePlacements.contains(id)) { + policy.addProperty("status", "missing_source"); return; + } + } + policy.addProperty("status", external ? "external_generation" : review ? "review_required" + : "consolidated".equals(string(policy, "mode", "keep_separate")) + ? "consolidated" : "separate"); + } + + private static Map decimalMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static Map stringMap(JsonObject parent, String key) { + if (!parent.has(key) || !parent.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : parent.getAsJsonObject(key).entrySet()) { + try { result.put(entry.getKey(), entry.getValue().getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + static final class OreSourceGroup { + final String key; + final String material; + final String domain; + final String displayName; + final List blockTagEntries; + final boolean curated; + final String mode; + final String outputMode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + OreSourceGroup(String key, String material, String domain, String displayName, + List blockTagEntries, boolean curated, String mode, String outputMode, String status, + List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.curated = curated; + this.mode = mode; + this.outputMode = outputMode; + this.status = status; + this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates)); + this.outputs = outputs; + this.placements = placements; + } + + List channels() { + Set result = new TreeSet<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.external && candidate.active) result.add(candidate.channel); + } + return Collections.unmodifiableList(new ArrayList<>(result)); + } + + List outputCandidates() { + Map unique = new LinkedHashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (candidate.enrichment) continue; + String identity = candidate.registryId + '|' + candidate.metadata; + OreSourceCandidate previous = unique.get(identity); + if (previous == null || (!previous.active && candidate.active) + || (previous.external && !candidate.external)) unique.put(identity, candidate); + } + return Collections.unmodifiableList(new ArrayList<>(unique.values())); + } + + boolean needsAttention() { + return needsReview() || "missing_source".equals(status) + || outputCandidates().isEmpty(); + } + + boolean needsReview() { + return "review_required".equals(status); + } + + boolean hasManagedPlacementSource() { + for (OreSourceCandidate candidate : candidates) { + if (candidate.loaded && candidate.active && !candidate.external + && !candidate.enrichment) return true; + } + return false; + } + + boolean isRoutineSingleSource() { + return !needsAttention() && blockTagEntries.size() == 1 + && outputCandidates().size() == 1; + } + + boolean isCustom() { + return isCustomMaterial(material); + } + + boolean canDeleteEmpty() { + return isCustom() && blockTagEntries.isEmpty(); + } + + boolean canDissolve() { + return isCustom() && !blockTagEntries.isEmpty(); + } + + boolean isRedundantSeparatePolicy() { + if (!"keep_separate".equals(mode) || candidates.isEmpty()) return false; + Set outputs = new HashSet<>(); + Map> ownersByChannel = new HashMap<>(); + for (OreSourceCandidate candidate : candidates) { + if (!candidate.loaded || candidate.external || candidate.enrichment + || candidate.reviewRequired) return false; + outputs.add(candidate.registryId + '|' + candidate.metadata); + ownersByChannel.computeIfAbsent(candidate.channel, ignored -> new HashSet<>()) + .add(candidate.owner); + } + if (outputs.size() > 1) return false; + for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return false; + return true; + } + } + + static final class OreSourceCandidate { + final String sourceId; + final String owner; + final String ownerName; + final String ownerVersion; + final String registryId; + final int metadata; + final String channel; + final List blockTag; + final boolean loaded; + final boolean active; + final boolean external; + final boolean enrichment; + final boolean reviewRequired; + + OreSourceCandidate(String sourceId, String owner, String ownerName, String ownerVersion, + String registryId, int metadata, String channel, List blockTag, + boolean loaded, boolean active, boolean external, boolean enrichment, boolean reviewRequired) { + this.sourceId = sourceId; + this.owner = owner; + this.ownerName = ownerName; + this.ownerVersion = ownerVersion; + this.registryId = registryId; + this.metadata = metadata; + this.channel = channel; + this.blockTag = Collections.unmodifiableList(new ArrayList<>(blockTag)); + this.loaded = loaded; + this.active = active; + this.external = external; + this.enrichment = enrichment; + this.reviewRequired = reviewRequired; + } + } + void resetEntry(String section, String id) { JsonObject originalSection = object(original, section); if (originalSection.has(id)) { @@ -391,6 +1222,159 @@ List installedBiomeIds() { return result; } + BiomeDirectoryModel.Snapshot biomeDirectory() { + return BiomeDirectoryModel.snapshot(root, biomeProviderDefaults, availableDimensionIds()); + } + + List biomePaletteIds(String dimensionId) { + List result = new ArrayList<>(); + for (Entry entry : section("biome_palettes").entrySet()) { + if (!entry.getValue().isJsonObject() + || BiomeReplacementRules.isOverridePalette(entry.getKey())) continue; + if (dimensionId.equals(string(entry.getValue().getAsJsonObject(), "dimension", ""))) { + result.add(entry.getKey()); + } + } + return Collections.unmodifiableList(result); + } + + JsonObject biomePaletteById(String paletteId) { + JsonObject palettes = section("biome_palettes"); + return palettes.has(paletteId) && palettes.get(paletteId).isJsonObject() + ? palettes.getAsJsonObject(paletteId) : null; + } + + List biomePlacementIdsForPalette(String paletteId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette == null) return Collections.emptyList(); + List result = new ArrayList<>(JsonCopies.keys(object(palette, "biomes"))); + Collections.sort(result); + return Collections.unmodifiableList(result); + } + + JsonObject biomePlacementByPalette(String paletteId, String biomeId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette == null) throw new IllegalArgumentException("Unknown biome palette " + paletteId); + return objectEntry(object(palette, "biomes"), biomeId); + } + + void removeBiomePlacementByPalette(String paletteId, String biomeId) { + JsonObject palette = biomePaletteById(paletteId); + if (palette != null) object(palette, "biomes").remove(biomeId); + } + + Map biomeReplacements(String dimensionId) { + return BiomeReplacementRules.read(root, dimensionId); + } + + void replaceBiome(String dimensionId, String sourceId, String targetId) { + if (!loadedBiome(sourceId) || !loadedBiome(targetId)) { + throw new IllegalArgumentException("Both replacement biomes must be loaded"); + } + BiomeReplacementRules.set(root, dimensionId, sourceId, targetId); + } + + void leaveBiomeOriginal(String dimensionId, String sourceId) { + BiomeReplacementRules.remove(root, dimensionId, sourceId); + } + + void resetBiome(String dimensionId, String biomeId) { + leaveBiomeOriginal(dimensionId, biomeId); + JsonObject defaults = biomeProviderDefaults.biomePalettesCopy(); + Set active = biomeProviderDefaults.activeProviderIds(); + JsonObject palettes = section("biome_palettes"); + for (String id : new ArrayList<>(JsonCopies.keys(palettes))) { + if (BiomeReplacementRules.isOverridePalette(id) || !palettes.get(id).isJsonObject()) continue; + JsonObject palette = palettes.getAsJsonObject(id); + if (!dimensionId.equals(string(palette, "dimension", ""))) continue; + String owner = string(palette, "source_provider", ""); + if (!owner.isEmpty() && !active.contains(owner)) continue; + JsonObject entries = object(palette, "biomes"); + JsonObject defaultPalette = defaults.has(id) && defaults.get(id).isJsonObject() + ? defaults.getAsJsonObject(id) : null; + JsonObject defaultEntries = defaultPalette == null ? new JsonObject() + : object(defaultPalette, "biomes"); + if (defaultEntries.has(biomeId)) { + entries.add(biomeId, JsonCopies.copy(defaultEntries.get(biomeId))); + } else { + entries.remove(biomeId); + } + } + for (Entry entry : defaults.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject definition = entry.getValue().getAsJsonObject(); + if (!dimensionId.equals(string(definition, "dimension", "")) + || !object(definition, "biomes").has(biomeId)) continue; + if (!palettes.has(entry.getKey())) { + palettes.add(entry.getKey(), JsonCopies.copy(definition)); + } else if (palettes.get(entry.getKey()).isJsonObject()) { + object(palettes.getAsJsonObject(entry.getKey()), "biomes").add(biomeId, + JsonCopies.copy(object(definition, "biomes").get(biomeId))); + } + } + } + + void resetBiomePalette(String paletteId) { + JsonObject palettes = section("biome_palettes"); + if (!palettes.has(paletteId) || !palettes.get(paletteId).isJsonObject()) return; + JsonObject current = palettes.getAsJsonObject(paletteId); + String owner = string(current, "source_provider", ""); + if (!owner.isEmpty() && !biomeProviderDefaults.activeProviderIds().contains(owner)) return; + JsonObject defaults = biomeProviderDefaults.biomePalettesCopy(); + if (defaults.has(paletteId)) palettes.add(paletteId, JsonCopies.copy(defaults.get(paletteId))); + else palettes.remove(paletteId); + } + + void resetBiomeDimension(String dimensionId) { + resetBiomeSections(dimensionId); + BiomeReplacementRules.clearDimension(root, dimensionId); + } + + void resetAllBiomeManagement() { + resetBiomeSections(null); + for (String dimension : new ArrayList<>(availableDimensionIds())) { + BiomeReplacementRules.clearDimension(root, dimension); + } + } + + private void resetBiomeSections(String dimensionId) { + resetProviderSection("biome_palettes", biomeProviderDefaults.biomePalettesCopy(), dimensionId); + resetProviderSection("dimension_materials", biomeProviderDefaults.dimensionMaterialsCopy(), dimensionId); + } + + private void resetProviderSection(String sectionName, JsonObject defaults, String dimensionId) { + JsonObject current = section(sectionName); + Set active = biomeProviderDefaults.activeProviderIds(); + for (String id : new ArrayList<>(JsonCopies.keys(current))) { + if (!current.get(id).isJsonObject()) continue; + JsonObject definition = current.getAsJsonObject(id); + if (dimensionId != null && !dimensionId.equals(string(definition, "dimension", ""))) continue; + String owner = string(definition, "source_provider", ""); + if (!owner.isEmpty() && !active.contains(owner)) continue; + if (defaults.has(id)) current.add(id, JsonCopies.copy(defaults.get(id))); + else current.remove(id); + } + for (Entry entry : defaults.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject definition = entry.getValue().getAsJsonObject(); + if (dimensionId == null || dimensionId.equals(string(definition, "dimension", ""))) { + current.add(entry.getKey(), JsonCopies.copy(definition)); + } + } + } + + boolean biomeTargetProviderDeclared(String dimensionId, String biomeId) { + for (BiomeDirectoryModel.BiomeEntry entry : biomeDirectory().entries(dimensionId, true)) { + if (biomeId.equals(entry.id)) return entry.providerDeclared; + } + return false; + } + + private static boolean loadedBiome(String id) { + try { return ForgeRegistries.BIOMES.getValue(new ResourceLocation(id)) != null; } + catch (RuntimeException ignored) { return false; } + } + String biomePaletteId(String dimensionId) { for (Entry entry : section("biome_palettes").entrySet()) { if (entry.getValue().isJsonObject() && dimensionId.equals(string( @@ -489,8 +1473,7 @@ void setMaterialBlock(String dimensionId, String key, String blockId, boolean fl } if (!validResource(blockId)) return; Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation(blockId)); - if (block == null || block == Blocks.AIR - || (fluid && block.defaultBlockState().getFluidState().isEmpty())) return; + if (block == null || block == Blocks.AIR || (fluid && !isFluidBlock(block))) return; JsonObject materials = dimensionMaterials(dimensionId, true); materials.addProperty(key, blockId); materials.addProperty("enabled", true); @@ -507,7 +1490,7 @@ List availableMaterialBlockIds(String search, boolean fluidOnly) { for (Block block : ForgeRegistries.BLOCKS.getValues()) { ResourceLocation id = ForgeRegistries.BLOCKS.getKey(block); if (id == null || block == Blocks.AIR || (!fluidOnly && block.asItem() == Items.AIR) - || (fluidOnly && block.defaultBlockState().getFluidState().isEmpty()) + || (fluidOnly && !isFluidBlock(block)) || (!fluidOnly && block instanceof EntityBlock) || (!query.isEmpty() && !id.toString().contains(query))) continue; result.add(id.toString()); @@ -537,7 +1520,7 @@ List availableFluidBlockIds(String search) { List result = new ArrayList<>(); for (Block block : ForgeRegistries.BLOCKS.getValues()) { ResourceLocation id = ForgeRegistries.BLOCKS.getKey(block); - if (id != null && block != Blocks.AIR && !block.defaultBlockState().getFluidState().isEmpty() + if (id != null && block != Blocks.AIR && isFluidBlock(block) && !configured.contains(id.toString()) && (query.isEmpty() || id.toString().contains(query))) result.add(id.toString()); } @@ -629,7 +1612,7 @@ JsonObject weightMap(String section, String id) { } void addGeome(String id) { - String normalized = id.trim().toLowerCase(Locale.ROOT); + String normalized = id == null ? "" : id.trim(); if (!validGeomeId(normalized) || section("geomes").has(normalized)) { return; } @@ -714,8 +1697,10 @@ List validate() { continue; } JsonObject rock = entry.getValue().getAsJsonObject(); - if (!validBlock(string(rock, "block", entry.getKey()))) { - errors.add("Invalid rock block: " + string(rock, "block", entry.getKey())); + String rockBlock = string(rock, "block", entry.getKey()); + if (!validBlock(rockBlock)) { + if (allowsMissingProviderAsset(rock, rockBlock)) continue; + errors.add("Invalid rock block: " + rockBlock); continue; } if (!bool(rock, "enabled", true) || decimal(rock, "weight", 1.0D) <= 0.0D) { @@ -749,14 +1734,17 @@ List validate() { continue; } JsonObject ore = entry.getValue().getAsJsonObject(); - if (!validBlock(string(ore, "block", entry.getKey()))) { - errors.add("Invalid ore block: " + string(ore, "block", entry.getKey())); + String oreBlock = string(ore, "block", entry.getKey()); + if (!validBlock(oreBlock)) { + if (allowsMissingProviderAsset(ore, oreBlock)) continue; + errors.add("Invalid ore block: " + oreBlock); continue; } if (!bool(ore, "enabled", true)) { continue; } - if (ore.has("deep_output") && !validBlock(string(ore, "deep_output", ""))) { + if (ore.has("deep_output") && !validBlock(string(ore, "deep_output", "")) + && !allowsMissingProviderAsset(ore, string(ore, "deep_output", ""))) { errors.add("Invalid deep ore block: " + string(ore, "deep_output", "")); } JsonObject dimensions = ore.has("dimensions") && ore.get("dimensions").isJsonObject() @@ -806,7 +1794,8 @@ List validate() { errors.add("Invalid ore pattern for " + entry.getKey() + " in " + dimension.getKey()); } if (bool(rule, "enabled", true)) { - boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(ore)) || validIdArray(rule.get("host_tags")); if (rule.has("host_families") && rule.get("host_families").isJsonArray()) { for (JsonElement family : rule.getAsJsonArray("host_families")) { @@ -819,6 +1808,8 @@ List validate() { validateGeomeWeights(errors, entry.getKey(), rule.get("geomes"), geomes); } } + validateOreMaterialGroups(errors); + validateOreSourcePolicies(errors); validateRuleGeomes(errors, section("biomes"), geomes, "biome"); validateRuleGeomes(errors, section("biome_dictionary"), geomes, "biome type"); for (Entry entry : section("terrain_dimensions").entrySet()) { @@ -828,7 +1819,8 @@ List validate() { } JsonObject dimension = entry.getValue().getAsJsonObject(); if (!bool(dimension, "enabled", true)) continue; - boolean hosts = validBlockArray(dimension.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(dimension.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(dimension)) || validIdArray(dimension.get("host_tags")); if (!hosts) errors.add("Enabled terrain dimension has no valid hosts: " + entry.getKey()); if (dimension.has("biome_ids")) { @@ -845,7 +1837,9 @@ List validate() { continue; } JsonObject deposit = entry.getValue().getAsJsonObject(); - if (!validFluidBlock(string(deposit, "block", ""))) { + String depositBlock = string(deposit, "block", ""); + if (!validFluidBlock(depositBlock)) { + if (allowsMissingProviderAsset(deposit, depositBlock)) continue; errors.add("Fluid deposit output is not a fluid block: " + entry.getKey()); continue; } @@ -879,7 +1873,8 @@ List validate() { errors.add("Invalid fluid placement values for " + entry.getKey() + " in " + dimension.getKey()); } - boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey()) + boolean hosts = validBlockArray(rule.get("host_blocks"), errors, entry.getKey(), + orphanedProviderDefinition(deposit)) || validIdArray(rule.get("host_tags")); if (rule.has("host_families") && rule.get("host_families").isJsonArray()) { for (JsonElement family : rule.getAsJsonArray("host_families")) { @@ -897,6 +1892,8 @@ List validate() { continue; } JsonObject palette = entry.getValue().getAsJsonObject(); + boolean replacementPalette = BiomeReplacementRules.isOverridePalette(entry.getKey()); + boolean orphanedProvider = orphanedProviderDefinition(palette); if (!validResource(string(palette, "dimension", ""))) { errors.add("Invalid biome palette dimension: " + entry.getKey()); } @@ -906,7 +1903,9 @@ List validate() { if (biomes.size() == 0) errors.add("Enabled biome palette has no biomes: " + entry.getKey()); for (Entry biome : biomes.entrySet()) { if (!validResource(biome.getKey()) - || ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null + || (!replacementPalette + && ForgeRegistries.BIOMES.getValue(new ResourceLocation(biome.getKey())) == null + && !orphanedProvider) || !biome.getValue().isJsonObject()) { errors.add("Invalid biome placement: " + biome.getKey()); continue; @@ -927,16 +1926,19 @@ > decimal(placement, "max_downfall", 1.0D)) { continue; } JsonObject materials = entry.getValue().getAsJsonObject(); + boolean orphanedProvider = orphanedProviderDefinition(materials); if (!validResource(string(materials, "dimension", ""))) { errors.add("Invalid dimension materials target: " + entry.getKey()); } for (String key : new String[] { "default_fluid", "deep_aquifer_fluid" }) { - if (materials.has(key) && !validFluidBlock(string(materials, key, ""))) { + if (materials.has(key) && !validFluidBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid fluid material in " + entry.getKey()); } } for (String key : new String[] { "snow_block", "ice_block" }) { - if (materials.has(key) && !validBlock(string(materials, key, ""))) { + if (materials.has(key) && !validBlock(string(materials, key, "")) + && !(orphanedProvider && validResource(string(materials, key, "")))) { errors.add("Invalid weather material in " + entry.getKey()); } } @@ -944,6 +1946,62 @@ > decimal(placement, "max_downfall", 1.0D)) { return errors; } + private void validateOreMaterialGroups(List errors) { + Set aliases = new HashSet<>(); + for (Entry entry : section("ore_material_groups").entrySet()) { + if (!validResource(entry.getKey()) || !entry.getValue().isJsonObject()) { + errors.add("Invalid ore material group: " + entry.getKey()); + continue; + } + JsonObject group = entry.getValue().getAsJsonObject(); + if (string(group, "display_name", "").trim().isEmpty()) { + errors.add("Ore material group has no display name: " + entry.getKey()); + } + for (String alias : stringList(group, "block_tag_entries")) { + if (!validBlockTagName(alias)) { + errors.add("Invalid block tag in " + entry.getKey() + ": " + alias); + } else if (!aliases.add(alias)) { + errors.add("Block tag belongs to more than one material group: " + alias); + } + } + } + } + + private void validateOreSourcePolicies(List errors) { + for (Entry entry : section("ore_source_policies").entrySet()) { + if (!entry.getValue().isJsonObject()) { + errors.add("Invalid ore source policy: " + entry.getKey()); + continue; + } + JsonObject policy = entry.getValue().getAsJsonObject(); + if (bool(policy, "dormant", false) + || !"consolidated".equals(string(policy, "mode", "keep_separate"))) continue; + JsonObject outputs = policy.has("outputs") && policy.get("outputs").isJsonObject() + ? policy.getAsJsonObject("outputs") : new JsonObject(); + JsonObject placements = policy.has("placement_sources") + && policy.get("placement_sources").isJsonObject() + ? policy.getAsJsonObject("placement_sources") : new JsonObject(); + String outputMode = outputMode(policy); + if (outputs.entrySet().isEmpty()) { + errors.add("Consolidated ore source policy has no selected outputs: " + entry.getKey()); + } + if (placements.entrySet().isEmpty()) { + errors.add("Consolidated ore source policy has no placement source: " + entry.getKey()); + } + if ("single".equals(outputMode) && outputs.entrySet().size() != 1) { + errors.add("Single-output ore source policy must select exactly one output: " + entry.getKey()); + } + for (Entry output : outputs.entrySet()) { + try { + double value = output.getValue().getAsDouble(); + if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException(); + } catch (RuntimeException invalid) { + errors.add("Ore source output weight must be positive: " + entry.getKey()); + } + } + } + } + private static void validateOrePattern(JsonObject rule) { if (!OreSpawnPatterns.isBuiltIn(rule)) { OreSpawnPatterns.decode(rule); @@ -1002,6 +2060,11 @@ private static boolean validIdArray(JsonElement element) { } private static boolean validBlockArray(JsonElement element, List errors, String oreId) { + return validBlockArray(element, errors, oreId, false); + } + + private static boolean validBlockArray(JsonElement element, List errors, String oreId, + boolean allowMissingProviderBlocks) { if (element == null || !element.isJsonArray() || element.getAsJsonArray().size() == 0) return false; boolean found = false; for (JsonElement value : element.getAsJsonArray()) { @@ -1013,6 +2076,10 @@ private static boolean validBlockArray(JsonElement element, List errors, continue; } if (!validBlock(id)) { + if (allowMissingProviderBlocks && validResource(id)) { + found = true; + continue; + } errors.add("Unknown host block '" + id + "' for " + oreId); continue; } @@ -1021,6 +2088,15 @@ private static boolean validBlockArray(JsonElement element, List errors, return found; } + private static boolean allowsMissingProviderAsset(JsonObject definition, String id) { + return orphanedProviderDefinition(definition) && validResource(id); + } + + private static boolean orphanedProviderDefinition(JsonObject definition) { + return bool(definition, "orphaned_provider", false) + && !string(definition, "source_provider", "").isEmpty(); + } + static JsonObject defaultOreDimension() { JsonObject dimension = new JsonObject(); dimension.addProperty("enabled", true); @@ -1158,10 +2234,15 @@ private static String safePath(String registryId) { private static boolean validFluidBlock(String id) { if (!validResource(id)) return false; Block block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation(id)); - return block != null && block != Blocks.AIR && !block.defaultBlockState().getFluidState().isEmpty(); + return block != null && block != Blocks.AIR && isFluidBlock(block); + } + + static boolean isFluidBlock(Block block) { + return FluidBlocks.isFluidBlock(block); } private static boolean validResource(String id) { + if (id == null || !id.matches("[a-z0-9_.-]+:[a-z0-9_./-]+")) return false; try { new ResourceLocation(id); return true; @@ -1170,6 +2251,57 @@ private static boolean validResource(String id) { } } + private static boolean validBlockTagName(String name) { + if (name == null || name.length() > 128 || !validResource(name)) return false; + String path = new ResourceLocation(name).getPath(); + return path.startsWith("ores/") && path.length() > "ores/".length(); + } + + private static String oreTagMaterial(String tag) { + return new ResourceLocation(tag).getPath().substring("ores/".length()); + } + + private static JsonObject sectionCopy(JsonObject parent, String key) { + return parent.has(key) && parent.get(key).isJsonObject() + ? JsonCopies.copy(parent.getAsJsonObject(key)) : new JsonObject(); + } + + private static List stringList(JsonObject parent, String key) { + List result = new ArrayList<>(); + if (parent.has(key) && parent.get(key).isJsonArray()) { + for (JsonElement element : parent.getAsJsonArray(key)) { + try { + String value = element.getAsString(); + if (!result.contains(value)) result.add(value); + } catch (RuntimeException ignored) { } + } + } + return result; + } + + private static JsonArray strings(Iterable values) { + JsonArray result = new JsonArray(); + for (String value : values) result.add(new JsonPrimitive(value)); + return result; + } + + private static String displayName(String id) { + int split = id.indexOf(':'); + String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static String outputMode(JsonObject policy) { + String configured = string(policy, "output_mode", ""); + if ("balanced".equals(configured) || "single".equals(configured) + || "custom".equals(configured)) return configured; + Map outputs = decimalMap(policy, "outputs"); + if (outputs.size() <= 1) return "single"; + double first = outputs.values().iterator().next(); + for (double value : outputs.values()) if (Double.compare(first, value) != 0) return "custom"; + return "balanced"; + } + private static boolean validGeomeId(String id) { if (id == null || id.isEmpty()) return false; if (id.indexOf(':') < 0) return id.matches("[a-z0-9_.-]+"); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java index ef9ee0da..1dfb1a46 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/GeologyMaterialsScreen.java @@ -106,9 +106,17 @@ protected void init() { previous.active = page > 0; next.active = page + 1 < pageCount; - int addWidth = Math.min(150, contentWidth - 105); + int addWidth = tab == MaterialTab.ORES ? Math.max(50, (contentWidth - 105) / 2) + : Math.min(150, contentWidth - 105); + int addX = contentLeft + contentWidth - addWidth; + Button sources = addRenderableWidget(OreSpawnScreenLayout.button(this, font, + addX - addWidth - 5, controlsY, addWidth, 20, + new TranslatableComponent("button.orespawn.ore_sources"), + button -> minecraft.setScreen(new OreSourceListScreen(this, session)))); + sources.visible = tab == MaterialTab.ORES; + OreSpawnScreenLayout.explain(this, sources, "tooltip.orespawn.ore_sources"); Button add = addRenderableWidget(OreSpawnScreenLayout.button(this, font, - contentLeft + contentWidth - addWidth, controlsY, addWidth, 20, + addX, controlsY, addWidth, 20, new TranslatableComponent("button.orespawn.add_block"), button -> openBlockPicker())); add.visible = tab != MaterialTab.UNASSIGNED; OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.material.add_block"); diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java new file mode 100644 index 00000000..efaa5e6c --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceGroupSettingsScreen.java @@ -0,0 +1,380 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeSet; + + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; + +/** Infrequent group metadata and placement ownership controls opened by the group cog. */ +final class OreSourceGroupSettingsScreen extends OreSpawnScreen { + private static final int MARGIN = 10; + private static final int NAME_TOP = 24; + private static final int ALIAS_LABEL_TOP = 48; + private static final int ALIAS_TOP = 58; + private final Screen parent; + private final GeologyEditorSession session; + private final String groupKey; + private String pendingAliasMove; + private String pendingAliasValue; + private boolean pendingDissolve; + private String error; + private int aliasScroll; + private int placementScroll; + private int contentX; + private int contentWidth; + private int aliasListHeight; + private int addAliasY; + private int placementLabelY; + private int placementListY; + private TextFieldWidget nameField; + private TextFieldWidget aliasField; + private CompactScrollList aliasList; + private CompactScrollList placementList; + + OreSourceGroupSettingsScreen(Screen parent, GeologyEditorSession session, String groupKey) { + super(new TranslatableComponent("screen.orespawn.ore_source.group_settings")); + this.parent = parent; + this.session = session; + this.groupKey = groupKey; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + OreSourceGroup group = selectedGroup(); + if (group == null) { + minecraft.setScreen(parent); + return; + } + contentWidth = Math.max(240, Math.min(600, width - (MARGIN * 2))); + contentX = (width - contentWidth) / 2; + boolean hasGroupAction = group.curated || group.canDeleteEmpty() || group.canDissolve(); + String groupActionKey = group.curated ? "button.orespawn.reset" + : group.canDissolve() ? (pendingDissolve + ? "button.orespawn.ore_source.confirm_dissolve" + : "button.orespawn.ore_source.dissolve_group") + : "button.orespawn.ore_source.delete_group"; + int actionWidth = hasGroupAction + ? Math.min(120, Math.max(55, font.width(I18n.get(groupActionKey)) + 10)) : 0; + int nameLabelWidth = font.width( + I18n.get("option.orespawn.ore_source.group_name")) + 8; + + nameField = addButton(new TextFieldWidget(font, contentX + nameLabelWidth, NAME_TOP, + contentWidth - nameLabelWidth - actionWidth - (hasGroupAction ? 5 : 0), 20, + new TranslatableComponent("option.orespawn.ore_source.group_name"))); + nameField.setMaxLength(64); + nameField.setValue(group.displayName); + if (hasGroupAction) { + Button groupAction = addButton(OreSpawnScreenLayout.button(this, font, + contentX + contentWidth - actionWidth, NAME_TOP, actionWidth, 20, + new TranslatableComponent(groupActionKey), button -> handleGroupAction(group))); + OreSpawnScreenLayout.explain(this, groupAction, group.curated + ? "tooltip.orespawn.ore_source.reset_group" + : group.canDissolve() ? "tooltip.orespawn.ore_source.dissolve_group" + : "tooltip.orespawn.ore_source.delete_group"); + } + + aliasListHeight = aliasListHeight(height); + final List aliases = group.blockTagEntries; + aliasList = addButton(new CompactScrollList(this, contentX, ALIAS_TOP, + contentWidth, aliasListHeight) { + @Override protected int size() { return aliases.size(); } + @Override protected String rowText(int index) { return aliases.get(index); } + @Override protected List rowTooltip(int index) { + return java.util.Collections.singletonList(aliases.get(index)); + } + @Override protected int rowActionWidth(int index) { return 18; } + @Override protected void drawRowAction(net.minecraft.client.Minecraft minecraft, + int index, int x, int y, int actionWidth, int rowHeight, boolean hovered) { + if (hovered) fill(x, y, x + actionWidth, y + rowHeight, 0x805F5F5F); + drawCenteredString(minecraft.font, "-", x + (actionWidth / 2), y + 3, 0xFFFFFF); + } + @Override protected void onRowActionPressed(int index) { + syncName(); + session.removeOreMaterialAlias(group.material, aliases.get(index)); + pendingAliasMove = null; + pendingAliasValue = null; + pendingDissolve = false; + error = null; + captureScroll(); + rebuild(); + } + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList( + I18n.get("tooltip.orespawn.ore_source.remove_alias", aliases.get(index))); + } + }); + aliasList.setFirstIndex(aliasScroll); + + addAliasY = ALIAS_TOP + aliasListHeight + 4; + aliasField = addButton(new TextFieldWidget(font, contentX, addAliasY, + contentWidth - 46, 20, + new TranslatableComponent("option.orespawn.ore_source.alias"))); + aliasField.setMaxLength(128); + if (pendingAliasMove != null) { + int split = pendingAliasMove.indexOf('|'); + aliasField.setValue(split < 0 ? pendingAliasMove : pendingAliasMove.substring(0, split)); + } else if (pendingAliasValue != null) { + aliasField.setValue(pendingAliasValue); + } + Button add = addButton(new Button(contentX + contentWidth - 41, addAliasY, 41, 20, + new TextComponent(pendingAliasMove == null ? "+" + : I18n.get("button.orespawn.ore_source.move")), button -> addAlias(group))); + OreSpawnScreenLayout.explain(this, add, "tooltip.orespawn.ore_source.alias"); + + final List channels = placementChannels(group); + placementLabelY = addAliasY + 27; + int helpX = contentX + font.width( + I18n.get("label.orespawn.ore_source.placement_rules")) + 8; + addButton(new Button(helpX, placementLabelY - 4, 16, 16, + new TextComponent("?"), button -> { }, (button, mouseX, mouseY) -> { + List lines = new ArrayList<>(); + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_rules")); + lines.add(placementStatus(group, channels)); + lines.add(I18n.get("tooltip.orespawn.ore_source.external_excluded")); + renderStringTooltip(lines, mouseX, mouseY); + })); + placementListY = placementLabelY + 14; + placementList = addButton(new CompactScrollList(this, contentX, placementListY, + contentWidth, Math.max(16, height - 32 - placementListY)) { + @Override protected int size() { return channels.size(); } + @Override protected String rowText(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + String selected = group.placements.get(channel); + int choice = OreSourceListScreen.indexOf(choices, selected); + String source = choice < 0 + ? I18n.get("label.orespawn.ore_source.missing") + : OreSourceListScreen.candidateLabel(choices.get(choice)); + return channelLabel(channel) + (placementSelectable(group, channel) ? " > " : " = ") + + placementSourceLabel(source, choices.size()); + } + @Override protected boolean rowEnabled(int index) { + String channel = channels.get(index); + return placementSelectable(group, channel); + } + @Override protected int rowColor(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + return choices.isEmpty() ? 0x777777 + : placementSelectable(group, channel) ? 0xFFFFFF : 0xC0C0C0; + } + @Override protected void onRowPressed(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + if (choices.size() <= 1) return; + int selected = OreSourceListScreen.indexOf(choices, group.placements.get(channel)); + OreSourceCandidate next = choices.get((selected + 1 + choices.size()) % choices.size()); + syncName(); + session.setOreSourcePlacement(group.key, channel, next.sourceId); + captureScroll(); + rebuild(); + } + @Override protected List rowTooltip(int index) { + String channel = channels.get(index); + List choices = OreSourceListScreen.placementCandidates(group, channel); + List lines = new ArrayList<>(); + lines.add(channelLabel(channel) + " (" + channel + ")"); + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_rules")); + if ("keep_separate".equals(group.mode)) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_requires_consolidated")); + } else if (choices.isEmpty()) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_missing")); + } else if (choices.size() == 1) { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_readonly")); + } else { + lines.add(I18n.get("tooltip.orespawn.ore_source.placement_select")); + } + lines.add(I18n.get("tooltip.orespawn.ore_source.external_excluded")); + return lines; + } + }); + placementList.setFirstIndex(placementScroll); + + int doneWidth = doneButtonWidth(contentWidth); + addButton(new Button(contentX + contentWidth - doneWidth, height - 28, doneWidth, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void handleGroupAction(OreSourceGroup group) { + syncName(); + pendingAliasMove = null; + pendingAliasValue = null; + if (group.curated) { + session.resetOreMaterialGroup(group.material); + pendingDissolve = false; + error = null; + rebuild(); + return; + } + if (group.canDeleteEmpty()) { + if (session.deleteEmptyOreMaterialGroup(group.material)) minecraft.setScreen(parent); + return; + } + if (!group.canDissolve()) return; + if (!pendingDissolve) { + pendingDissolve = true; + error = I18n.get("error.orespawn.ore_source.confirm_dissolve"); + rebuild(); + return; + } + if (session.dissolveOreMaterialGroup(group.material)) minecraft.setScreen(parent); + } + + private void addAlias(OreSourceGroup group) { + syncName(); + String alias = aliasField == null ? "" : aliasField.getValue().trim(); + String owner = session.blockTagOwner(alias); + String move = owner == null || owner.equals(group.material) ? null : alias + '|' + owner; + if (move != null && session.vanillaOreManagementRequiredForAliasMove(group.material, alias)) { + pendingDissolve = false; + pendingAliasMove = null; + pendingAliasValue = alias; + error = I18n.get("error.orespawn.ore_source.manage_vanilla_first"); + rebuild(); + return; + } + if (move != null && !move.equals(pendingAliasMove)) { + pendingDissolve = false; + pendingAliasMove = move; + pendingAliasValue = alias; + error = I18n.get("error.orespawn.ore_source.alias_owned", + OreSourceListScreen.display(owner)); + rebuild(); + return; + } + if (!session.addOreMaterialAlias(group.material, alias, move != null)) { + error = I18n.get("error.orespawn.ore_source.alias"); + return; + } + pendingAliasMove = null; + pendingAliasValue = null; + pendingDissolve = false; + error = null; + rebuild(); + } + + private OreSourceGroup selectedGroup() { + for (OreSourceGroup group : session.oreSourceGroups()) { + if (groupKey.equals(group.key)) return group; + } + return null; + } + + private void syncName() { + OreSourceGroup group = selectedGroup(); + if (group != null && nameField != null) { + session.renameOreMaterialGroup(group.material, nameField.getValue()); + } + } + + private void captureScroll() { + if (aliasList != null) aliasScroll = aliasList.firstIndex(); + if (placementList != null) placementScroll = placementList.firstIndex(); + } + + private void rebuild() { + clearWidgets(); + init(); + } + + static int aliasListHeight(int height) { + return height >= 260 ? 80 : 64; + } + + static int doneButtonWidth(int contentWidth) { + return Math.min(120, Math.max(80, contentWidth / 4)); + } + + static int validationMessageWidth(int contentWidth) { + return Math.max(40, contentWidth - doneButtonWidth(contentWidth) - 8); + } + + static List placementChannels(OreSourceGroup group) { + Set channels = new TreeSet<>(group.channels()); + channels.addAll(group.placements.keySet()); + return new ArrayList<>(channels); + } + + static String channelLabel(String channel) { + return "orespawn:standard".equals(channel) + ? I18n.get("label.orespawn.ore_source.standard_veins") + : OreSourceListScreen.display(channel); + } + + static String placementSourceLabel(String source, int choices) { + return choices > 1 + ? I18n.get("label.orespawn.ore_source.placement_choice_count", source, choices) + : source; + } + + static boolean placementSelectable(OreSourceGroup group, String channel) { + return !"keep_separate".equals(group.mode) + && OreSourceListScreen.placementCandidates(group, channel).size() > 1; + } + + static String placementStatus(OreSourceGroup group, List channels) { + if (channels.isEmpty()) return I18n.get("label.orespawn.ore_source.no_placement_rules"); + if ("keep_separate".equals(group.mode)) { + return I18n.get("tooltip.orespawn.ore_source.placement_requires_consolidated"); + } + boolean selectable = false; + for (String channel : channels) { + int choices = OreSourceListScreen.placementCandidates(group, channel).size(); + if (choices == 0) return I18n.get("tooltip.orespawn.ore_source.placement_missing"); + selectable |= choices > 1; + } + return I18n.get(selectable ? "tooltip.orespawn.ore_source.placement_select" + : "tooltip.orespawn.ore_source.placement_readonly"); + } + + @Override + public void onClose() { + syncName(); + captureScroll(); + minecraft.setScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, new TranslatableComponent("option.orespawn.ore_source.group_name"), + contentX + 2, NAME_TOP + 6, 0xA0A0A0); + drawString(font, new TranslatableComponent("label.orespawn.ore_source.aliases"), + contentX + 2, ALIAS_LABEL_TOP, 0xA0A0A0); + drawString(font, new TranslatableComponent("label.orespawn.ore_source.placement_rules"), + contentX + 2, placementLabelY, 0xFFFFFF); + OreSourceGroup group = selectedGroup(); + if (group != null && group.blockTagEntries.isEmpty()) { + drawCenteredString(font, new TranslatableComponent("label.orespawn.ore_source.no_aliases"), + contentX + (contentWidth / 2), ALIAS_TOP + 4, 0x777777); + } + super.render(mouseX, mouseY, partialTick); + if (error != null) { + int messageWidth = validationMessageWidth(contentWidth); + TextComponent message = new TextComponent(error); + drawString(font, OreSpawnScreenLayout.fit(font, + message, messageWidth), contentX + 2, height - 22, 0xFF5555); + } + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + if (error != null) { + int messageWidth = validationMessageWidth(contentWidth); + if (mouseX >= contentX && mouseX < contentX + messageWidth + && mouseY >= height - 26 && mouseY < height - 8) { + renderStringTooltip(java.util.Collections.singletonList(error), mouseX, mouseY); + } + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java new file mode 100644 index 00000000..db23d575 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSourceListScreen.java @@ -0,0 +1,500 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + + +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceCandidate; +import zone.moddev.mc.orespawn.client.GeologyEditorSession.OreSourceGroup; + +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.Screen; +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.blaze3d.systems.RenderSystem; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; +import net.minecraft.util.FormattedCharSequence; + +/** Two-pane material-group editor backed by compact independently scrolling lists. */ +final class OreSourceListScreen extends OreSpawnScreen { + private static final int HORIZONTAL_MARGIN = 10; + private static final int PANE_GAP = 6; + private static final int CONTENT_TOP = 24; + private final Screen parent; + private final GeologyEditorSession session; + private final Map weights = new LinkedHashMap<>(); + private String selectedKey; + private String error; + private boolean showAll; + private boolean resetArmed; + private int groupScroll; + private int outputScroll; + private int leftPaneX; + private int leftPaneWidth; + private int rightPaneX; + private int rightPaneWidth; + private int paneBottom; + private CompactScrollList groupList; + private CompactScrollList outputList; + private List visibleCandidates = java.util.Collections.emptyList(); + + OreSourceListScreen(Screen parent, GeologyEditorSession session) { + super(new TranslatableComponent("screen.orespawn.ore_sources")); + this.parent = parent; + this.session = session; + } + + @Override + protected void init() { + OreSpawnScreenLayout.beginHelp(this); + weights.clear(); + List groups = orderedGroups(); + if (selected(groups) == null && !groups.isEmpty()) selectedKey = groups.get(0).key; + + int contentWidth = contentWidth(width); + leftPaneX = (width - contentWidth) / 2; + leftPaneWidth = leftPaneWidth(width); + rightPaneX = leftPaneX + leftPaneWidth + PANE_GAP; + rightPaneWidth = contentWidth - leftPaneWidth - PANE_GAP; + paneBottom = height - 32; + + initGroups(groups); + initOutputs(selected(groups)); + String resetLabel = I18n.get(resetArmed + ? "button.orespawn.ore_source.confirm_reset_all" + : "button.orespawn.ore_source.reset_all"); + int resetWidth = Math.min(leftPaneWidth, Math.max(54, font.width(resetLabel) + 10)); + Button reset = addButton(OreSpawnScreenLayout.button(this, font, + leftPaneX, height - 28, resetWidth, 20, + new TextComponent(resetLabel), button -> resetAll())); + OreSpawnScreenLayout.explain(this, reset, "tooltip.orespawn.ore_source.reset_all"); + int doneWidth = Math.min(150, rightPaneWidth); + addButton(new Button(rightPaneX + rightPaneWidth - doneWidth, height - 28, doneWidth, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void resetAll() { + if (!resetArmed) { + resetArmed = true; + error = null; + rebuild(); + return; + } + session.resetOreSourcesToDefaults(); + selectedKey = null; + groupScroll = 0; + outputScroll = 0; + showAll = false; + resetArmed = false; + error = null; + rebuild(); + } + + private void initGroups(final List groups) { + int addX = leftPaneX + font.width(I18n.get("label.orespawn.ore_source.groups")) + 8; + Button add = addButton(new Button(addX, 4, 16, 16, + new TextComponent("+"), button -> { + if (!syncWeights()) return; + captureScroll(); + selectedKey = session.addOreMaterialGroup(); + outputScroll = 0; + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, add, "button.orespawn.ore_source.add_group"); + int showX = addX + 20; + String showLabel = I18n.get(showAll + ? "button.orespawn.ore_source.hide_single" + : "button.orespawn.show_all"); + int showWidth = compactFilterWidth(leftPaneX + leftPaneWidth - showX, + font.width(showLabel)); + Button show = addButton(OreSpawnScreenLayout.button(this, font, + showX, 4, showWidth, 16, + new TextComponent(showLabel), button -> { + if (!syncWeights()) return; + captureScroll(); + showAll = !showAll; + groupScroll = 0; + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, show, "tooltip.orespawn.ore_source.group_colours"); + + groupList = addButton(new CompactScrollList(this, leftPaneX, CONTENT_TOP, + leftPaneWidth, paneBottom - CONTENT_TOP) { + @Override protected int size() { return groups.size(); } + + @Override protected String rowText(int index) { + OreSourceGroup group = groups.get(index); + return group.displayName + " - " + display(group.domain); + } + + @Override protected int rowColor(int index) { + return groupRowColor(groups.get(index)); + } + + @Override protected boolean rowSelected(int index) { + return groups.get(index).key.equals(selectedKey); + } + + @Override protected void onRowPressed(int index) { + if (!syncWeights()) return; + captureScroll(); + selectedKey = groups.get(index).key; + outputScroll = 0; + error = null; + rebuild(); + } + + @Override protected List rowTooltip(int index) { + return groupTooltip(groups.get(index)); + } + + @Override protected int rowActionWidth(int index) { return 16; } + + @Override protected void drawRowAction(Minecraft minecraft, int index, + int x, int y, int actionWidth, int rowHeight, boolean hovered) { + drawCompactCog(currentPoseStack(), minecraft, x, y, actionWidth, rowHeight, hovered); + } + + @Override protected void onRowActionPressed(int index) { + if (!syncWeights()) return; + captureScroll(); + selectedKey = groups.get(index).key; + minecraft.setScreen(new OreSourceGroupSettingsScreen( + OreSourceListScreen.this, session, selectedKey)); + } + + @Override protected List rowActionTooltip(int index) { + return java.util.Collections.singletonList( + I18n.get("tooltip.orespawn.ore_source.group_settings")); + } + }); + groupList.setFirstIndex(groupScroll); + int selected = groupIndexOf(groups, selectedKey); + if (selected >= 0) groupList.ensureVisible(selected); + } + + private void initOutputs(final OreSourceGroup group) { + if (group == null) { + visibleCandidates = java.util.Collections.emptyList(); + return; + } + String mode = "keep_separate".equals(group.mode) ? "keep_original" : group.outputMode; + String acceptLabel = I18n.get("button.orespawn.ore_source.accept"); + int acceptWidth = group.needsReview() + ? Math.min(Math.max(42, font.width(acceptLabel) + 10), rightPaneWidth / 3) + : 0; + int modeWidth = rightPaneWidth - (acceptWidth == 0 ? 0 : acceptWidth + 4); + Button modeButton = addButton(OreSpawnScreenLayout.button(this, font, + rightPaneX, CONTENT_TOP, modeWidth, 20, + new TranslatableComponent("option.orespawn.ore_source.output_mode", + new TranslatableComponent("mode.orespawn.ore_source." + mode).getString()), + button -> { + if (!syncWeights()) return; + cycleMode(group); + outputScroll = 0; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, modeButton, "tooltip.orespawn.ore_source.output_mode"); + if (acceptWidth > 0) { + Button accept = addButton(OreSpawnScreenLayout.button(this, font, + rightPaneX + modeWidth + 4, CONTENT_TOP, acceptWidth, 20, + new TextComponent(acceptLabel), button -> { + if (!syncWeights()) return; + captureScroll(); + session.acceptOreSourcePolicy(group.key); + error = null; + rebuild(); + })); + OreSpawnScreenLayout.explain(this, accept, "tooltip.orespawn.ore_source.accept"); + } + + visibleCandidates = group.outputCandidates(); + final List candidates = visibleCandidates; + outputList = addButton(new CompactScrollList(this, rightPaneX, CONTENT_TOP + 24, + rightPaneWidth, paneBottom - CONTENT_TOP - 24) { + @Override protected int size() { return candidates.size(); } + + @Override protected String rowText(int index) { + OreSourceCandidate candidate = candidates.get(index); + boolean selected = group.outputs.containsKey(candidate.sourceId); + boolean single = "single".equals(group.outputMode) + && !"keep_separate".equals(group.mode); + String marker = single ? (selected ? "(*) " : "( ) ") + : (selected ? "[x] " : "[ ] "); + return marker + candidateLabel(candidate); + } + + @Override protected boolean rowEnabled(int index) { + OreSourceCandidate candidate = candidates.get(index); + return candidate.loaded && !candidate.enrichment + && !"keep_separate".equals(group.mode); + } + + @Override protected boolean rowSelected(int index) { + return group.outputs.containsKey(candidates.get(index).sourceId); + } + + @Override protected void onRowPressed(int index) { + if (!syncWeights()) return; + OreSourceCandidate candidate = candidates.get(index); + boolean single = "single".equals(group.outputMode) + && !"keep_separate".equals(group.mode); + session.setOreSourceOutput(group.key, candidate.sourceId, + single || !group.outputs.containsKey(candidate.sourceId), + group.outputs.containsKey(candidate.sourceId) + ? group.outputs.get(candidate.sourceId) : 1.0D); + captureScroll(); + rebuild(); + } + + @Override protected List rowTooltip(int index) { + return candidateTooltip(candidates.get(index)); + } + + @Override protected int rowActionWidth(int index) { + OreSourceCandidate candidate = candidates.get(index); + return "custom".equals(group.outputMode) + && !"keep_separate".equals(group.mode) + && group.outputs.containsKey(candidate.sourceId) ? 48 : 0; + } + }); + outputList.setFirstIndex(outputScroll); + + if ("custom".equals(group.outputMode) && !"keep_separate".equals(group.mode)) { + for (OreSourceCandidate candidate : candidates) { + if (!group.outputs.containsKey(candidate.sourceId)) continue; + TextFieldWidget weight = addButton(new TextFieldWidget(font, 0, 0, 42, 14, + new TranslatableComponent("option.orespawn.ore_source.weight"))); + weight.setMaxLength(12); + weight.setValue(formatWeight(group.outputs.get(candidate.sourceId))); + weight.active = candidate.loaded && !candidate.enrichment; + weight.visible = false; + weights.put(candidate.sourceId, weight); + } + } + } + + private void updateWeightBounds() { + if (outputList == null) return; + for (int index = 0; index < visibleCandidates.size(); index++) { + OreSourceCandidate candidate = visibleCandidates.get(index); + TextFieldWidget weight = weights.get(candidate.sourceId); + if (weight == null) continue; + int y = outputList.rowY(index); + weight.visible = y >= 0; + if (weight.visible) weight.setBounds(outputList.contentRight() - 45, y + 1, 42, 14); + } + } + + private void cycleMode(OreSourceGroup group) { + resetArmed = false; + if (!group.hasManagedPlacementSource()) { + if (!"keep_separate".equals(group.mode)) session.restoreOreSourceOriginalMode(group.key); + error = I18n.get("label.orespawn.ore_source.no_placement_rules"); + return; + } + error = null; + if ("keep_separate".equals(group.mode)) session.setOreSourceOutputMode(group.key, "balanced"); + else if ("balanced".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "single"); + else if ("single".equals(group.outputMode)) session.setOreSourceOutputMode(group.key, "custom"); + else session.restoreOreSourceOriginalMode(group.key); + } + + private boolean syncWeights() { + OreSourceGroup group = selected(orderedGroups()); + if (group == null || !"custom".equals(group.outputMode)) return true; + for (Map.Entry entry : weights.entrySet()) { + if (!group.outputs.containsKey(entry.getKey())) continue; + try { + double value = Double.parseDouble(entry.getValue().getValue().trim()); + if (!Double.isFinite(value) || value <= 0.0D) throw new NumberFormatException(); + session.setOreSourceOutput(group.key, entry.getKey(), true, value); + } catch (NumberFormatException invalid) { + error = I18n.get("error.orespawn.ore_source.weight"); + return false; + } + } + error = null; + return true; + } + + private void captureScroll() { + resetArmed = false; + if (groupList != null) groupScroll = groupList.firstIndex(); + if (outputList != null) outputScroll = outputList.firstIndex(); + } + + private List orderedGroups() { + List result = new ArrayList<>(); + for (OreSourceGroup group : session.oreSourceGroups()) { + if (showAll || showByDefault(group)) result.add(group); + } + result.sort((left, right) -> { + int leftRank = groupStatusRank(left); + int rightRank = groupStatusRank(right); + if (leftRank != rightRank) return Integer.compare(leftRank, rightRank); + int name = left.displayName.compareToIgnoreCase(right.displayName); + if (name != 0) return name; + int material = left.material.compareTo(right.material); + return material != 0 ? material : left.domain.compareTo(right.domain); + }); + return result; + } + + static boolean showByDefault(OreSourceGroup group) { + return !group.isRoutineSingleSource(); + } + + static int groupRowColor(OreSourceGroup group) { + return group.needsAttention() ? 0xFF5555 + : group.isRoutineSingleSource() ? 0x55FF55 : 0xFFFF55; + } + + private static int groupStatusRank(OreSourceGroup group) { + return group.needsAttention() ? 0 : group.isRoutineSingleSource() ? 2 : 1; + } + + private OreSourceGroup selected(List groups) { + if (selectedKey == null) return null; + for (OreSourceGroup group : groups) if (selectedKey.equals(group.key)) return group; + return null; + } + + static List placementCandidates(OreSourceGroup group, String channel) { + List result = new ArrayList<>(); + for (OreSourceCandidate candidate : group.candidates) { + if (candidate.loaded && candidate.active && !candidate.external && !candidate.enrichment + && channel.equals(candidate.channel)) result.add(candidate); + } + return result; + } + + static int indexOf(List candidates, String sourceId) { + for (int index = 0; index < candidates.size(); index++) { + if (candidates.get(index).sourceId.equals(sourceId)) return index; + } + return -1; + } + + static int groupIndexOf(List groups, String key) { + for (int index = 0; index < groups.size(); index++) { + if (groups.get(index).key.equals(key)) return index; + } + return -1; + } + + static String candidateLabel(OreSourceCandidate candidate) { + String owner = candidate.ownerName.isEmpty() ? candidate.owner : candidate.ownerName; + String state = candidate.external ? I18n.get("status.orespawn.ore_source.external_short") + : !candidate.loaded ? I18n.get("status.orespawn.ore_source.missing_short") + : !candidate.active ? I18n.get("status.orespawn.ore_source.output_only") : ""; + return owner + " - " + candidate.registryId + (state.isEmpty() ? "" : " (" + state + ")"); + } + + static List candidateTooltip(OreSourceCandidate candidate) { + List result = new ArrayList<>(); + result.add(I18n.get("label.orespawn.ore_source.owner", candidate.owner, + candidate.ownerVersion.isEmpty() ? "?" : candidate.ownerVersion)); + result.add(I18n.get("label.orespawn.ore_source.registry", candidate.registryId, candidate.metadata)); + result.add(I18n.get("label.orespawn.ore_source.dictionary", + candidate.blockTag.isEmpty() ? "-" : String.join(", ", candidate.blockTag))); + result.add(I18n.get(candidate.external ? "tooltip.orespawn.ore_source.external" + : !candidate.loaded ? "tooltip.orespawn.ore_source.missing" + : !candidate.active ? "tooltip.orespawn.ore_source.output_only" + : "tooltip.orespawn.ore_source.controlled")); + return result; + } + + static List groupTooltip(OreSourceGroup group) { + List result = new ArrayList<>(); + result.add(group.material); + result.add(I18n.get("label.orespawn.ore_source.summary", group.outputCandidates().size(), + I18n.get("status.orespawn.ore_source." + group.status))); + result.add(group.blockTagEntries.isEmpty() ? "-" + : String.join(", ", group.blockTagEntries)); + result.add(I18n.get("tooltip.orespawn.ore_source.group_colours")); + return result; + } + + static String formatWeight(Double weight) { + if (weight == null) return "1"; + long whole = weight.longValue(); + return weight.doubleValue() == whole ? Long.toString(whole) : Double.toString(weight.doubleValue()); + } + + static String display(String id) { + int split = id.indexOf(':'); + String value = (split < 0 ? id : id.substring(split + 1)).replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static void drawCompactCog(PoseStack poseStack, Minecraft minecraft, int x, int y, + int width, int height, boolean hovered) { + if (hovered) fill(poseStack, x, y, x + width, y + height, 0x805F5F5F); + CogButton.drawCog(poseStack, x + width / 2, y + height / 2, true); + } + + private void rebuild() { + clearWidgets(); + init(); + } + + static int contentWidth(int width) { + return Math.max(240, Math.min(700, width - (HORIZONTAL_MARGIN * 2))); + } + + static int leftPaneWidth(int width) { + int content = contentWidth(width); + return Math.max(104, Math.min(280, (content * 43) / 100)); + } + + static int listHeight(int height) { + return Math.max(32, (height - 32) - CONTENT_TOP); + } + + static int compactFilterWidth(int availableWidth, int labelWidth) { + return Math.min(Math.max(38, labelWidth + 10), Math.max(16, availableWidth)); + } + + @Override + public void onClose() { + captureScroll(); + if (syncWeights()) minecraft.setScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + fill(leftPaneX - 2, CONTENT_TOP - 2, + leftPaneX + leftPaneWidth + 2, paneBottom + 2, 0x70000000); + fill(rightPaneX - 2, CONTENT_TOP - 2, + rightPaneX + rightPaneWidth + 2, paneBottom + 2, 0x70000000); + drawCenteredString(font, title, width / 2, 8, 0xFFFFFF); + drawString(font, new TranslatableComponent("label.orespawn.ore_source.groups"), + leftPaneX + 2, 8, 0xFFFFFF); + if (error != null) { + drawCenteredString(font, OreSpawnScreenLayout.fit(font, + new TextComponent(error), width - 24), width / 2, height - 39, 0xFF5555); + } + if (selected(orderedGroups()) == null) { + int messageY = CONTENT_TOP + 10; + for (FormattedCharSequence line : font.split( + new TranslatableComponent("label.orespawn.ore_source.none"), + groupList.contentRight() - leftPaneX - 12)) { + drawString(currentPoseStack(), font, line, leftPaneX + 6, messageY, 0xFFA0A0A0); + messageY += 10; + } + } + updateWeightBounds(); + super.render(mouseX, mouseY, partialTick); + OreSpawnScreenLayout.renderExplanations(this, mouseX, mouseY); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java new file mode 100644 index 00000000..153343a1 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModel.java @@ -0,0 +1,124 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +import zone.moddev.mc.orespawn.OreSpawn; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo; + +import net.minecraftforge.fml.ModList; +import net.minecraftforge.forgespi.language.IModInfo; + +/** Builds the immutable, loaded-only model rendered by the OreSpawn Mods screen. */ +final class OreSpawnModDirectoryModel { + private OreSpawnModDirectoryModel() { + } + + static List snapshot() { + List loaded = new ArrayList<>(); + for (IModInfo mod : ModList.get().getMods()) { + loaded.add(new LoadedMod(mod.getModId(), mod.getDisplayName(), + mod.getVersion().toString())); + } + return assemble(loaded, WorldgenIntegrationManager.providerIntegrations(), + WorldSettingsExtensionRegistry.extensions()); + } + + static List assemble(List loadedMods, + List providers, List extensions) { + Map loaded = new LinkedHashMap<>(); + for (LoadedMod mod : loadedMods) { + if (mod != null && mod.modId != null) loaded.put(mod.modId, mod); + } + Map integrations = new LinkedHashMap<>(); + for (ProviderIntegrationInfo provider : providers) { + if (isVisible(provider.modId(), loaded)) { + integrations.computeIfAbsent(provider.modId(), ignored -> new MutableEntry()) + .provider = provider; + } + } + for (WorldSettingsExtension extension : extensions) { + String modId = extension.id().getNamespace(); + if (isVisible(modId, loaded)) { + integrations.computeIfAbsent(modId, ignored -> new MutableEntry()) + .extension = extension; + } + } + + List result = new ArrayList<>(); + for (Map.Entry integration : integrations.entrySet()) { + LoadedMod mod = loaded.get(integration.getKey()); + result.add(new Entry(mod.modId, nonBlank(mod.displayName, mod.modId), + nonBlank(mod.version, "?"), integration.getValue().provider, + integration.getValue().extension)); + } + result.sort(Comparator.comparing((Entry entry) -> entry.displayName.toLowerCase(Locale.ROOT)) + .thenComparing(entry -> entry.modId)); + return Collections.unmodifiableList(result); + } + + private static boolean isVisible(String modId, Map loaded) { + return modId != null && !OreSpawn.MODID.equals(modId) && loaded.containsKey(modId); + } + + private static String nonBlank(String value, String fallback) { + return value == null || value.trim().isEmpty() ? fallback : value; + } + + static final class LoadedMod { + final String modId; + final String displayName; + final String version; + + LoadedMod(String modId, String displayName, String version) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + } + } + + static final class Entry { + final String modId; + final String displayName; + final String version; + final ProviderIntegrationInfo provider; + final WorldSettingsExtension extension; + + Entry(String modId, String displayName, String version, + ProviderIntegrationInfo provider, WorldSettingsExtension extension) { + this.modId = modId; + this.displayName = displayName; + this.version = version; + this.provider = provider; + this.extension = extension; + } + + boolean configurable() { return extension != null; } + boolean nativeOs4() { return provider != null && provider.nativeOs4(); } + int schemaVersion() { return provider == null ? -1 : provider.schemaVersion(); } + int providerRevision() { return provider == null ? -1 : provider.providerRevision(); } + List legacyLineages() { + return provider == null ? Collections.emptyList() : provider.legacyLineages(); + } + String statusKey() { + if (provider == null) return "config_only"; + if (provider.rejected()) return "rejected"; + ProviderStatus status = provider.status(); + return status == null ? "inactive" : status.name().toLowerCase(Locale.ROOT); + } + } + + private static final class MutableEntry { + ProviderIntegrationInfo provider; + WorldSettingsExtension extension; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java new file mode 100644 index 00000000..02b08aa0 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnModsScreen.java @@ -0,0 +1,190 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.network.chat.CommonComponents; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.resources.language.I18n; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.network.chat.TranslatableComponent; + +/** Paginated directory of loaded mods known to OreSpawn. */ +final class OreSpawnModsScreen extends OreSpawnScreen { + private static final int ROW_HEIGHT = 42; + private final Screen parent; + private final List entries; + private int page; + private String error; + private int contentLeft; + private int contentWidth; + private int listTop; + private int pageSize; + + OreSpawnModsScreen(Screen parent) { + this(parent, OreSpawnModDirectoryModel.snapshot()); + } + + OreSpawnModsScreen(Screen parent, List entries) { + super(new TranslatableComponent("screen.orespawn.mods")); + this.parent = parent; + this.entries = Collections.unmodifiableList(new ArrayList<>(entries)); + } + + @Override + protected void init() { + contentWidth = Math.min(390, Math.max(280, width - 24)); + contentLeft = (width - contentWidth) / 2; + listTop = 38; + int controlsY = height - 52; + pageSize = pageSize(height); + int pageCount = pageCount(entries.size(), pageSize); + page = Math.max(0, Math.min(page, pageCount - 1)); + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < entries.size(); index++) { + Entry entry = entries.get(start + index); + CogButton cog = addButton(new CogButton(contentLeft + contentWidth - 26, + listTop + index * ROW_HEIGHT + 9, button -> open(entry), + (button, mouseX, mouseY) -> renderStringTooltip(tooltip(entry), mouseX, mouseY))); + cog.active = entry.configurable(); + } + + Button previous = addButton(new Button(contentLeft, controlsY, 80, 20, + new TranslatableComponent("button.orespawn.previous"), + button -> { page--; rebuildWidgets(); })); + Button next = addButton(new Button(contentLeft + contentWidth - 80, controlsY, 80, 20, + new TranslatableComponent("button.orespawn.next"), + button -> { page++; rebuildWidgets(); })); + previous.active = page > 0; + next.active = page + 1 < pageCount; + addButton(new Button(width / 2 - 75, height - 28, 150, 20, + CommonComponents.GUI_DONE, button -> onClose())); + } + + private void open(Entry entry) { + if (!entry.configurable()) return; + try { + Screen screen = entry.extension.createScreen(this); + if (screen == null) { + error = I18n.get("error.orespawn.mod.screen_missing", entry.modId); + return; + } + WorldSettingsExtensionNavigation.open(this, screen); + } catch (RuntimeException failure) { + error = I18n.get("error.orespawn.mod.screen_failed", entry.modId, + failure.getClass().getSimpleName()); + } + } + + private List tooltip(Entry entry) { + List lines = new ArrayList<>(); + lines.add(I18n.get(entry.configurable() + ? "tooltip.orespawn.mod.configure" : "tooltip.orespawn.mod.unavailable", + entry.displayName)); + lines.add(I18n.get("label.orespawn.mod.id_version", entry.modId, entry.version)); + lines.add(integration(entry)); + for (Integer lineage : entry.legacyLineages()) { + if (!entry.nativeOs4() && lineage.intValue() == primaryLegacy(entry)) continue; + lines.add(I18n.get("label.orespawn.mod.additional_lineage", lineage)); + } + lines.add(I18n.get("label.orespawn.mod.status", + I18n.get("status.orespawn.mod." + entry.statusKey()))); + return lines; + } + + private String integration(Entry entry) { + if (entry.nativeOs4()) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.get("label.orespawn.mod.os4_version", + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.get("label.orespawn.mod.os4"); + } + int legacy = primaryLegacy(entry); + if (legacy > 0) { + if (entry.schemaVersion() > 0 && entry.providerRevision() > 0) { + return I18n.get("label.orespawn.mod.legacy_version", legacy, + entry.schemaVersion(), entry.providerRevision()); + } + return I18n.get("label.orespawn.mod.legacy", legacy); + } + return I18n.get("label.orespawn.mod.config_only"); + } + + private static int primaryLegacy(Entry entry) { + List lineages = entry.legacyLineages(); + return lineages.isEmpty() ? 0 : lineages.get(lineages.size() - 1); + } + + private void rebuildWidgets() { + clearWidgets(); + init(); + } + + static int pageSize(int height) { + return Math.max(1, ((height - 52) - 38) / ROW_HEIGHT); + } + + static int pageCount(int entries, int pageSize) { + return Math.max(1, (Math.max(0, entries) + Math.max(1, pageSize) - 1) + / Math.max(1, pageSize)); + } + + @Override + public void onClose() { + minecraft.setScreen(parent); + } + + @Override + public void render(int mouseX, int mouseY, float partialTick) { + renderBackground(); + drawCenteredString(font, title, width / 2, 14, 0xFFFFFF); + if (entries.isEmpty()) { + drawCenteredString(font, new TranslatableComponent("label.orespawn.mod.none"), + width / 2, height / 2 - 6, 0xA0A0A0); + } + int start = page * pageSize; + for (int index = 0; index < pageSize && start + index < entries.size(); index++) { + drawEntry(entries.get(start + index), listTop + index * ROW_HEIGHT); + } + int pages = pageCount(entries.size(), pageSize); + drawCenteredString(font, new TextComponent((page + 1) + " / " + pages), + width / 2, height - 46, 0xA0A0A0); + if (error != null) { + drawCenteredString(font, new TextComponent(error), width / 2, 27, 0xFF5555); + } + super.render(mouseX, mouseY, partialTick); + } + + private void drawEntry(Entry entry, int y) { + fill(contentLeft, y, contentLeft + contentWidth, y + 38, 0x70000000); + int textWidth = contentWidth - 36; + drawString(font, new TextComponent(trim(entry.displayName, textWidth)), + contentLeft + 6, y + 3, 0xFFFFFF); + drawString(font, new TextComponent(trim( + I18n.get("label.orespawn.mod.id_version", entry.modId, entry.version), textWidth)), + contentLeft + 6, y + 14, 0xA0A0A0); + String details = I18n.get("label.orespawn.mod.integration_status", integration(entry), + I18n.get("status.orespawn.mod." + entry.statusKey())); + drawString(font, new TextComponent(trim(details, textWidth)), + contentLeft + 6, y + 25, statusColor(entry.statusKey())); + } + + private String trim(String value, int maximumWidth) { + if (font.width(value) <= maximumWidth) return value; + String suffix = "..."; + int width = maximumWidth - font.width(suffix); + return font.plainSubstrByWidth(value, Math.max(0, width)) + suffix; + } + + private static int statusColor(String status) { + if ("active".equals(status)) return 0x55FF55; + if ("pending".equals(status)) return 0xFFFF55; + if ("rejected".equals(status)) return 0xFF5555; + return 0xA0A0A0; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java new file mode 100644 index 00000000..c7bba057 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreen.java @@ -0,0 +1,99 @@ +package zone.moddev.mc.orespawn.client; + +import java.util.ArrayList; +import java.util.List; + +import com.mojang.blaze3d.vertex.PoseStack; + +import net.minecraft.client.gui.Font; +import net.minecraft.client.gui.GuiComponent; +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.client.gui.components.AbstractWidget; +import net.minecraft.util.FormattedCharSequence; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.TextComponent; + +/** + * Lets the compact editor screens share their drawing code while Minecraft + * 1.17 supplies the pose stack for each frame. + */ +abstract class OreSpawnScreen extends Screen { + private PoseStack currentPoseStack; + + OreSpawnScreen(Component title) { + super(title); + } + + protected final T addButton(T widget) { + return addRenderableWidget(widget); + } + + @Override + public void render(PoseStack poseStack, int mouseX, int mouseY, float partialTick) { + currentPoseStack = poseStack; + try { + render(mouseX, mouseY, partialTick); + } finally { + currentPoseStack = null; + } + } + + public void render(int mouseX, int mouseY, float partialTick) { + super.render(currentPoseStack, mouseX, mouseY, partialTick); + for (net.minecraft.client.gui.components.Widget widget : renderables) { + if (widget instanceof CompactScrollList) { + ((CompactScrollList) widget).renderTooltip(mouseX, mouseY); + } + } + } + + protected final void renderBackground() { + super.renderBackground(currentPoseStack); + } + + protected final void fill(int x1, int y1, int x2, int y2, int color) { + GuiComponent.fill(currentPoseStack, x1, y1, x2, y2, color); + } + + protected final void drawCenteredString(Font font, + Component text, int x, int y, int color) { + super.drawCenteredString(currentPoseStack, font, text, x, y, color); + } + + protected final void drawCenteredString(Font font, + String text, int x, int y, int color) { + super.drawCenteredString(currentPoseStack, font, text, x, y, color); + } + + protected final void drawString(Font font, + Component text, int x, int y, int color) { + super.drawString(currentPoseStack, font, text, x, y, color); + } + + protected final void drawString(Font font, + String text, int x, int y, int color) { + super.drawString(currentPoseStack, font, text, x, y, color); + } + + protected final void renderComponentTooltip(List lines, + int mouseX, int mouseY) { + List copy = new ArrayList<>(lines); + super.renderComponentTooltip(currentPoseStack, copy, mouseX, mouseY); + } + + final void renderStringTooltip(List lines, int mouseX, int mouseY) { + List wrapped = new ArrayList<>(); + for (String line : lines) { + wrapped.addAll(font.split(new TextComponent(line), Math.max(80, width - 24))); + } + super.renderTooltip(currentPoseStack, wrapped, mouseX, mouseY); + } + + final PoseStack currentPoseStack() { + return currentPoseStack; + } + + final List qualificationButtons() { + return new ArrayList<>(renderables); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java index 53f2db20..033cbf1c 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayout.java @@ -10,7 +10,6 @@ import net.minecraft.client.Minecraft; import net.minecraft.client.gui.Font; import net.minecraft.client.gui.components.AbstractWidget; -import net.minecraft.client.gui.components.Button; import net.minecraft.client.gui.screens.Screen; import net.minecraft.network.chat.Component; import net.minecraft.network.chat.TextComponent; @@ -67,18 +66,18 @@ static Component fit(Font font, Component message, int width) { } static Button button(Screen screen, Font font, int x, int y, int width, int height, - Component message, Button.OnPress onPress) { + Component message, net.minecraft.client.gui.components.Button.OnPress onPress) { Component fitted = fit(font, message, Math.max(0, width - 8)); if (fitted == message) { return new Button(x, y, width, height, message, onPress); } return new Button(x, y, width, height, fitted, onPress, - (button, poseStack, mouseX, mouseY) -> screen.renderTooltip(poseStack, + (button, mouseX, mouseY) -> screen.renderTooltip(button.currentPoseStack(), font.split(message, Math.max(180, Math.min(310, screen.width - 20))), mouseX, mouseY)); } static Button explainedButton(Screen screen, Font font, int x, int y, int width, int height, - Component message, Button.OnPress onPress, String translationKey) { + Component message, net.minecraft.client.gui.components.Button.OnPress onPress, String translationKey) { return explain(screen, button(screen, font, x, y, width, height, message, onPress), translationKey); } @@ -109,6 +108,10 @@ static void renderExplanations(Screen screen, PoseStack poseStack, int mouseX, i } } + static void renderExplanations(OreSpawnScreen screen, int mouseX, int mouseY) { + renderExplanations(screen, screen.currentPoseStack(), mouseX, mouseY); + } + private static final class ExplainedWidget { final AbstractWidget widget; final String translationKey; diff --git a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java index cad23134..60ea4141 100644 --- a/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java +++ b/src/main/java/zone/moddev/mc/orespawn/client/OreSpawnWorldSettingsScreen.java @@ -8,6 +8,7 @@ import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; +import zone.moddev.mc.orespawn.worldgen.GeomeConfig; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfile; import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager; @@ -111,8 +112,11 @@ columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), contentWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.biomes_world_materials"), button -> openBiomeWorldMaterials(), "tooltip.orespawn.main.biomes_materials")); - addRenderableWidget(OreSpawnScreenLayout.button(this, font, left, top + (row * rowIndex), - contentWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.help"), + addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.mods"), + button -> openMods(), "tooltip.orespawn.main.mods")); + addRenderableWidget(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.help"), button -> openHelp())); } else { geologyModeButton = OreSpawnScreenLayout.explain(this, @@ -155,12 +159,15 @@ columnWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.materials addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), columnWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.advanced"), button -> openAdvanced(), "tooltip.orespawn.main.advanced")); - addRenderableWidget(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex++), + addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, right, top + (row * rowIndex++), + columnWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), + "tooltip.orespawn.main.fluid_editor")); + addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), + columnWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.mods"), + button -> openMods(), "tooltip.orespawn.main.mods")); + addRenderableWidget(OreSpawnScreenLayout.button(this, font, right, top + (row * rowIndex), columnWidth, BUTTON_HEIGHT, new TranslatableComponent("button.orespawn.help"), button -> openHelp())); - addRenderableWidget(OreSpawnScreenLayout.explainedButton(this, font, left, top + (row * rowIndex), - contentWidth, BUTTON_HEIGHT, fluidEditorLabel(), button -> openFluidDeposits(), - "tooltip.orespawn.main.fluid_editor")); } addRenderableWidget(OreSpawnScreenLayout.button(this, font, left, this.height - 28, columnWidth, BUTTON_HEIGHT, CommonComponents.GUI_DONE, button -> saveAndClose())); @@ -279,6 +286,11 @@ private void saveAndClose() { validationError = new net.minecraft.network.chat.TextComponent(errors.get(0)); return; } + if (session.oreMaterialGroupsChanged() + && !GeomeConfig.persistOreMaterialGroups(session.oreMaterialGroupsCopy())) { + validationError = new TranslatableComponent("error.orespawn.ore_source.defaults_write"); + return; + } WorldGeologyProfileManager.setPendingNewWorldProfile(session.profile()); minecraft.setScreen(parent); } @@ -323,6 +335,11 @@ private void openFluidDeposits() { minecraft.setScreen(new FluidDepositListScreen(this, session)); } + private void openMods() { + syncSession(); + minecraft.setScreen(new OreSpawnModsScreen(this)); + } + private void openHelp() { minecraft.setScreen(new OreSpawnGuideScreen(this)); } diff --git a/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java new file mode 100644 index 00000000..91c9f8ce --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/TextFieldWidget.java @@ -0,0 +1,24 @@ +package zone.moddev.mc.orespawn.client; + +import net.minecraft.client.gui.Font; +import net.minecraft.network.chat.Component; +import net.minecraft.network.chat.TextComponent; + +/** Keeps editor field construction consistent with the native 1.16 widget. */ +class TextFieldWidget extends net.minecraft.client.gui.components.EditBox { + TextFieldWidget(Font font, int x, int y, int width, int height, String label) { + this(font, x, y, width, height, new TextComponent(label)); + } + + TextFieldWidget(Font font, int x, int y, int width, int height, + Component label) { + super(font, x, y, width, height, label); + } + + void setBounds(int x, int y, int width, int height) { + this.x = x; + this.y = y; + this.width = width; + this.height = height; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java new file mode 100644 index 00000000..03ab4f78 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/client/WorldSettingsExtensionNavigation.java @@ -0,0 +1,43 @@ +package zone.moddev.mc.orespawn.client; + +import zone.moddev.mc.orespawn.OreSpawn; +import net.minecraft.client.Minecraft; +import net.minecraft.client.gui.screens.TitleScreen; +import net.minecraft.client.gui.screens.Screen; +import net.minecraftforge.client.event.ScreenOpenEvent; +import net.minecraftforge.eventbus.api.SubscribeEvent; +import net.minecraftforge.api.distmarker.Dist; +import net.minecraftforge.fml.common.Mod; + +/** Keeps an add-on's inherited Escape action inside OreSpawn's screen stack. */ +@Mod.EventBusSubscriber(modid = OreSpawn.MODID, value = Dist.CLIENT) +public final class WorldSettingsExtensionNavigation { + private static Screen directory; + private static Screen extensionScreen; + + private WorldSettingsExtensionNavigation() { + } + + static synchronized void open(Screen parentDirectory, Screen screen) { + directory = parentDirectory; + extensionScreen = screen; + Minecraft.getInstance().setScreen(screen); + } + + @SubscribeEvent + public static synchronized void onScreenOpen(ScreenOpenEvent event) { + if (extensionScreen == null) return; + // This is the initial transition from the directory to the add-on. + if (event.getScreen() == extensionScreen) return; + Minecraft minecraft = Minecraft.getInstance(); + boolean inheritedEscape = event.getScreen() == null + || (minecraft.level == null && event.getScreen() instanceof TitleScreen); + if (minecraft.screen == extensionScreen && inheritedEscape) { + event.setScreen(directory); + } + // Done already names the directory as its destination. Other non-null + // transitions belong to the add-on and are deliberately left untouched. + directory = null; + extensionScreen = null; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java index 54a9de16..9ad46e67 100644 --- a/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java +++ b/src/main/java/zone/moddev/mc/orespawn/documentation/DocumentationExporter.java @@ -29,7 +29,6 @@ public final class DocumentationExporter { "BIOMES.md", "MIGRATION.md", "TROUBLESHOOTING.md", - "AGENTS.md", "VERSIONS.md", "examples/examplemod-orespawn.json", "examples/orespawn-global.json", diff --git a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java index 12ef60dd..17478697 100644 --- a/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManager.java @@ -22,6 +22,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import zone.moddev.mc.orespawn.OreSpawn; @@ -45,6 +46,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Internal owner of provider discovery, validation, merging, and lifecycle. */ public final class WorldgenIntegrationManager { @@ -165,6 +167,56 @@ public static synchronized Set activeProviderIds() { return Collections.unmodifiableSet(new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet())); } + /** A stable snapshot of integrations for the client Mods directory. */ + public static synchronized List providerIntegrations() { + Set ids = new LinkedHashSet<>(); + ids.addAll(API_PROVIDERS.keySet()); + ids.addAll(RESOURCE_PROVIDERS.keySet()); + ids.addAll(FILE_PROVIDER_IDS); + ids.addAll(ACTIVE_PROVIDERS.keySet()); + ids.addAll(INVALID_PROVIDERS); + List sorted = new ArrayList<>(ids); + Collections.sort(sorted); + List result = new ArrayList<>(); + for (String id : sorted) { + JsonObject root = FILE_PROVIDERS.get(id); + if (root == null) root = RESOURCE_PROVIDERS.get(id); + ProviderDefinition active = ACTIVE_PROVIDERS.get(id); + if (root == null && active != null) root = active.root; + WorldgenProvider api = API_PROVIDERS.get(id); + boolean hasProvider = root != null || api != null || FILE_PROVIDER_IDS.contains(id); + result.add(new ProviderIntegrationInfo(id, hasProvider, hasProvider, + root == null ? (api == null ? -1 : 5) : integer(root, "schema_version", -1), + root == null ? (api == null ? -1 : api.revision()) + : integer(root, "provider_revision", -1), + getProviderStatus(id), INVALID_PROVIDERS.contains(id), + Collections.emptyList())); + } + return Collections.unmodifiableList(result); + } + + /** The editor works on copies, so changing a screen cannot alter a provider definition. */ + public static synchronized BiomeProviderDefaultsSnapshot biomeProviderDefaults() { + JsonObject palettes = new JsonObject(); + JsonObject materials = new JsonObject(); + for (ProviderDefinition provider : ACTIVE_PROVIDERS.values()) { + copyOwnedDefaults(provider, "biome_palettes", palettes); + copyOwnedDefaults(provider, "dimension_materials", materials); + } + return new BiomeProviderDefaultsSnapshot(palettes, materials, + new LinkedHashSet<>(ACTIVE_PROVIDERS.keySet())); + } + + private static void copyOwnedDefaults(ProviderDefinition provider, String section, + JsonObject target) { + for (Entry entry : provider.section(section).entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject value = JsonCopies.copy(entry.getValue().getAsJsonObject()); + value.addProperty("source_provider", provider.modId); + target.add(entry.getKey(), value); + } + } + /** Merge new provider-owned defaults without overwriting pack or world values. */ public static synchronized boolean mergeProviderDefinitions(JsonObject target) { JsonObject manifests = object(target, "providers"); @@ -440,7 +492,7 @@ private static void rebuildActiveProviders() { static void validateProvider(String providerId, JsonObject root) { int schema = integer(root, "schema_version", -1); - if (schema != 1 && schema != 2 && schema != 3 && schema != 4) { + if (schema != 1 && schema != 2 && schema != 3 && schema != 4 && schema != 5) { throw new JsonSyntaxException("unsupported schema_version"); } if (!providerId.equals(string(root, "provider_modid", ""))) { @@ -484,7 +536,7 @@ static void validateProvider(String providerId, JsonObject root) { if ("rocks".equals(section)) { validateRock(entry.getKey(), entry.getValue().getAsJsonObject()); } else if ("ores".equals(section)) { - validateOre(entry.getKey(), entry.getValue().getAsJsonObject()); + validateOre(entry.getKey(), entry.getValue().getAsJsonObject(), schema); } else if ("geomes".equals(section)) { validateGeome(entry.getKey(), entry.getValue().getAsJsonObject()); } else if ("biome_rules".equals(section)) { @@ -538,7 +590,11 @@ private static void validateRock(String idText, JsonObject rock) { validateWeights(optionalObject(rock, "geomes")); } - private static void validateOre(String idText, JsonObject ore) { + private static void validateOre(String idText, JsonObject ore, int schema) { + if (schema < 5 && ore.has("material")) { + throw new JsonSyntaxException("ore material requires provider schema 5"); + } + if (ore.has("material")) new ResourceLocation(string(ore, "material", "")); String blockId = string(ore, "block", idText); Block output = block(blockId); if (output == null || output == Blocks.AIR) { @@ -552,19 +608,23 @@ private static void validateOre(String idText, JsonObject ore) { } for (Entry entry : dimensions.entrySet()) { new ResourceLocation(entry.getKey()); - validateOreRule(idText, entry); + validateOreRule(idText, entry, schema); } for (Entry entry : selectors.entrySet()) { OreDimensionSelector.fromId(new ResourceLocation(entry.getKey())); - validateOreRule(idText, entry); + validateOreRule(idText, entry, schema); } } - private static void validateOreRule(String idText, Entry entry) { + private static void validateOreRule(String idText, Entry entry, int schema) { if (!entry.getValue().isJsonObject()) { throw new JsonSyntaxException("ore dimension is not an object: " + entry.getKey()); } JsonObject rule = entry.getValue().getAsJsonObject(); + if (schema < 5 && rule.has("placement_channel")) { + throw new JsonSyntaxException("ore placement_channel requires provider schema 5"); + } + if (rule.has("placement_channel")) new ResourceLocation(string(rule, "placement_channel", "")); if (!bool(rule, "enabled", true)) return; int minY = integer(rule, "min_y", Integer.MIN_VALUE); int maxY = integer(rule, "max_y", Integer.MIN_VALUE); @@ -671,7 +731,7 @@ private static void validateTerrainDimension(String id, JsonObject dimension) { private static void validateFluidDeposit(String id, JsonObject deposit) { String blockId = string(deposit, "block", ""); Block output = block(blockId); - if (output == null || output == Blocks.AIR || output.defaultBlockState().getFluidState().isEmpty()) { + if (!FluidBlocks.isFluidBlock(output)) { throw new JsonSyntaxException("fluid deposit output is not a fluid block: " + blockId); } JsonObject dimensions = requiredObject(deposit, "dimensions"); @@ -802,7 +862,7 @@ private static void validateDimensionMaterials(String id, JsonObject materials) for (String key : new String[] { "default_fluid", "deep_aquifer_fluid" }) { if (!materials.has(key)) continue; Block value = block(materials.get(key).getAsString()); - if (value == null || value == Blocks.AIR || value.defaultBlockState().getFluidState().isEmpty()) { + if (!FluidBlocks.isFluidBlock(value)) { throw new JsonSyntaxException("dimension material is not a fluid block for " + id + ": " + materials.get(key).getAsString()); } @@ -1030,6 +1090,58 @@ JsonObject section(String name) { } } + /** Provider status shown by the client without exposing mutable provider data. */ + public static final class ProviderIntegrationInfo { + private final String modId; + private final boolean nativeOs4; + private final boolean provider; + private final int schemaVersion; + private final int providerRevision; + private final ProviderStatus status; + private final boolean rejected; + private final List legacyLineages; + + public ProviderIntegrationInfo(String modId, boolean nativeOs4, boolean provider, + int schemaVersion, int providerRevision, ProviderStatus status, + boolean rejected, List legacyLineages) { + this.modId = modId; + this.nativeOs4 = nativeOs4; + this.provider = provider; + this.schemaVersion = schemaVersion; + this.providerRevision = providerRevision; + this.status = status; + this.rejected = rejected; + this.legacyLineages = Collections.unmodifiableList(new ArrayList<>(legacyLineages)); + } + + public String modId() { return modId; } + public boolean nativeOs4() { return nativeOs4; } + public boolean hasProvider() { return provider; } + public int schemaVersion() { return schemaVersion; } + public int providerRevision() { return providerRevision; } + public ProviderStatus status() { return status; } + public boolean rejected() { return rejected; } + public List legacyLineages() { return legacyLineages; } + } + + /** Copy-on-read provider defaults used for GUI resets, not a public API. */ + public static final class BiomeProviderDefaultsSnapshot { + private final JsonObject palettes; + private final JsonObject materials; + private final Set activeProviders; + + private BiomeProviderDefaultsSnapshot(JsonObject palettes, JsonObject materials, + Set activeProviders) { + this.palettes = JsonCopies.copy(palettes); + this.materials = JsonCopies.copy(materials); + this.activeProviders = Collections.unmodifiableSet(new LinkedHashSet<>(activeProviders)); + } + + public JsonObject biomePalettesCopy() { return JsonCopies.copy(palettes); } + public JsonObject dimensionMaterialsCopy() { return JsonCopies.copy(materials); } + public Set activeProviderIds() { return activeProviders; } + } + public static final class TemplateDefinition { private final ResourceLocation id; private final String nameKey; diff --git a/src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java b/src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java new file mode 100644 index 00000000..c0e495dd --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/util/FluidBlocks.java @@ -0,0 +1,19 @@ +package zone.moddev.mc.orespawn.util; + +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LiquidBlock; +import net.minecraft.world.level.material.FluidState; + +/** Distinguishes a fluid's own block from a waterlogged block. */ +public final class FluidBlocks { + private FluidBlocks() { } + + public static boolean isFluidBlock(Block block) { + if (block == null || block == Blocks.AIR) return false; + // Fluid blocks are identifiable even before their fluid holders are populated. + if (block instanceof LiquidBlock) return true; + FluidState fluid = block.defaultBlockState().getFluidState(); + return !fluid.isEmpty() && fluid.createLegacyBlock().getBlock() == block; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java b/src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java new file mode 100644 index 00000000..161cb61a --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/util/JsonCopies.java @@ -0,0 +1,36 @@ +package zone.moddev.mc.orespawn.util; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import com.google.gson.JsonObject; + +/** Keeps configuration copies independent of the Minecraft-bundled Gson version. */ +public final class JsonCopies { + private JsonCopies() { + } + + @SuppressWarnings("unchecked") + public static T copy(T source) { + if (source == null || source.isJsonNull()) return (T) JsonNull.INSTANCE; + if (source.isJsonPrimitive()) return source; + if (source.isJsonArray()) { + JsonArray result = new JsonArray(); + for (JsonElement value : source.getAsJsonArray()) result.add(copy(value)); + return (T) result; + } + JsonObject result = new JsonObject(); + for (java.util.Map.Entry entry : source.getAsJsonObject().entrySet()) { + result.add(entry.getKey(), copy(entry.getValue())); + } + return (T) result; + } + + public static java.util.Set keys(JsonObject source) { + java.util.Set result = new java.util.LinkedHashSet<>(); + for (java.util.Map.Entry entry : source.entrySet()) { + result.add(entry.getKey()); + } + return result; + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java index f4dd2fe0..2b0c2936 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/BiomeWorldgenManager.java @@ -36,9 +36,11 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** Bakes and installs optional biome and dimension-material integration. */ final class BiomeWorldgenManager { + private static final String UI_OVERRIDE_PREFIX = "orespawn:ui/biome_overrides/"; private static final Logger LOGGER = LogManager.getLogger(); private static volatile Map, BakedBiomeWorldgen> baked = Collections.emptyMap(); @@ -111,11 +113,11 @@ private static BakedBiomeWorldgen bake(ServerLevel level, JsonObject root) { return new BakedBiomeWorldgen(palettes, surfaces, materials); } - private static List bakePalettes(JsonObject root, Registry registry, + static List bakePalettes(JsonObject root, Registry registry, ResourceLocation dimension, Map, Surface> surfaces) { JsonObject section = object(root, "biome_palettes"); List result = new ArrayList<>(); - for (Entry paletteEntry : section.entrySet()) { + for (Entry paletteEntry : orderedPaletteEntries(section)) { if (!paletteEntry.getValue().isJsonObject()) continue; JsonObject json = paletteEntry.getValue().getAsJsonObject(); if (!bool(json, "enabled", true) @@ -176,6 +178,17 @@ private static List bakePalettes(JsonObject root, Registry regis return result; } + static List> orderedPaletteEntries(JsonObject section) { + List> ordered = new ArrayList<>(); + for (Entry entry : section.entrySet()) { + if (!entry.getKey().startsWith(UI_OVERRIDE_PREFIX)) ordered.add(entry); + } + for (Entry entry : section.entrySet()) { + if (entry.getKey().startsWith(UI_OVERRIDE_PREFIX)) ordered.add(entry); + } + return ordered; + } + private static Map bakeChoices(Registry registry, JsonObject palette, BakedBiomeWorldgen.Entry[] entries) { boolean replace = "replace".equals(string(palette, "mode", "augment")); @@ -306,7 +319,7 @@ private static BlockState state(JsonObject json, String key, boolean fluid) { Block block = ForgeRegistries.BLOCKS.getValue( new ResourceLocation(json.get(key).getAsString())); if (block == null || block == Blocks.AIR - || (fluid && block.defaultBlockState().getFluidState().isEmpty())) return null; + || (fluid && !FluidBlocks.isFluidBlock(block))) return null; return block.defaultBlockState(); } catch (RuntimeException e) { return null; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java index a61471f1..a28b90a4 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeature.java @@ -43,6 +43,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import zone.moddev.mc.orespawn.util.FluidBlocks; /** One allocation-light feature for all provider-owned underground fluid deposits. */ public final class FluidDepositFeature extends Feature { @@ -326,7 +327,7 @@ private static Map, BakedDeposit[]> bakeDeposits(JsonObject p JsonObject deposit = depositEntry.getValue().getAsJsonObject(); if (!bool(deposit, "enabled", true)) continue; Block output = block(string(deposit, "block", "")); - if (output == null || output == Blocks.AIR || output.defaultBlockState().getFluidState().isEmpty() + if (!FluidBlocks.isFluidBlock(output) || !deposit.has("dimensions") || !deposit.get("dimensions").isJsonObject()) { LOGGER.warn("Ignoring invalid fluid deposit '{}'", depositEntry.getKey()); continue; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java index bc59251e..87b75ec1 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/GeomeConfig.java @@ -26,6 +26,7 @@ import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import zone.moddev.mc.orespawn.OreSpawnConfig; @@ -61,6 +62,10 @@ public final class GeomeConfig { private static final Path CONFIG_V3_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v3.bak"); private static final Path CONFIG_V4_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v4.bak"); private static final Path CONFIG_V5_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v5.bak"); + private static final Path CONFIG_V6_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v6.bak"); + private static final Path CONFIG_V7_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.v7.bak"); + private static final Path MATERIAL_GROUPS_BACKUP_PATH = Paths.get("config", + "orespawn-worldgen.pre-material-groups.bak"); private static final Path BIOME_DEFAULTS_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.pre-biome-revision-3.bak"); private static final Path WORLDGEN_ALIAS_DEFAULTS_BACKUP_PATH = Paths.get("config", @@ -70,7 +75,7 @@ public final class GeomeConfig { private static final Path CONFIG_TEMP_PATH = Paths.get("config", "orespawn-worldgen.json.tmp"); private static final Path PROVIDER_DEFAULTS_BACKUP_PATH = Paths.get("config", "orespawn-worldgen.pre-provider-defaults.bak"); - public static final int SCHEMA_VERSION = 6; + public static final int SCHEMA_VERSION = 8; private static final int BIOME_DEFAULTS_REVISION = 3; private static final int WORLDGEN_ALIAS_DEFAULTS_REVISION = 1; private static final int ORE_DEFAULTS_REVISION = 10; @@ -97,6 +102,7 @@ public static synchronized BakedGeomeConfig bake() { JsonObject root = loadConfig(); boolean changed = OreSpawnOreIntegration.mergeProviderOres(root); changed |= FluidDepositMigration.normalize(root); + changed |= OreSourcePolicies.initialize(root, false); if (changed) { writeProviderMerge(root); } @@ -162,6 +168,23 @@ public static WorldGeologyProfile globalBaseProfile() { return globalBaseProfile; } + /** Saves accepted material names and tag aliases as defaults for new worlds. */ + public static synchronized boolean persistOreMaterialGroups(JsonObject groups) { + JsonObject updated = globalBaseConfigSnapshot(); + updated.add(OreMaterialGroups.SECTION, JsonCopies.copy(groups)); + OreMaterialGroups.initialize(updated); + updated.addProperty("schema_version", SCHEMA_VERSION); + if (!writeUpdatedConfig(updated, MATERIAL_GROUPS_BACKUP_PATH)) return false; + globalBaseConfigRoot = JsonCopies.copy(updated); + globalBaseProfile = WorldGeologyProfile.fromGlobalConfig(updated, + OreSpawnConfig.geologyMode(), OreSpawnConfig.placeCrudeOil()); + JsonObject effective = applyFreshWorldTemplate(updated); + globalConfigRoot = JsonCopies.copy(effective); + globalProfile = WorldGeologyProfile.fromGlobalConfig(effective, + OreSpawnConfig.geologyMode(), OreSpawnConfig.placeCrudeOil()); + return true; + } + static boolean hasTerrainReplacement(ResourceKey dimension) { return terrainDimension(dimension) != null; } @@ -193,14 +216,16 @@ private static JsonObject loadConfig() { try { int schemaVersion = getInt(root, "schema_version", 1); if (schemaVersion < SCHEMA_VERSION) { - JsonObject migrated = schemaVersion <= 1 ? migrateV1(root) : root.deepCopy(); - migrated = migrateToV6(migrated, defaults); + JsonObject migrated = schemaVersion <= 1 ? migrateV1(root) : JsonCopies.copy(root); + migrated = migrateToV8(migrated, defaults); writeMigratedConfig(migrated, schemaVersion <= 1 ? CONFIG_BACKUP_PATH : schemaVersion == 2 ? CONFIG_V2_BACKUP_PATH : schemaVersion == 3 ? CONFIG_V3_BACKUP_PATH : schemaVersion == 4 ? CONFIG_V4_BACKUP_PATH - : CONFIG_V5_BACKUP_PATH); + : schemaVersion == 5 ? CONFIG_V5_BACKUP_PATH + : schemaVersion == 6 ? CONFIG_V6_BACKUP_PATH + : CONFIG_V7_BACKUP_PATH); return migrated; } if (schemaVersion > SCHEMA_VERSION) { @@ -656,14 +681,15 @@ private static JsonObject migrateV1(JsonObject original) { return refreshBiomeDefaults(migrated, defaultConfig()); } - private static JsonObject migrateToV6(JsonObject original, JsonObject defaults) { + private static JsonObject migrateToV8(JsonObject original, JsonObject defaults) { JsonObject migrated = getInt(original, "biome_defaults_revision", 0) < BIOME_DEFAULTS_REVISION ? refreshBiomeDefaults(original, defaults) : original.deepCopy(); FluidDepositMigration.normalize(migrated); for (String key : new String[] { "geology_mode", "place_fluid_deposits", "fluid_deposits", "manage_vanilla_ores", "ore_defaults_revision", "cyano", "ores", "ore_providers", "providers", "worldgen_aliases", "default_template", - "terrain_dimensions", "biome_palettes", "dimension_materials" }) { + "terrain_dimensions", "biome_palettes", "dimension_materials", + OreMaterialGroups.SECTION, OreSourcePolicies.SECTION }) { if (!migrated.has(key)) { migrated.add(key, defaults.get(key).deepCopy()); } @@ -1372,6 +1398,8 @@ private static JsonObject defaultConfig() { root.add("terrain_dimensions", new JsonObject()); root.add("biome_palettes", new JsonObject()); root.add("dimension_materials", new JsonObject()); + root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults()); + root.add(OreSourcePolicies.SECTION, new JsonObject()); JsonObject retrogen = new JsonObject(); retrogen.addProperty("enabled", false); retrogen.addProperty("force", false); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java index 6aa580fd..5838ab52 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyConfigMigrator.java @@ -358,7 +358,7 @@ private static void writeReport(Path config, List lines) { private static void writeUpgradeReport(Path config, int imported, List detail) { List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.118021 Upgrade Report"); + lines.add("OreSpawn 4.1.0.118021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Legacy OreSpawn settings were imported into the OS4 profile."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java index 58242430..efdc2463 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/LegacyMineralogyProfileMigration.java @@ -204,7 +204,7 @@ private static void writeUpgradeReport(Path worldRoot, Path configPath, Path report = worldRoot.resolve("serverconfig/orespawn-upgrade-report.txt"); List missing = missingBlocks(igneous, metamorphic, sedimentary); List lines = new ArrayList<>(); - lines.add("OreSpawn 4.0.16.118021 Upgrade Report"); + lines.add("OreSpawn 4.1.0.118021 Upgrade Report"); lines.add("================================"); lines.add(""); lines.add("RESULT: Existing Mineralogy " + identity.version + " world detected."); diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalog.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalog.java new file mode 100644 index 00000000..3df9e460 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalog.java @@ -0,0 +1,64 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** Names known MMD ore providers; it never creates an ore or a placement rule. */ +final class MmdOreConflictCatalog { + private static final Set ORDINARY = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList( + "basemetals", "modernmetals", "basegems", "baseminerals", "fantasymetals", + "electricadvantage", "steamadvantage", "poweradvantage", "mineralogy"))); + private static final Map> PRIORITIES = priorities(); + + private MmdOreConflictCatalog() { } + + static boolean ordinary(String owner) { + return ORDINARY.contains(owner); + } + + static boolean enrichment(String owner) { + return "densemetals".equals(owner); + } + + static boolean netherOnly(String owner) { + return "nethermetals".equals(owner); + } + + static boolean endOnly(String owner) { + return "endmetals".equals(owner); + } + + static List priority(String material) { + return PRIORITIES.getOrDefault(material, Collections.emptyList()); + } + + /** Only exact, shared ore tags from two loaded ordinary providers opt in. */ + static boolean automaticallyBalance(Map> tagsByOwner) { + Map> ownersByTag = new HashMap<>(); + for (Map.Entry> entry : tagsByOwner.entrySet()) { + if (!ordinary(entry.getKey())) continue; + for (String tag : entry.getValue()) { + if (!OreMaterialGroups.validTag(tag)) continue; + ownersByTag.computeIfAbsent(tag, ignored -> new HashSet<>()).add(entry.getKey()); + } + } + for (Set owners : ownersByTag.values()) if (owners.size() >= 2) return true; + return false; + } + + private static Map> priorities() { + Map> result = new LinkedHashMap<>(); + result.put("sulfur", Collections.unmodifiableList(Arrays.asList( + "mineralogy", "baseminerals", "electricadvantage"))); + result.put("lithium", Collections.unmodifiableList(Arrays.asList( + "baseminerals", "electricadvantage"))); + return Collections.unmodifiableMap(result); + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java new file mode 100644 index 00000000..73b6f7d3 --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroups.java @@ -0,0 +1,320 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.resources.ResourceLocation; +import zone.moddev.mc.orespawn.util.JsonCopies; + +/** Stable material names and exact block-tag aliases used by source arbitration. */ +final class OreMaterialGroups { + static final String SECTION = "ore_material_groups"; + static final String TAGS = "block_tag_entries"; + static final String LEGACY_ALIASES = "ore_dictionary_entries"; + private static final ResourceLocation LEGACY_REVIEW = new ResourceLocation("orespawn", "review_required"); + private static final ResourceLocation SULFUR = new ResourceLocation("orespawn", "sulfur"); + private static final ResourceLocation ALUMINUM = new ResourceLocation("orespawn", "aluminum"); + + private OreMaterialGroups() { } + + static boolean initialize(JsonObject root) { + JsonObject before = root.has(SECTION) && root.get(SECTION).isJsonObject() + ? JsonCopies.copy(root.getAsJsonObject(SECTION)) : new JsonObject(); + JsonObject normalized = normalize(before); + mergeCurated(normalized, SULFUR, "Sulfur", "forge:ores/sulfur", "forge:ores/sulphur"); + mergeCurated(normalized, ALUMINUM, "Aluminum", "forge:ores/aluminum", "forge:ores/aluminium"); + deduplicateAliases(normalized); + root.add(SECTION, normalized); + return !before.toString().equals(normalized.toString()); + } + + static JsonObject defaults() { + JsonObject root = new JsonObject(); + mergeCurated(root, SULFUR, "Sulfur", "forge:ores/sulfur", "forge:ores/sulphur"); + mergeCurated(root, ALUMINUM, "Aluminum", "forge:ores/aluminum", "forge:ores/aluminium"); + return root; + } + + static Inference infer(JsonObject root, Iterable blockTags) { + Map owners = aliases(root); + Set materials = new LinkedHashSet<>(); + Set exactNames = new LinkedHashSet<>(); + for (String name : blockTags) { + if (!validTag(name)) continue; + exactNames.add(name); + ResourceLocation material = owners.get(name); + if (material == null) { + ResourceLocation tag = resource(name); + String token = tag.getPath().substring("ores/".length()); + try { material = new ResourceLocation("orespawn", token); } + catch (RuntimeException invalid) { material = null; } + } + if (material != null) materials.add(material); + } + List exact = new ArrayList<>(exactNames); + Collections.sort(exact); + if (materials.size() == 1) { + ResourceLocation material = materials.iterator().next(); + return new Inference(material, false, exact); + } + return new Inference(materials.size() > 1 ? provisionalMaterial(exact) : null, + materials.size() > 1, exact); + } + + static List definitions(JsonObject root) { + JsonObject groups = root.has(SECTION) && root.get(SECTION).isJsonObject() + ? root.getAsJsonObject(SECTION) : defaults(); + List result = new ArrayList<>(); + for (Entry entry : groups.entrySet()) { + ResourceLocation id = resource(entry.getKey()); + if (id == null || !entry.getValue().isJsonObject()) continue; + JsonObject value = entry.getValue().getAsJsonObject(); + List names = new ArrayList<>(); + if (value.has(TAGS) && value.get(TAGS).isJsonArray()) { + for (JsonElement element : value.getAsJsonArray(TAGS)) { + try { + String name = element.getAsString(); + if (validTag(name) && !names.contains(name)) names.add(name); + } catch (RuntimeException ignored) { } + } + } + Collections.sort(names); + String display = string(value, "display_name", humanize(id.getPath())); + result.add(new Definition(id, display, names, legacyAliases(value), curated(id))); + } + result.sort(Comparator.comparing((Definition value) -> value.displayName.toLowerCase(Locale.ROOT)) + .thenComparing(value -> value.id.toString())); + return Collections.unmodifiableList(result); + } + + static Definition definition(JsonObject root, ResourceLocation id) { + for (Definition definition : definitions(root)) if (definition.id.equals(id)) return definition; + return new Definition(id, humanize(id.getPath()), Collections.emptyList(), + Collections.emptyList(), curated(id)); + } + + static boolean ensureDefinition(JsonObject root, ResourceLocation id, Iterable blockTags) { + List exact = exactTags(blockTags); + JsonObject groups = root.has(SECTION) && root.get(SECTION).isJsonObject() + ? root.getAsJsonObject(SECTION) : defaults(); + boolean changed = !root.has(SECTION) || !root.get(SECTION).isJsonObject(); + JsonObject value; + if (groups.has(id.toString()) && groups.get(id.toString()).isJsonObject()) { + value = groups.getAsJsonObject(id.toString()); + } else { + value = new JsonObject(); + value.addProperty("display_name", defaultDisplayName(id, exact)); + value.add(TAGS, new JsonArray()); + groups.add(id.toString(), value); + changed = true; + } + JsonArray aliases = value.has(TAGS) + && value.get(TAGS).isJsonArray() + ? value.getAsJsonArray(TAGS) : new JsonArray(); + if (!value.has(TAGS) || !value.get(TAGS).isJsonArray()) { + value.add(TAGS, aliases); + changed = true; + } + Set present = new LinkedHashSet<>(); + for (JsonElement alias : aliases) { + try { present.add(alias.getAsString()); } catch (RuntimeException ignored) { } + } + Map owners = aliases(root); + for (String name : exact) { + if (!validTag(name) || present.contains(name)) continue; + ResourceLocation owner = owners.get(name); + if (owner != null && !owner.equals(id)) continue; + aliases.add(new JsonPrimitive(name)); + present.add(name); + changed = true; + } + root.add(SECTION, groups); + return changed; + } + + static boolean validTag(String name) { + ResourceLocation tag = resource(name); + return tag != null && name.length() <= 128 + && tag.getPath().startsWith("ores/") + && tag.getPath().length() > "ores/".length(); + } + + static boolean curated(ResourceLocation id) { + return SULFUR.equals(id) || ALUMINUM.equals(id); + } + + private static JsonObject normalize(JsonObject groups) { + JsonObject result = new JsonObject(); + for (Entry entry : groups.entrySet()) { + ResourceLocation id = resource(entry.getKey()); + if (id == null || LEGACY_REVIEW.equals(id) || !entry.getValue().isJsonObject()) continue; + JsonObject source = entry.getValue().getAsJsonObject(); + JsonObject value = new JsonObject(); + value.addProperty("display_name", string(source, "display_name", humanize(id.getPath()))); + JsonArray names = new JsonArray(); + Set seen = new LinkedHashSet<>(); + if (source.has(TAGS) && source.get(TAGS).isJsonArray()) { + for (JsonElement element : source.getAsJsonArray(TAGS)) { + try { + String name = element.getAsString(); + if (validTag(name) && seen.add(name)) names.add(new JsonPrimitive(name)); + } catch (RuntimeException ignored) { } + } + } + value.add(TAGS, names); + JsonArray dormant = new JsonArray(); + for (String alias : legacyAliases(source)) dormant.add(new JsonPrimitive(alias)); + if (dormant.size() > 0) value.add(LEGACY_ALIASES, dormant); + result.add(id.toString(), value); + } + return result; + } + + private static void mergeCurated(JsonObject groups, ResourceLocation id, String display, String... aliases) { + if (groups.has(id.toString()) && groups.get(id.toString()).isJsonObject()) return; + JsonObject value = new JsonObject(); + value.addProperty("display_name", display); + JsonArray names = new JsonArray(); + for (String alias : aliases) names.add(new JsonPrimitive(alias)); + value.add(TAGS, names); + groups.add(id.toString(), value); + } + + private static void deduplicateAliases(JsonObject groups) { + Set claimed = new LinkedHashSet<>(); + for (Entry entry : groups.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject value = entry.getValue().getAsJsonObject(); + JsonArray unique = new JsonArray(); + if (value.has(TAGS) && value.get(TAGS).isJsonArray()) { + for (JsonElement element : value.getAsJsonArray(TAGS)) { + try { + String name = element.getAsString(); + if (validTag(name) && claimed.add(name)) unique.add(new JsonPrimitive(name)); + } catch (RuntimeException ignored) { } + } + } + value.add(TAGS, unique); + } + } + + private static Map aliases(JsonObject root) { + Map result = new LinkedHashMap<>(); + for (Definition definition : definitions(root)) { + for (String name : definition.blockTagEntries) result.putIfAbsent(name, definition.id); + } + return result; + } + + private static List legacyAliases(JsonObject value) { + Set result = new LinkedHashSet<>(); + if (value.has(LEGACY_ALIASES) && value.get(LEGACY_ALIASES).isJsonArray()) { + for (JsonElement element : value.getAsJsonArray(LEGACY_ALIASES)) { + try { + String alias = element.getAsString(); + if (alias.length() <= 128 && alias.matches("ore[A-Z][A-Za-z0-9_]+")) result.add(alias); + } catch (RuntimeException ignored) { } + } + } + return new ArrayList<>(result); + } + + private static ResourceLocation resource(String value) { + try { return value == null || value.trim().isEmpty() ? null : new ResourceLocation(value); } + catch (RuntimeException ignored) { return null; } + } + + private static String string(JsonObject root, String key, String fallback) { + try { return root.has(key) ? root.get(key).getAsString() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private static String humanize(String path) { + String value = path.replace('_', ' ').replace('/', ' '); + return value.isEmpty() ? value : Character.toUpperCase(value.charAt(0)) + value.substring(1); + } + + private static ResourceLocation provisionalMaterial(List exact) { + String token = exact.isEmpty() ? "unknown" + : resource(exact.get(0)).getPath().substring("ores/".length()); + return new ResourceLocation("orespawn", "review/" + token + '-' + stableAliasHash(exact)); + } + + private static String stableAliasHash(List exact) { + long hash = 0xcbf29ce484222325L; + for (String name : exact) { + for (int index = 0; index < name.length(); index++) { + hash ^= name.charAt(index); + hash *= 0x100000001b3L; + } + hash ^= 0xffL; + hash *= 0x100000001b3L; + } + String value = Long.toHexString(hash); + StringBuilder padded = new StringBuilder(16); + for (int index = value.length(); index < 16; index++) padded.append('0'); + return padded.append(value).toString(); + } + + private static List exactTags(Iterable blockTags) { + Set unique = new LinkedHashSet<>(); + for (String name : blockTags) if (validTag(name)) unique.add(name); + List result = new ArrayList<>(unique); + Collections.sort(result); + return result; + } + + private static String defaultDisplayName(ResourceLocation id, List exact) { + if (!"orespawn".equals(id.getNamespace()) || !id.getPath().startsWith("review/")) { + return humanize(id.getPath()); + } + StringBuilder display = new StringBuilder(); + for (String name : exact) { + if (display.length() > 0) display.append(", "); + display.append(humanize(resource(name).getPath().substring("ores/".length()))); + } + return display.length() == 0 ? "Review required" : display.toString(); + } + + static final class Definition { + final ResourceLocation id; + final String displayName; + final List blockTagEntries; + final List dormantOreDictionaryEntries; + final boolean curated; + + Definition(ResourceLocation id, String displayName, List blockTagEntries, + List dormantOreDictionaryEntries, boolean curated) { + this.id = id; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.dormantOreDictionaryEntries = Collections.unmodifiableList(new ArrayList<>(dormantOreDictionaryEntries)); + this.curated = curated; + } + } + + static final class Inference { + final ResourceLocation material; + final boolean reviewRequired; + final List names; + + Inference(ResourceLocation material, boolean reviewRequired, List names) { + this.material = material; + this.reviewRequired = reviewRequired; + this.names = Collections.unmodifiableList(new ArrayList<>(names)); + } + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java new file mode 100644 index 00000000..e663f87a --- /dev/null +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSourcePolicies.java @@ -0,0 +1,796 @@ +package zone.moddev.mc.orespawn.worldgen; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Map.Entry; +import java.util.Set; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; +import zone.moddev.mc.orespawn.util.JsonCopies; + +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.tags.TagKey; +import net.minecraft.resources.ResourceLocation; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.registries.ForgeRegistries; + +/** Bake-time discovery and persistence for equivalent managed ore sources. */ +final class OreSourcePolicies { + static final ResourceLocation STANDARD = new ResourceLocation("orespawn", "standard"); + static final String SECTION = "ore_source_policies"; + private static final String MODE_CONSOLIDATED = "consolidated"; + private static final String MODE_SEPARATE = "keep_separate"; + private static final String OUTPUT_BALANCED = "balanced"; + private static final String OUTPUT_SINGLE = "single"; + private static final String OUTPUT_CUSTOM = "custom"; + + private OreSourcePolicies() { } + + /** + * Discovers currently available sources and creates only missing policies. + * Existing selections are never discarded, which lets missing mods return. + */ + static boolean initialize(JsonObject root, boolean existingWorld) { + boolean changed = OreMaterialGroups.initialize(root); + boolean hadPolicies = root.has(SECTION) && root.get(SECTION).isJsonObject(); + JsonObject policies = object(root, SECTION); + Map groups = discover(root); + for (Group group : groups.values()) { + Set names = new LinkedHashSet<>(); + for (Candidate candidate : group.candidates) names.addAll(candidate.blockTags); + changed |= OreMaterialGroups.ensureDefinition(root, group.material, names); + } + changed |= !hadPolicies; + Set refreshedKeys = new LinkedHashSet<>(); + for (Group group : groups.values()) { + String key = key(group.material, group.domain); + JsonObject previous = policies.has(key) && policies.get(key).isJsonObject() + ? policies.getAsJsonObject(key) : null; + JsonObject refreshed = previous == null + ? createPolicy(group, existingWorld) : refreshPolicy(previous, group); + refreshed.remove("dormant"); + if (previous == null || !previous.toString().equals(refreshed.toString())) { + policies.add(key, refreshed); + changed = true; + } + refreshedKeys.add(key); + } + for (Entry entry : new ArrayList<>(policies.entrySet())) { + if (refreshedKeys.contains(entry.getKey()) || !entry.getValue().isJsonObject()) continue; + JsonObject previous = entry.getValue().getAsJsonObject(); + ResourceLocation material = resource(string(previous, "material", "")); + ResourceLocation domain = resource(string(previous, "domain", "")); + if (material == null || domain == null) continue; + Group group = groups.get(key(material, domain)); + if (group == null) group = new Group(material, domain); + JsonObject refreshed = refreshPolicy(previous, group); + if (candidatesWereReassigned(root, previous, material)) { + refreshed.addProperty("dormant", true); + } + if (!previous.toString().equals(refreshed.toString())) { + policies.add(entry.getKey(), refreshed); + changed = true; + } + } + root.add(SECTION, policies); + return changed; + } + + /** + * A retained policy is missing from discovery either because its mod is absent or + * because the user moved its inferred aliases into another material group. Missing + * mods must remain visible; reassigned policies must remain stored but dormant. + */ + private static boolean candidatesWereReassigned(JsonObject root, JsonObject policy, + ResourceLocation material) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return false; + boolean foundInferredCandidate = false; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (bool(candidate, "material_declared", false)) return false; + List names = strings(candidate.get("block_tags")); + if (names.isEmpty()) return false; + OreMaterialGroups.Inference inferred = OreMaterialGroups.infer(root, names); + if (inferred.material == null || material.equals(inferred.material)) return false; + foundInferredCandidate = true; + } + return foundInferredCandidate; + } + + static Snapshot snapshot(JsonObject root) { + Map discovered = discover(root); + JsonObject policies = object(root, SECTION); + List views = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + if (bool(policy, "dormant", false)) continue; + ResourceLocation material = resource(string(policy, "material", "")); + ResourceLocation domain = resource(string(policy, "domain", "")); + if (material == null || domain == null) continue; + Group current = discovered.get(key(material, domain)); + List candidates = readCandidates(policy, current); + String mode = string(policy, "mode", MODE_SEPARATE); + OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, material); + views.add(new GroupView(entry.getKey(), material, domain, definition.displayName, + definition.blockTagEntries, definition.curated, + mode, outputMode(policy), status(policy, candidates), candidates, + decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"))); + seen.add(entry.getKey()); + } + for (Group group : discovered.values()) { + String key = key(group.material, group.domain); + if (seen.contains(key)) continue; + JsonObject policy = createPolicy(group, true); + OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, group.material); + views.add(new GroupView(key, group.material, group.domain, definition.displayName, + definition.blockTagEntries, definition.curated, MODE_SEPARATE, OUTPUT_CUSTOM, + status(policy, group.candidates), immutableCandidates(group.candidates), + decimalMap(policy, "outputs"), stringMap(policy, "placement_sources"))); + } + views.sort(Comparator.comparing((GroupView view) -> view.material.toString()) + .thenComparing(view -> view.domain.toString())); + return new Snapshot(views); + } + + static ResourceLocation material(JsonObject ore, Block output) { + ResourceLocation explicit = resource(string(ore, "material", "")); + if (explicit != null) return explicit; + return inferMaterial(blockTags(output)).material; + } + + static ResourceLocation material(JsonObject root, JsonObject ore, Block output) { + ResourceLocation explicit = resource(string(ore, "material", "")); + if (explicit != null) return explicit; + return inferMaterial(root, blockTags(output)).material; + } + + static ResourceLocation placementChannel(JsonObject rule) { + ResourceLocation explicit = resource(string(rule, "placement_channel", "")); + if (explicit != null) return explicit; + JsonObject pattern = rule.has("pattern") && rule.get("pattern").isJsonObject() + ? rule.getAsJsonObject("pattern") : null; + if (pattern != null) { + ResourceLocation type = resource(string(pattern, "type", "")); + if (type != null) return type; + } + String legacy = string(rule, "pattern", ""); + ResourceLocation type = legacy.indexOf(':') >= 0 ? resource(legacy) : null; + return type != null ? type : STANDARD; + } + + static Inference inferMaterial(Iterable blockTags) { + JsonObject root = new JsonObject(); + root.add(OreMaterialGroups.SECTION, OreMaterialGroups.defaults()); + return inferMaterial(root, blockTags); + } + + static Inference inferMaterial(JsonObject root, Iterable blockTags) { + OreMaterialGroups.Inference inferred = OreMaterialGroups.infer(root, blockTags); + return new Inference(inferred.material, inferred.reviewRequired, inferred.names); + } + + static String key(ResourceLocation material, ResourceLocation domain) { + return material.toString() + "|" + domain.toString(); + } + + private static Map discover(JsonObject root) { + Map groups = new LinkedHashMap<>(); + boolean manageVanillaOres = bool(root, "manage_vanilla_ores", false); + JsonObject ores = object(root, "ores"); + for (Entry oreEntry : ores.entrySet()) { + if (!oreEntry.getValue().isJsonObject()) continue; + JsonObject ore = oreEntry.getValue().getAsJsonObject(); + boolean oreActive = bool(ore, "enabled", true) + && (!bool(ore, "native_generation", false) || manageVanillaOres); + ResourceLocation blockId = resource(string(ore, "block", oreEntry.getKey())); + Block block = blockId == null ? null : ForgeRegistries.BLOCKS.getValue(blockId); + if (block == null || block == Blocks.AIR) continue; + int metadata = integer(ore, "metadata", 0); + Inference inferred = inferMaterial(root, blockTags(block)); + ResourceLocation material = resource(string(ore, "material", "")); + boolean materialDeclared = material != null; + if (material == null) material = inferred.material; + if (material == null) continue; + String owner = owner(ore, blockId); + Role role = role(owner); + if (role == Role.NONE) continue; + for (Entry dimension : object(ore, "dimensions").entrySet()) { + addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material, + resource(dimension.getKey()), dimension.getValue(), inferred, role, + oreActive, materialDeclared); + } + for (Entry selector : object(ore, "dimension_selectors").entrySet()) { + addConfigured(groups, oreEntry.getKey(), owner, blockId, metadata, material, + resource(selector.getKey()), selector.getValue(), inferred, role, + oreActive, materialDeclared); + } + } + addExternalTagCandidates(groups, root); + return groups; + } + + private static void addConfigured(Map groups, String sourceId, String owner, + ResourceLocation blockId, int metadata, ResourceLocation material, ResourceLocation domain, + JsonElement ruleElement, Inference inferred, Role role, boolean oreActive, + boolean materialDeclared) { + if (domain == null || !ruleElement.isJsonObject()) return; + if (role == Role.NETHER && !"minecraft:the_nether".equals(domain.toString())) return; + if (role == Role.END && !"minecraft:the_end".equals(domain.toString())) return; + JsonObject rule = ruleElement.getAsJsonObject(); + boolean active = oreActive && bool(rule, "enabled", true); + Candidate candidate = new Candidate(sourceId, owner, modName(owner), modVersion(owner), blockId, + metadata, material, domain, placementChannel(rule), inferred.names, true, active, + false, role == Role.ENRICHMENT, inferred.reviewRequired, materialDeclared); + group(groups, material, domain).add(candidate); + } + + private static void addExternalTagCandidates(Map groups, JsonObject root) { + if (groups.isEmpty()) return; + Map> namesByMaterial = new HashMap<>(); + for (String name : safeBlockTagNames()) { + Inference inference = inferMaterial(root, Collections.singletonList(name)); + if (inference.material != null && !inference.reviewRequired) { + namesByMaterial.computeIfAbsent(inference.material, ignored -> new ArrayList<>()).add(name); + } + } + for (Group group : groups.values()) { + List names = namesByMaterial.get(group.material); + if (names == null) continue; + for (String tagName : names) { + for (Block block : safeTaggedBlocks(tagName)) { + ResourceLocation id = block == null ? null : ForgeRegistries.BLOCKS.getKey(block); + if (id == null || block == Blocks.AIR || group.hasBlock(id, 0)) continue; + String owner = id.getNamespace(); + Role role = role(owner); + if (role == Role.NONE || role == Role.ENRICHMENT) continue; + if (role == Role.NETHER && !"minecraft:the_nether".equals(group.domain.toString())) continue; + if (role == Role.END && !"minecraft:the_end".equals(group.domain.toString())) continue; + String sourceId = "external/" + id.toString(); + group.add(new Candidate(sourceId, owner, modName(owner), modVersion(owner), id, + 0, group.material, group.domain, STANDARD, + Collections.singletonList(tagName), loaded(owner), false, true, false, false, + false)); + } + } + } + } + + static JsonObject createPolicy(Group group, boolean existingWorld) { + JsonObject policy = new JsonObject(); + policy.addProperty("material", group.material.toString()); + policy.addProperty("domain", group.domain.toString()); + Candidate preferred = preferred(group); + boolean automatic = !existingWorld && preferred != null && highConfidence(group); + policy.addProperty("mode", automatic ? MODE_CONSOLIDATED : MODE_SEPARATE); + policy.addProperty("output_mode", automatic ? OUTPUT_BALANCED : OUTPUT_CUSTOM); + JsonObject outputs = new JsonObject(); + for (Candidate candidate : group.outputs()) { + if (candidate.external) continue; + outputs.addProperty(candidate.sourceId, 1.0D); + } + policy.add("outputs", outputs); + JsonObject placements = new JsonObject(); + for (ResourceLocation channel : group.channels()) { + Candidate selected = preferred(group, channel); + if (selected != null) placements.addProperty(channel.toString(), selected.sourceId); + } + policy.add("placement_sources", placements); + policy.addProperty("review_required", !automatic && group.reviewRequired()); + policy.addProperty("status", automatic ? "consolidated" + : group.hasExternal() ? "external_generation" + : group.reviewRequired() ? "review_required" : "separate"); + policy.add("candidates", candidates(group.candidates)); + return policy; + } + + private static JsonObject refreshPolicy(JsonObject previous, Group group) { + JsonObject policy = JsonCopies.copy(previous); + policy.addProperty("material", group.material.toString()); + policy.addProperty("domain", group.domain.toString()); + if (!MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE))) { + policy.addProperty("mode", MODE_SEPARATE); + } + policy.addProperty("output_mode", outputMode(policy)); + if (!policy.has("outputs") || !policy.get("outputs").isJsonObject()) { + JsonObject outputs = new JsonObject(); + for (Candidate candidate : group.outputs()) if (!candidate.external) { + outputs.addProperty(candidate.sourceId, 1.0D); + } + policy.add("outputs", outputs); + } + if (!policy.has("placement_sources") || !policy.get("placement_sources").isJsonObject()) { + policy.add("placement_sources", new JsonObject()); + } + List merged = readCandidates(previous, group); + policy.add("candidates", candidates(merged)); + String status = status(policy, merged); + policy.addProperty("status", status); + policy.addProperty("review_required", "review_required".equals(status) + || bool(previous, "review_required", false)); + return policy; + } + + private static String outputMode(JsonObject policy) { + String configured = string(policy, "output_mode", ""); + if (OUTPUT_BALANCED.equals(configured) || OUTPUT_SINGLE.equals(configured) + || OUTPUT_CUSTOM.equals(configured)) return configured; + Map outputs = decimalMap(policy, "outputs"); + if (outputs.size() <= 1) return OUTPUT_SINGLE; + double first = outputs.values().iterator().next(); + for (double value : outputs.values()) { + if (Double.compare(first, value) != 0) return OUTPUT_CUSTOM; + } + return OUTPUT_BALANCED; + } + + private static String status(JsonObject policy, List candidates) { + Map current = new HashMap<>(); + boolean external = false; + boolean review = false; + for (Candidate candidate : candidates) { + current.put(candidate.sourceId, candidate); + external |= candidate.external && candidate.loaded; + review |= candidate.reviewRequired; + } + Map outputs = stringMap(policy, "outputs"); + Map placements = stringMap(policy, "placement_sources"); + if (MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE)) + && (outputs.isEmpty() || placements.isEmpty())) return "missing_source"; + for (String selected : outputs.keySet()) { + Candidate candidate = current.get(selected); + if (candidate == null || !candidate.loaded) return "missing_source"; + } + for (String selected : placements.values()) { + Candidate candidate = current.get(selected); + if (candidate == null || !candidate.loaded || !candidate.active) return "missing_source"; + } + if (external) return "external_generation"; + if (review || bool(policy, "review_required", false)) return "review_required"; + return MODE_CONSOLIDATED.equals(string(policy, "mode", MODE_SEPARATE)) + ? "consolidated" : "separate"; + } + + private static List readCandidates(JsonObject policy, Group current) { + Map merged = new LinkedHashMap<>(); + if (policy.has("candidates") && policy.get("candidates").isJsonArray()) { + for (JsonElement element : policy.getAsJsonArray("candidates")) { + Candidate candidate = candidate(element, false); + if (candidate != null) merged.put(candidate.identity(), candidate); + } + } + if (current != null) { + for (Candidate candidate : current.candidates) merged.put(candidate.identity(), candidate); + } + List result = new ArrayList<>(); + Set currentIds = new LinkedHashSet<>(); + if (current != null) for (Candidate candidate : current.candidates) currentIds.add(candidate.identity()); + for (Candidate candidate : merged.values()) { + result.add(currentIds.contains(candidate.identity()) ? candidate : candidate.missing()); + } + result.sort(CANDIDATE_ORDER); + return Collections.unmodifiableList(result); + } + + private static JsonArray candidates(List values) { + JsonArray result = new JsonArray(); + List sorted = new ArrayList<>(values); + sorted.sort(CANDIDATE_ORDER); + for (Candidate candidate : sorted) result.add(candidate.toJson()); + return result; + } + + private static List immutableCandidates(List values) { + List copy = new ArrayList<>(values); + copy.sort(CANDIDATE_ORDER); + return Collections.unmodifiableList(copy); + } + + private static final Comparator CANDIDATE_ORDER = Comparator + .comparing((Candidate candidate) -> candidate.owner) + .thenComparing(candidate -> candidate.sourceId) + .thenComparing(candidate -> candidate.channel.toString()); + + private static Candidate candidate(JsonElement element, boolean defaultLoaded) { + if (element == null || !element.isJsonObject()) return null; + JsonObject json = element.getAsJsonObject(); + ResourceLocation block = resource(string(json, "registry_id", "")); + ResourceLocation material = resource(string(json, "material", "")); + ResourceLocation domain = resource(string(json, "domain", "")); + ResourceLocation channel = resource(string(json, "placement_channel", "")); + if (block == null || material == null || domain == null || channel == null) return null; + List names = strings(json.get("block_tags")); + return new Candidate(string(json, "source_id", ""), string(json, "owner", block.getNamespace()), + string(json, "owner_name", ""), string(json, "owner_version", ""), block, + integer(json, "metadata", 0), material, domain, channel, names, + bool(json, "loaded", defaultLoaded), bool(json, "placement_active", defaultLoaded), + bool(json, "external", false), bool(json, "enrichment", false), + bool(json, "review_required", false), bool(json, "material_declared", false)); + } + + private static boolean highConfidence(Group group) { + Map> tagsByOwner = new HashMap<>(); + for (Candidate candidate : group.configured()) { + if (!candidate.loaded || candidate.enrichment) continue; + tagsByOwner.computeIfAbsent(candidate.owner, ignored -> new LinkedHashSet<>()) + .addAll(candidate.blockTags); + } + return MmdOreConflictCatalog.automaticallyBalance(tagsByOwner); + } + + private static Candidate preferred(Group group) { + for (String owner : MmdOreConflictCatalog.priority(group.material.getPath())) { + for (Candidate candidate : group.configured()) if (owner.equals(candidate.owner)) return candidate; + } + for (Candidate candidate : group.configured()) { + if (MmdOreConflictCatalog.ordinary(candidate.owner)) return candidate; + } + return group.configured().isEmpty() ? null : group.configured().get(0); + } + + private static Candidate preferred(Group group, ResourceLocation channel) { + Candidate preferred = preferred(group); + if (preferred != null && channel.equals(preferred.channel)) return preferred; + for (Candidate candidate : group.configured()) if (channel.equals(candidate.channel)) return candidate; + return null; + } + + private static Role role(String owner) { + if (MmdOreConflictCatalog.enrichment(owner)) return Role.ENRICHMENT; + if ("basesciences".equals(owner)) return Role.NONE; + if (MmdOreConflictCatalog.netherOnly(owner)) return Role.NETHER; + if (MmdOreConflictCatalog.endOnly(owner)) return Role.END; + if (MmdOreConflictCatalog.ordinary(owner) || "minecraft".equals(owner) || "orespawn".equals(owner)) { + return Role.ORDINARY; + } + return Role.ORDINARY; + } + + private static Group group(Map groups, ResourceLocation material, ResourceLocation domain) { + String key = key(material, domain); + return groups.computeIfAbsent(key, ignored -> new Group(material, domain)); + } + + private static String owner(JsonObject ore, ResourceLocation block) { + String owner = string(ore, "source_provider", string(ore, "source_mod", block.getNamespace())); + return owner.trim().toLowerCase(Locale.ROOT); + } + + private static boolean loaded(String owner) { + if ("minecraft".equals(owner) || "orespawn".equals(owner)) return true; + try { return ModList.get().isLoaded(owner); } + catch (Throwable ignored) { return false; } + } + + private static String modName(String owner) { + return owner; + } + + private static String modVersion(String owner) { + return ""; + } + + private static List blockTags(Block block) { + List result = new ArrayList<>(); + for (Entry entry : tagMap().entrySet()) { + if (OreMaterialGroups.validTag(entry.getKey().toString()) + && tagMembers(entry.getValue()).contains(block)) result.add(entry.getKey().toString()); + } + Collections.sort(result); + return result; + } + + private static List safeBlockTagNames() { + List result = new ArrayList<>(); + for (ResourceLocation id : tagMap().keySet()) { + if (OreMaterialGroups.validTag(id.toString())) result.add(id.toString()); + } + Collections.sort(result); + return result; + } + + private static List safeTaggedBlocks(String name) { + Object tag = tagMap().get(new ResourceLocation(name)); + return tag == null ? Collections.emptyList() : new ArrayList<>(tagMembers(tag)); + } + + private static Map> tagMap() { + Map> result = new LinkedHashMap<>(); + net.minecraftforge.registries.tags.ITagManager tags = ForgeRegistries.BLOCKS.tags(); + if (tags != null) tags.getTagNames().forEach(key -> result.put(key.location(), key)); + return result; + } + + @SuppressWarnings("unchecked") + private static java.util.Collection tagMembers(Object tag) { + net.minecraftforge.registries.tags.ITagManager tags = ForgeRegistries.BLOCKS.tags(); + if (tags == null) return Collections.emptyList(); + List result = new ArrayList<>(); + tags.getTag((TagKey) tag).forEach(result::add); + return result; + } + + private static JsonObject object(JsonObject root, String key) { + if (root.has(key) && root.get(key).isJsonObject()) return root.getAsJsonObject(key); + JsonObject result = new JsonObject(); + root.add(key, result); + return result; + } + + private static ResourceLocation resource(String value) { + try { return value == null || value.trim().isEmpty() ? null : new ResourceLocation(value); } + catch (RuntimeException ignored) { return null; } + } + + private static Map stringMap(JsonObject root, String key) { + if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : root.getAsJsonObject(key).entrySet()) { + try { result.put(entry.getKey(), entry.getValue().getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static Map decimalMap(JsonObject root, String key) { + if (!root.has(key) || !root.get(key).isJsonObject()) return Collections.emptyMap(); + Map result = new LinkedHashMap<>(); + for (Entry entry : root.getAsJsonObject(key).entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return Collections.unmodifiableMap(result); + } + + private static List strings(JsonElement element) { + if (element == null || !element.isJsonArray()) return Collections.emptyList(); + List result = new ArrayList<>(); + for (JsonElement value : element.getAsJsonArray()) { + try { result.add(value.getAsString()); } + catch (RuntimeException ignored) { } + } + return Collections.unmodifiableList(result); + } + + private static String string(JsonObject root, String key, String fallback) { + try { return root.has(key) ? root.get(key).getAsString() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private static boolean bool(JsonObject root, String key, boolean fallback) { + try { return root.has(key) ? root.get(key).getAsBoolean() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private static int integer(JsonObject root, String key, int fallback) { + try { return root.has(key) ? root.get(key).getAsInt() : fallback; } + catch (RuntimeException ignored) { return fallback; } + } + + private enum Role { ORDINARY, NETHER, END, ENRICHMENT, NONE } + + static final class Inference { + final ResourceLocation material; + final boolean reviewRequired; + final List names; + + Inference(ResourceLocation material, boolean reviewRequired, List names) { + this.material = material; + this.reviewRequired = reviewRequired; + this.names = Collections.unmodifiableList(new ArrayList<>(names)); + } + } + + static final class Snapshot { + private final List groups; + + Snapshot(List groups) { + this.groups = Collections.unmodifiableList(new ArrayList<>(groups)); + } + + List groups() { return groups; } + } + + static final class GroupView { + final String key; + final ResourceLocation material; + final ResourceLocation domain; + final String displayName; + final List blockTagEntries; + final boolean curated; + final String mode; + final String outputMode; + final String status; + final List candidates; + final Map outputs; + final Map placements; + + GroupView(String key, ResourceLocation material, ResourceLocation domain, + String displayName, List blockTagEntries, boolean curated, + String mode, String outputMode, String status, List candidates, Map outputs, + Map placements) { + this.key = key; + this.material = material; + this.domain = domain; + this.displayName = displayName; + this.blockTagEntries = Collections.unmodifiableList(new ArrayList<>(blockTagEntries)); + this.curated = curated; + this.mode = mode; + this.outputMode = outputMode; + this.status = status; + this.candidates = Collections.unmodifiableList(new ArrayList<>(candidates)); + this.outputs = Collections.unmodifiableMap(new LinkedHashMap<>(outputs)); + this.placements = Collections.unmodifiableMap(new LinkedHashMap<>(placements)); + } + } + + static final class Candidate { + final String sourceId; + final String owner; + final String ownerName; + final String ownerVersion; + final ResourceLocation block; + final int metadata; + final ResourceLocation material; + final ResourceLocation domain; + final ResourceLocation channel; + final List blockTags; + final boolean loaded; + final boolean active; + final boolean external; + final boolean enrichment; + final boolean reviewRequired; + final boolean materialDeclared; + + Candidate(String sourceId, String owner, String ownerName, String ownerVersion, + ResourceLocation block, int metadata, ResourceLocation material, ResourceLocation domain, + ResourceLocation channel, List blockTags, boolean loaded, boolean active, + boolean external, boolean enrichment, boolean reviewRequired, boolean materialDeclared) { + this.sourceId = sourceId; + this.owner = owner; + this.ownerName = ownerName; + this.ownerVersion = ownerVersion; + this.block = block; + this.metadata = metadata; + this.material = material; + this.domain = domain; + this.channel = channel; + this.blockTags = Collections.unmodifiableList(new ArrayList<>(blockTags)); + this.loaded = loaded; + this.active = active; + this.external = external; + this.enrichment = enrichment; + this.reviewRequired = reviewRequired; + this.materialDeclared = materialDeclared; + } + + String identity() { return sourceId + '|' + channel.toString(); } + + String outputIdentity() { return block.toString() + '|' + metadata; } + + Candidate missing() { + return new Candidate(sourceId, owner, ownerName, ownerVersion, block, metadata, material, + domain, channel, blockTags, false, false, external, enrichment, reviewRequired, + materialDeclared); + } + + JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("source_id", sourceId); + json.addProperty("owner", owner); + json.addProperty("owner_name", ownerName); + json.addProperty("owner_version", ownerVersion); + json.addProperty("registry_id", block.toString()); + json.addProperty("metadata", metadata); + json.addProperty("material", material.toString()); + json.addProperty("domain", domain.toString()); + json.addProperty("placement_channel", channel.toString()); + JsonArray tags = new JsonArray(); + for (String name : blockTags) tags.add(new JsonPrimitive(name)); + json.add("block_tags", tags); + json.addProperty("loaded", loaded); + json.addProperty("placement_active", active); + json.addProperty("external", external); + json.addProperty("enrichment", enrichment); + json.addProperty("review_required", reviewRequired); + json.addProperty("material_declared", materialDeclared); + return json; + } + } + + static final class Group { + final ResourceLocation material; + final ResourceLocation domain; + final List candidates = new ArrayList<>(); + + Group(ResourceLocation material, ResourceLocation domain) { + this.material = material; + this.domain = domain; + } + + void add(Candidate candidate) { + for (Candidate value : candidates) if (value.identity().equals(candidate.identity())) return; + candidates.add(candidate); + } + + boolean hasBlock(ResourceLocation block, int metadata) { + for (Candidate value : candidates) { + if (value.block.equals(block) && value.metadata == metadata) return true; + } + return false; + } + + List managed() { + List result = new ArrayList<>(); + for (Candidate value : candidates) if (!value.external && !value.enrichment) result.add(value); + result.sort(CANDIDATE_ORDER); + return result; + } + + List configured() { + List result = new ArrayList<>(); + for (Candidate value : managed()) if (value.active) result.add(value); + return result; + } + + List outputs() { + Map unique = new LinkedHashMap<>(); + for (Candidate value : candidates) { + if (value.enrichment || !value.loaded) continue; + String key = value.outputIdentity(); + Candidate previous = unique.get(key); + if (previous == null || (!previous.active && value.active) + || (previous.external && !value.external)) unique.put(key, value); + } + List result = new ArrayList<>(unique.values()); + result.sort(CANDIDATE_ORDER); + return result; + } + + Set channels() { + Set result = new LinkedHashSet<>(); + for (Candidate value : configured()) result.add(value.channel); + return result; + } + + boolean hasExternal() { + for (Candidate value : candidates) if (value.external) return true; + return false; + } + + boolean reviewRequired() { + for (Candidate value : candidates) if (value.reviewRequired) return true; + return hasManagedConflict() && !highConfidence(this); + } + + private boolean hasManagedConflict() { + List managed = managed(); + Set outputs = new LinkedHashSet<>(); + Map> ownersByChannel = new LinkedHashMap<>(); + for (Candidate value : managed) { + outputs.add(value.outputIdentity()); + if (value.active) ownersByChannel.computeIfAbsent(value.channel, + ignored -> new LinkedHashSet<>()).add(value.owner); + } + if (outputs.size() > 1) return true; + for (Set owners : ownersByChannel.values()) if (owners.size() > 1) return true; + return false; + } + + } +} diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java index 7474e38a..1823c7aa 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGeneration.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Map; import java.util.Map.Entry; +import java.util.Optional; import java.util.Random; import java.util.Set; @@ -17,7 +18,10 @@ import com.google.gson.JsonObject; import zone.moddev.mc.orespawn.OreSpawn; import zone.moddev.mc.orespawn.api.CompiledOrePattern; +import zone.moddev.mc.orespawn.api.GeologySampler; +import zone.moddev.mc.orespawn.api.OreGenerationContext; import zone.moddev.mc.orespawn.api.OreDimensionSelector; +import zone.moddev.mc.orespawn.api.OreSpawnApi; import zone.moddev.mc.orespawn.api.OrePlacementContext; import zone.moddev.mc.orespawn.init.OreSpawnPatterns; @@ -117,6 +121,7 @@ static boolean takesOverVanillaOre(ResourceKey dimension, Block output) { @Override public boolean place(FeaturePlaceContext context) { + WorldGeologyProfileManager.resolveFreshPoliciesBeforeGeneration(); WorldGenLevel world = context.level(); ResourceKey dimension = world.getLevel().dimension(); BakedOre[] ores = oresForDimension(dimension); @@ -177,7 +182,8 @@ private static boolean generateChunk(WorldGenLevel world, ChunkAccess chunk, Hol } int attempts = attemptsForFrequency(random, frequency); for (int attempt = 0; attempt < attempts; attempt++) { - changed |= placeAttempt(world, chunk, random, ore, geome, scratch); + changed |= placeAttempt(world, chunk, random, ore, geome, + dimension, worldSeed, attempt, scratch); } } if (changed) { @@ -196,7 +202,7 @@ private static long mix(long seed, long chunk, long revision) { } private static boolean placeAttempt(WorldGenLevel world, ChunkAccess chunk, Random random, - BakedOre ore, int geome, + BakedOre ore, int geome, ResourceKey dimension, long worldSeed, int attempt, GenerationScratch scratch) { int minY = Math.max(ore.minY, chunk.getMinBuildHeight()); int maxY = Math.min(ore.maxY, chunk.getMaxBuildHeight() - 1); @@ -207,7 +213,11 @@ private static boolean placeAttempt(WorldGenLevel world, ChunkAccess chunk, Rand int y = ore.heightDistribution.sample(random, minY, maxY); int z = chunk.getPos().getMinBlockZ() + random.nextInt(16); int quantity = sampleQuantity(random, ore.minQuantity, ore.maxQuantity); - scratch.patternContext.initialize(world, chunk, random, ore, geome, x, y, z, minY, maxY, + scratch.patternContext.initialize(world, chunk, random, ore, geome, + dimension.location(), worldSeed, + ore.arbitrationSources.length == 0 ? null + : ore.outputSource(worldSeed, chunk.getPos().toLong(), attempt), + x, y, z, minY, maxY, quantity); return ore.pattern.place(scratch.patternContext); } @@ -246,21 +256,17 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { boolean manageVanillaOres = bool(profile, "manage_vanilla_ores", false); Map, Set> resolvedTags = new HashMap<>(); List rules = new ArrayList<>(); + Map outputBundles = new LinkedHashMap<>(); Set> explicitDimensions = new HashSet<>(); for (Entry oreEntry : profile.getAsJsonObject("ores").entrySet()) { if (!oreEntry.getValue().isJsonObject()) { continue; } JsonObject oreJson = oreEntry.getValue().getAsJsonObject(); - if (!bool(oreJson, "enabled", true)) { - continue; - } + boolean oreEnabled = bool(oreJson, "enabled", true); boolean nativeGeneration = bool(oreJson, "native_generation", false); boolean retrogen = bool(oreJson, "retrogen", true); boolean suppressVanilla = nativeGeneration || bool(oreJson, "suppress_vanilla", false); - if (nativeGeneration && !manageVanillaOres) { - continue; - } ResourceLocation oreId = resource(string(oreJson, "block", oreEntry.getKey())); Block output = oreId == null ? null : ForgeRegistries.BLOCKS.getValue(oreId); JsonObject dimensions = objectOrEmpty(oreJson, "dimensions"); @@ -277,6 +283,12 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { } int deepOutputMaxY = integer(oreJson, "deep_output_max_y", -1); BakedOutput[] outputs = bakeOutputs(oreJson, output.defaultBlockState()); + ResourceLocation ruleId = resource(oreEntry.getKey()); + if (ruleId == null) ruleId = oreId; + outputBundles.put(ruleId.toString(), new BakedOutputBundle( + output.defaultBlockState(), deepOutput, deepOutputMaxY, outputs)); + if (!oreEnabled || (nativeGeneration && !manageVanillaOres)) continue; + ResourceLocation material = OreSourcePolicies.material(profile, oreJson, output); BakedOreRule rule = new BakedOreRule(output, manageVanillaOres && suppressVanilla); for (Entry dimensionEntry : dimensions.entrySet()) { @@ -296,8 +308,11 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (!bool(dimension, "enabled", true)) { continue; } - BakedOre baked = bakeOre(output.defaultBlockState(), deepOutput, deepOutputMaxY, outputs, - dimension, config, resolvedTags, retrogen); + BakedGeomeConfig dimensionConfig = GeomeConfig.baked(dimensionKey); + if (dimensionConfig == null) dimensionConfig = config; + BakedOre baked = bakeOre(ruleId, material, dimensionId, + output.defaultBlockState(), deepOutput, deepOutputMaxY, outputs, + dimension, dimensionConfig, resolvedTags, retrogen); if (baked != null) { rule.explicit.put(dimensionKey, baked); } else { @@ -316,7 +331,8 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (!selectorEntry.getValue().isJsonObject()) continue; JsonObject selector = selectorEntry.getValue().getAsJsonObject(); if (!bool(selector, "enabled", true)) continue; - rule.selector = bakeOre(output.defaultBlockState(), deepOutput, deepOutputMaxY, outputs, + rule.selector = bakeOre(ruleId, material, selectorId, + output.defaultBlockState(), deepOutput, deepOutputMaxY, outputs, selector, config, resolvedTags, retrogen); if (rule.selector == null) { reportBakeProblem("Ignoring invalid selector rule for OreSpawn-managed ore '{}'", @@ -339,6 +355,7 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { if (rule.suppressVanilla) suppressed.add(rule.output); } } + combined = arbitrate(profile, combined, outputBundles); result.put(dimension, combined.toArray(new BakedOre[combined.size()])); vanillaOutputs.put(dimension, Collections.unmodifiableSet(suppressed)); } @@ -349,6 +366,7 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { selectorList.add(rule.selector); if (rule.suppressVanilla) selectorOutputs.add(rule.output); } + selectorList = arbitrate(profile, selectorList, outputBundles); BakedOre[] selectorResult = selectorList.toArray(new BakedOre[selectorList.size()]); LOGGER.info("Baked {} OreSpawn-managed ore definitions across {} dimensions", rules.size(), result.size()); @@ -361,6 +379,86 @@ private static BakedOres bakeOres(JsonObject profile, BakedGeomeConfig config) { Collections.unmodifiableSet(selectorOutputs)); } + private static List arbitrate(JsonObject profile, List original, + Map outputBundles) { + if (!profile.has(OreSourcePolicies.SECTION) + || !profile.get(OreSourcePolicies.SECTION).isJsonObject()) return original; + JsonObject policies = profile.getAsJsonObject(OreSourcePolicies.SECTION); + Map> groups = new LinkedHashMap<>(); + for (BakedOre ore : original) { + if (ore.material != null) groups.computeIfAbsent( + OreSourcePolicies.key(ore.material, ore.policyDomain), ignored -> new ArrayList<>()).add(ore); + } + Map> replacements = new HashMap<>(); + for (Entry> entry : groups.entrySet()) { + String key = entry.getKey(); + if (!policies.has(key) || !policies.get(key).isJsonObject()) continue; + JsonObject policy = policies.getAsJsonObject(key); + if (!"consolidated".equals(string(policy, "mode", "keep_separate"))) continue; + List sources = new ArrayList<>(); + for (Entry output : positiveWeights(objectOrEmpty(policy, "outputs")).entrySet()) { + BakedOutputBundle bundle = outputBundles.get(output.getKey()); + if (bundle == null) bundle = candidateBundle(policy, output.getKey()); + if (bundle != null) sources.add(new BakedSource(bundle, output.getValue())); + } + Map available = new HashMap<>(); + for (BakedOre ore : entry.getValue()) available.put(ore.ruleId.toString(), ore); + List selected = new ArrayList<>(); + for (Entry placement : objectOrEmpty(policy, "placement_sources").entrySet()) { + ResourceLocation channel = resource(placement.getKey()); + String sourceId; + try { sourceId = placement.getValue().getAsString(); } + catch (RuntimeException invalid) { continue; } + BakedOre ore = available.get(sourceId); + if (ore != null && channel != null && channel.equals(ore.channel) && !sources.isEmpty()) { + selected.add(ore.withSources(sources)); + } + } + replacements.put(key, selected); + } + if (replacements.isEmpty()) return original; + List result = new ArrayList<>(); + Set emitted = new HashSet<>(); + for (BakedOre ore : original) { + String key = ore.material == null ? null + : OreSourcePolicies.key(ore.material, ore.policyDomain); + List selected = key == null ? null : replacements.get(key); + if (selected == null) result.add(ore); + else if (emitted.add(key)) result.addAll(selected); + } + return result; + } + + private static Map positiveWeights(JsonObject values) { + Map result = new LinkedHashMap<>(); + for (Entry entry : values.entrySet()) { + try { + double value = entry.getValue().getAsDouble(); + if (Double.isFinite(value) && value > 0.0D) result.put(entry.getKey(), value); + } catch (RuntimeException ignored) { } + } + return result; + } + + private static BakedOutputBundle candidateBundle(JsonObject policy, String sourceId) { + if (!policy.has("candidates") || !policy.get("candidates").isJsonArray()) return null; + for (JsonElement element : policy.getAsJsonArray("candidates")) { + if (!element.isJsonObject()) continue; + JsonObject candidate = element.getAsJsonObject(); + if (!sourceId.equals(string(candidate, "source_id", "")) + || !bool(candidate, "loaded", false) + || bool(candidate, "enrichment", false)) continue; + ResourceLocation id = resource(string(candidate, "registry_id", "")); + Block block = id == null ? null : ForgeRegistries.BLOCKS.getValue(id); + if (block == null || block == Blocks.AIR) return null; + BlockState state = block.defaultBlockState(); + return new BakedOutputBundle(state, state, -1, + new BakedOutput[] { new BakedOutput(state, 1.0D, + Integer.MIN_VALUE, Integer.MAX_VALUE) }); + } + return null; + } + private static void reportBakeProblem(String message, Object... arguments) { // Loading invokes provisional bakes before the server's registries and // data-pack tags are authoritative. Keep diagnostics available at debug @@ -381,7 +479,8 @@ private static JsonObject objectOrEmpty(JsonObject root, String key) { return root.has(key) && root.get(key).isJsonObject() ? root.getAsJsonObject(key) : new JsonObject(); } - private static BakedOre bakeOre(BlockState output, BlockState deepOutput, int deepOutputMaxY, + private static BakedOre bakeOre(ResourceLocation ruleId, ResourceLocation material, + ResourceLocation domain, BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, JsonObject json, BakedGeomeConfig config, Map, Set> resolvedTags, boolean retrogen) { @@ -443,7 +542,8 @@ private static BakedOre bakeOre(BlockState output, BlockState deepOutput, int de Set> includedBiomes = resolveBiomes(json, "biome_ids", "biome_dictionary"); Set> excludedBiomes = resolveBiomes(json, "excluded_biome_ids", "excluded_biome_dictionary"); - return new BakedOre(output, deepOutput, deepOutputMaxY, outputs, + return new BakedOre(ruleId, material, domain, + OreSourcePolicies.placementChannel(json), output, deepOutput, deepOutputMaxY, outputs, minY, maxY, Math.min(64.0D, frequency), minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, spread, verticalSpread, nodeSize, @@ -623,6 +723,10 @@ private static BlockState blockState(JsonObject json, String key, BlockState fal } private static final class BakedOre { + final ResourceLocation ruleId; + final ResourceLocation material; + final ResourceLocation policyDomain; + final ResourceLocation channel; final BlockState output; final BlockState deepOutput; final int deepOutputMaxY; @@ -644,8 +748,12 @@ private static final class BakedOre { final Set> includedBiomes; final Set> excludedBiomes; final boolean retrogen; + final BakedSource[] arbitrationSources; + final long sourceSalt; - BakedOre(BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, + BakedOre(ResourceLocation ruleId, ResourceLocation material, + ResourceLocation policyDomain, ResourceLocation channel, + BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, int minY, int maxY, double frequency, int minQuantity, int maxQuantity, CompiledOrePattern pattern, OreHeightDistribution heightDistribution, double discardChanceOnAirExposure, @@ -653,6 +761,28 @@ private static final class BakedOre { Map hostBlocks, int familyMask, double[] geomeWeights, Set> includedBiomes, Set> excludedBiomes, boolean retrogen) { + this(ruleId, material, policyDomain, channel, output, deepOutput, deepOutputMaxY, outputs, + minY, maxY, frequency, minQuantity, maxQuantity, pattern, heightDistribution, + discardChanceOnAirExposure, spread, verticalSpread, nodeSize, hostBlocks, + familyMask, geomeWeights, includedBiomes, excludedBiomes, retrogen, + new BakedSource[0]); + } + + private BakedOre(ResourceLocation ruleId, ResourceLocation material, + ResourceLocation policyDomain, ResourceLocation channel, + BlockState output, BlockState deepOutput, int deepOutputMaxY, BakedOutput[] outputs, + int minY, int maxY, double frequency, int minQuantity, int maxQuantity, + CompiledOrePattern pattern, OreHeightDistribution heightDistribution, + double discardChanceOnAirExposure, + int spread, int verticalSpread, int nodeSize, + Map hostBlocks, int familyMask, double[] geomeWeights, + Set> includedBiomes, + Set> excludedBiomes, boolean retrogen, + BakedSource[] arbitrationSources) { + this.ruleId = ruleId; + this.material = material; + this.policyDomain = policyDomain; + this.channel = channel; this.output = output; this.deepOutput = deepOutput; this.deepOutputMaxY = deepOutputMaxY; @@ -674,6 +804,31 @@ private static final class BakedOre { this.includedBiomes = includedBiomes; this.excludedBiomes = excludedBiomes; this.retrogen = retrogen; + this.arbitrationSources = arbitrationSources; + this.sourceSalt = material == null ? 0L + : ((long) material.hashCode() << 32) ^ channel.hashCode(); + } + + BakedOre withSources(List sources) { + return new BakedOre(ruleId, material, policyDomain, channel, + output, deepOutput, deepOutputMaxY, outputs, minY, maxY, frequency, + minQuantity, maxQuantity, pattern, heightDistribution, discardChanceOnAirExposure, + spread, verticalSpread, nodeSize, hostBlocks, familyMask, geomeWeights, + includedBiomes, excludedBiomes, retrogen, + sources.toArray(new BakedSource[sources.size()])); + } + + BakedOutputBundle outputSource(long worldSeed, long chunkIdentity, int attempt) { + if (arbitrationSources.length == 0) return null; + double total = 0.0D; + for (BakedSource source : arbitrationSources) total += source.weight; + long sample = mix(worldSeed ^ sourceSalt, chunkIdentity, attempt); + double choice = (sample >>> 11) * 0x1.0p-53 * total; + for (BakedSource source : arbitrationSources) { + choice -= source.weight; + if (choice < 0.0D) return source.bundle; + } + return arbitrationSources[arbitrationSources.length - 1].bundle; } BlockState outputAt(int y, Random random) { @@ -718,6 +873,45 @@ private static final class BakedOreRule { } } + private static final class BakedSource { + final BakedOutputBundle bundle; + final double weight; + + BakedSource(BakedOutputBundle bundle, double weight) { + this.bundle = bundle; + this.weight = weight; + } + } + + private static final class BakedOutputBundle { + final BlockState output; + final BlockState deepOutput; + final int deepOutputMaxY; + final BakedOutput[] outputs; + + BakedOutputBundle(BlockState output, BlockState deepOutput, int deepOutputMaxY, + BakedOutput[] outputs) { + this.output = output; + this.deepOutput = deepOutput; + this.deepOutputMaxY = deepOutputMaxY; + this.outputs = outputs; + } + + BlockState outputAt(int y, Random random) { + if (y <= deepOutputMaxY) return deepOutput; + double total = 0.0D; + for (BakedOutput candidate : outputs) if (candidate.acceptsY(y)) total += candidate.weight; + if (total <= 0.0D) return output; + double choice = random.nextDouble() * total; + for (BakedOutput candidate : outputs) { + if (!candidate.acceptsY(y)) continue; + choice -= candidate.weight; + if (choice <= 0.0D) return candidate.state; + } + return output; + } + } + private static final class BakedOutput { final BlockState state; final double weight; @@ -768,13 +962,24 @@ private static final class GenerationScratch { } } - private static final class PatternContext implements OrePlacementContext { + private static final class PatternContext implements OreGenerationContext { private final BlockPos.MutableBlockPos cursor; private final BlockPos.MutableBlockPos airCursor = new BlockPos.MutableBlockPos(); private WorldGenLevel world; private ChunkAccess chunk; private Random random; private BakedOre ore; + private BakedOutputBundle selectedOutput; + private BakedOutputBundle identifiedOutput; + private long identifiedOutputIdentity; + private boolean hasIdentifiedOutput; + private ResourceLocation dimension; + private long worldSeed; + private int chunkX; + private int chunkZ; + private net.minecraft.server.MinecraftServer samplerServer; + private ResourceLocation samplerDimension; + private Optional sampler = Optional.empty(); private int geome; private int originX; private int originY; @@ -788,11 +993,19 @@ private static final class PatternContext implements OrePlacementContext { } void initialize(WorldGenLevel world, ChunkAccess chunk, Random random, BakedOre ore, int geome, + ResourceLocation dimension, long worldSeed, BakedOutputBundle selectedOutput, int originX, int originY, int originZ, int minY, int maxY, int quantity) { this.world = world; this.chunk = chunk; this.random = random; this.ore = ore; + this.selectedOutput = selectedOutput; + this.identifiedOutput = null; + this.hasIdentifiedOutput = false; + this.dimension = dimension; + this.worldSeed = worldSeed; + this.chunkX = chunk.getPos().x; + this.chunkZ = chunk.getPos().z; this.geome = geome; this.originX = originX; this.originY = originY; @@ -812,6 +1025,27 @@ void initialize(WorldGenLevel world, ChunkAccess chunk, Random random, BakedOre @Override public int spread() { return ore.spread; } @Override public int verticalSpread() { return ore.verticalSpread; } @Override public int nodeSize() { return ore.nodeSize; } + @Override public long worldSeed() { return worldSeed; } + @Override public ResourceLocation dimension() { return dimension; } + @Override public int chunkX() { return chunkX; } + @Override public int chunkZ() { return chunkZ; } + @Override public Optional geologySampler() { + net.minecraft.server.MinecraftServer server = WorldGeologyProfileManager.activeServer(); + if (server != samplerServer || !dimension.equals(samplerDimension)) { + samplerServer = server; + samplerDimension = dimension; + sampler = Optional.empty(); + if (server != null) { + for (ServerLevel level : server.getAllLevels()) { + if (dimension.equals(level.dimension().location())) { + sampler = OreSpawnApi.createSampler(level); + break; + } + } + } + } + return sampler; + } @Override public boolean inside(int x, int y, int z) { @@ -832,6 +1066,35 @@ public boolean isFluid(int x, int y, int z, Fluid fluid) { @Override public boolean tryPlace(int x, int y, int z) { + // The ordinary pattern path runs for every candidate block in a vein. + if (!inside(x, y, z)) return false; + cursor.set(x, y, z); + BlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor); + if (!ore.accepts(existing, random, geomeConfig)) return false; + if (ore.discardChanceOnAirExposure > 0.0D + && random.nextDouble() < ore.discardChanceOnAirExposure + && isAdjacentToAir(x, y, z)) { + return false; + } + BlockState output = selectedOutput == null + ? ore.outputAt(y, random) : selectedOutput.outputAt(y, random); + if (world == null) chunk.setBlockState(cursor, output, false); + else world.setBlock(cursor, output, 2); + return true; + } + + @Override + public boolean tryPlace(int x, int y, int z, long outputIdentity) { + if (!hasIdentifiedOutput || identifiedOutputIdentity != outputIdentity) { + identifiedOutputIdentity = outputIdentity; + identifiedOutput = ore.outputSource(worldSeed, outputIdentity, 0); + hasIdentifiedOutput = true; + } + return tryPlaceWithOutput(x, y, z, identifiedOutput); + } + + private boolean tryPlaceWithOutput(int x, int y, int z, + BakedOutputBundle outputSource) { if (!inside(x, y, z)) return false; cursor.set(x, y, z); BlockState existing = world == null ? chunk.getBlockState(cursor) : world.getBlockState(cursor); @@ -841,7 +1104,8 @@ public boolean tryPlace(int x, int y, int z) { && isAdjacentToAir(x, y, z)) { return false; } - BlockState output = ore.outputAt(y, random); + BlockState output = outputSource == null + ? ore.outputAt(y, random) : outputSource.outputAt(y, random); if (world == null) chunk.setBlockState(cursor, output, false); else world.setBlock(cursor, output, 2); return true; diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java index 015823c3..ed97102e 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGate.java @@ -2,6 +2,7 @@ import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.IdentityHashMap; import java.util.List; import java.util.Map; @@ -15,12 +16,14 @@ import net.minecraft.resources.ResourceKey; import net.minecraft.resources.ResourceLocation; import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.levelgen.GenerationStep; import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; import net.minecraft.world.level.levelgen.feature.Feature; import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext; import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; import net.minecraft.world.level.levelgen.feature.configurations.FeatureConfiguration; +import net.minecraft.world.level.levelgen.feature.configurations.OreConfiguration; import net.minecraft.world.level.levelgen.placement.PlacedFeature; import net.minecraftforge.event.world.BiomeLoadingEvent; import net.minecraftforge.registries.IForgeRegistry; @@ -41,6 +44,7 @@ public final class VanillaOreFeatureGate { private static final SuppressibleGateFeature SUPPRESSIBLE_FEATURE = new SuppressibleGateFeature(); private static Gate[] gates = new Gate[0]; private static Map> suppressibleGates = new LinkedHashMap<>(); + private static final Map> inlineGates = new IdentityHashMap<>(); private VanillaOreFeatureGate() { } @@ -105,6 +109,9 @@ static boolean wrapFeatureList(List> features) { if (!wrapped) { ResourceLocation id = BuiltinRegistries.PLACED_FEATURE.getKey(feature.value()); Holder replacement = id == null ? null : suppressibleGates.get(id); + if (id == null && nativeOutput(feature.value().feature().value()) != null) { + replacement = inlineGate(feature.value()); + } if (replacement != null) { features.set(featureIndex, replacement); changed = true; @@ -114,6 +121,17 @@ static boolean wrapFeatureList(List> features) { return changed; } + private static synchronized Holder inlineGate(PlacedFeature original) { + // One identity across biomes preserves the sorter's decoration indices. + return inlineGates.computeIfAbsent(original, feature -> Holder.direct(new PlacedFeature( + Holder.direct(new ConfiguredFeature<>(SUPPRESSIBLE_FEATURE, + new SuppressibleConfig(feature.feature()))), feature.placement()))); + } + + static synchronized void clearInlineGates() { + inlineGates.clear(); + } + private static void registerSuppressibleOreGates() { Map> registered = new LinkedHashMap<>(); List, PlacedFeature>> candidates = @@ -149,6 +167,37 @@ private static boolean isStandardOreFeature(PlacedFeature placed) { || configured.feature() == Feature.SCATTERED_ORE); } + static Block nativeOutput(ConfiguredFeature feature) { + Block output = null; + for (ConfiguredFeature child : (Iterable>) feature.getFeatures()::iterator) { + if ((child.feature() != Feature.ORE && child.feature() != Feature.SCATTERED_ORE) + || !(child.config() instanceof OreConfiguration)) return null; + OreConfiguration config = (OreConfiguration) child.config(); + if (config.targetStates.isEmpty()) return null; + for (OreConfiguration.TargetBlockState target : config.targetStates) { + Block candidate = vanillaOreFamily(target.state.getBlock()); + if (candidate == null || (output != null && output != candidate)) return null; + output = candidate; + } + } + return output; + } + + private static Block vanillaOreFamily(Block block) { + // Stone and deepslate variants share one managed rule on this target. + if (block == Blocks.COAL_ORE || block == Blocks.DEEPSLATE_COAL_ORE) return Blocks.COAL_ORE; + if (block == Blocks.IRON_ORE || block == Blocks.DEEPSLATE_IRON_ORE) return Blocks.IRON_ORE; + if (block == Blocks.COPPER_ORE || block == Blocks.DEEPSLATE_COPPER_ORE) return Blocks.COPPER_ORE; + if (block == Blocks.GOLD_ORE || block == Blocks.DEEPSLATE_GOLD_ORE) return Blocks.GOLD_ORE; + if (block == Blocks.REDSTONE_ORE || block == Blocks.DEEPSLATE_REDSTONE_ORE) return Blocks.REDSTONE_ORE; + if (block == Blocks.DIAMOND_ORE || block == Blocks.DEEPSLATE_DIAMOND_ORE) return Blocks.DIAMOND_ORE; + if (block == Blocks.LAPIS_ORE || block == Blocks.DEEPSLATE_LAPIS_ORE) return Blocks.LAPIS_ORE; + if (block == Blocks.EMERALD_ORE || block == Blocks.DEEPSLATE_EMERALD_ORE) return Blocks.EMERALD_ORE; + if (block == Blocks.NETHER_GOLD_ORE || block == Blocks.NETHER_QUARTZ_ORE + || block == Blocks.ANCIENT_DEBRIS) return block; + return null; + } + private static Definition[] definitions() { return new Definition[] { definition("ore_coal_upper", "coal_ore"), @@ -208,6 +257,7 @@ void initialize(Holder> original, Block output) { @Override public boolean place(FeaturePlaceContext context) { + WorldGeologyProfileManager.resolveFreshPoliciesBeforeGeneration(); if (WorldGeologyProfileManager.activeProfile().suppressAllOreFeatures()) { return false; } @@ -226,9 +276,11 @@ private static final class SuppressibleConfig implements FeatureConfiguration { .xmap(SuppressibleConfig::new, value -> value.delegate) .codec(); final Holder> delegate; + final Block nativeOutput; SuppressibleConfig(Holder> delegate) { this.delegate = delegate; + this.nativeOutput = VanillaOreFeatureGate.nativeOutput(delegate.value()); } } @@ -240,7 +292,11 @@ private static final class SuppressibleGateFeature extends Feature context) { + WorldGeologyProfileManager.resolveFreshPoliciesBeforeGeneration(); if (WorldGeologyProfileManager.activeProfile().suppressAllOreFeatures()) return false; + Block output = context.config().nativeOutput; + if (output != null && OreSpawnOreGeneration.takesOverVanillaOre( + context.level().getLevel().dimension(), output)) return false; return context.config().delegate.value().place(context.level(), context.chunkGenerator(), context.random(), context.origin()); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java index f6301ac6..3b0d1103 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfile.java @@ -8,6 +8,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; import zone.moddev.mc.orespawn.OreSpawnConfig.GeologyMode; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Algorithm; import zone.moddev.mc.orespawn.worldgen.FormationSettings.Preset; @@ -20,7 +21,7 @@ /** A complete, self-contained snapshot of the geology settings for one world. */ public final class WorldGeologyProfile { - public static final int SCHEMA_VERSION = 5; + public static final int SCHEMA_VERSION = 7; private static final Logger LOGGER = LogManager.getLogger(); @@ -37,6 +38,11 @@ public final class WorldGeologyProfile { private WorldGeologyProfile(JsonObject root, GeologyMode fallbackMode, boolean fallbackFluidDeposits) { this.root = root.deepCopy(); FluidDepositMigration.normalize(this.root); + OreMaterialGroups.initialize(this.root); + if (!this.root.has(OreSourcePolicies.SECTION) + || !this.root.get(OreSourcePolicies.SECTION).isJsonObject()) { + this.root.add(OreSourcePolicies.SECTION, new JsonObject()); + } this.root.addProperty("schema_version", SCHEMA_VERSION); geologyMode = enumValue(this.root, "geology_mode", GeologyMode.class, fallbackMode); placeFluidDeposits = booleanValue(this.root, "place_fluid_deposits", fallbackFluidDeposits); @@ -64,7 +70,8 @@ public static WorldGeologyProfile recommended(boolean placeFluidDeposits) { public static WorldGeologyProfile fromGlobalConfig(JsonObject globalRoot, GeologyMode geologyMode, boolean placeFluidDeposits) { - JsonObject root = globalRoot.deepCopy(); + JsonObject root = JsonCopies.copy(globalRoot); + OreSourcePolicies.initialize(root, false); if (!root.has("geology_mode")) { root.addProperty("geology_mode", geologyMode.name().toLowerCase(Locale.ROOT)); } @@ -79,14 +86,15 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile if (schema >= SCHEMA_VERSION) { return new WorldGeologyProfile(json, fallback.geologyMode, fallback.placeFluidDeposits); } - if (schema == 2 || schema == 3 || schema == 4) { - JsonObject migrated = json.deepCopy(); + if (schema >= 2) { + JsonObject migrated = JsonCopies.copy(json); for (String key : new String[] { "terrain_dimensions", "providers", - "biome_palettes", "dimension_materials" }) { + "biome_palettes", "dimension_materials", OreMaterialGroups.SECTION }) { if (!migrated.has(key) && fallback.root.has(key)) { migrated.add(key, fallback.root.get(key).deepCopy()); } } + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } @@ -96,6 +104,9 @@ public static WorldGeologyProfile fromJson(JsonObject json, WorldGeologyProfile copyIfPresent(json, migrated, "geology_mode"); copyIfPresent(json, migrated, "place_crude_oil"); copyIfPresent(json, migrated, "formations"); + // An existing world must not inherit a new install's automatic choices. + migrated.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(migrated, true); return new WorldGeologyProfile(migrated, fallback.geologyMode, fallback.placeFluidDeposits); } diff --git a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java index bc897ff9..4af8e88b 100644 --- a/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java +++ b/src/main/java/zone/moddev/mc/orespawn/worldgen/WorldGeologyProfileManager.java @@ -9,6 +9,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardCopyOption; +import java.util.LinkedHashMap; +import java.util.Map; import com.google.gson.Gson; import com.google.gson.GsonBuilder; @@ -25,6 +27,7 @@ import net.minecraft.server.MinecraftServer; import net.minecraft.world.level.storage.LevelResource; import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.event.server.ServerStartingEvent; import net.minecraftforge.event.server.ServerStoppedEvent; import net.minecraftforge.fml.loading.FMLPaths; import net.minecraftforge.event.world.WorldEvent; @@ -42,6 +45,8 @@ public final class WorldGeologyProfileManager { private static volatile Object pendingNewWorldSession; private static volatile WorldGeologyProfile activeProfile; private static volatile MinecraftServer activeServer; + private static final Map deferredFreshPolicies = new LinkedHashMap<>(); + private static volatile boolean freshPolicyDiscoveryPending; private WorldGeologyProfileManager() { throw new IllegalAccessError("Not an instantiable class"); @@ -107,7 +112,9 @@ public static synchronized boolean reloadActiveProfile() { .resolve("serverconfig").resolve(PROFILE_FILE_NAME); WorldGeologyProfile profile = readProfile(profilePath, globalProfile()); JsonObject merged = profile.rootCopy(); - if (OreSpawnOreIntegration.mergeProviderOres(merged)) { + boolean changed = OreSpawnOreIntegration.mergeProviderOres(merged); + changed |= OreSourcePolicies.initialize(merged, true); + if (changed) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); } @@ -118,6 +125,8 @@ public static synchronized boolean reloadActiveProfile() { public static void onServerAboutToStart(ServerAboutToStartEvent event) { activeServer = event.getServer(); + deferredFreshPolicies.clear(); + freshPolicyDiscoveryPending = false; BiomeTypeCompatibility.useRegistry(event.getServer().registryAccess() .registryOrThrow(Registry.BIOME_REGISTRY)); Path worldRoot = event.getServer().getWorldPath(LevelResource.ROOT).normalize(); @@ -132,6 +141,7 @@ public static void onServerAboutToStart(ServerAboutToStartEvent event) { JsonObject merged = profile.rootCopy(); String beforeMerge = merged.toString(); OreSpawnOreIntegration.mergeProviderOres(merged); + OreSourcePolicies.initialize(merged, true); if (!beforeMerge.equals(merged.toString())) { profile = profile.withRoot(merged); writeProfile(profilePath, profile); @@ -158,6 +168,11 @@ public static void onServerAboutToStart(ServerAboutToStartEvent event) { profile = fallback.copy(); source = "installed-pack fresh-world"; } + profile = initializeNewWorldPolicies(profile, generatedWorld); + if (!generatedWorld) { + rememberUnreviewedDefaults(profile, fallback); + freshPolicyDiscoveryPending = true; + } writeProfile(profilePath, profile); LOGGER.info("Created OreSpawn world geology profile '{}' from {} settings", profilePath, source); @@ -172,12 +187,103 @@ public static void onServerAboutToStart(ServerAboutToStartEvent event) { profile.enabledFluidDepositCount(), profile.fluidDepositCount()); } + private static void rememberUnreviewedDefaults(WorldGeologyProfile profile, + WorldGeologyProfile defaults) { + JsonObject current = profile.rootCopy().getAsJsonObject(OreSourcePolicies.SECTION); + JsonObject baseline = defaults.rootCopy().getAsJsonObject(OreSourcePolicies.SECTION); + if (current == null || baseline == null) return; + for (Map.Entry entry : current.entrySet()) { + if (!entry.getValue().isJsonObject() || !baseline.has(entry.getKey()) + || !baseline.get(entry.getKey()).isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + if (!"keep_separate".equals(policy.has("mode") ? policy.get("mode").getAsString() : "") + || !policy.has("review_required") || !policy.get("review_required").getAsBoolean() + || !sameOreSourceChoice(policy, baseline.getAsJsonObject(entry.getKey()))) continue; + deferredFreshPolicies.put(entry.getKey(), policy.toString()); + } + } + + private static boolean sameOreSourceChoice(JsonObject current, JsonObject baseline) { + // Tags can finish loading between the global profile and world creation. + // Compare the player's choices, not the discovered candidate snapshots. + for (String field : new String[] { "mode", "output_mode", "outputs", "placement_sources" }) { + if (!java.util.Objects.equals(current.get(field), baseline.get(field))) return false; + } + return true; + } + + /** Block tags are ready by this event, before Forge decorates spawn chunks. */ + public static void onServerStarting(ServerStartingEvent event) { + if (event.getServer() == activeServer) resolveFreshPoliciesBeforeGeneration(); + } + + public static void resolveFreshPoliciesBeforeGeneration() { + if (freshPolicyDiscoveryPending) resolveFreshPoliciesOnce(); + } + + private static synchronized void resolveFreshPoliciesOnce() { + if (!freshPolicyDiscoveryPending || activeServer == null) return; + JsonObject refreshed = activeProfile().rootCopy(); + JsonObject policies = refreshed.getAsJsonObject(OreSourcePolicies.SECTION); + boolean hadConsolidatedPolicies = hasConsolidatedPolicies(policies); + if (policies != null) { + for (Map.Entry entry : deferredFreshPolicies.entrySet()) { + if (policies.has(entry.getKey()) + && entry.getValue().equals(policies.get(entry.getKey()).toString())) { + policies.remove(entry.getKey()); + } + } + } + deferredFreshPolicies.clear(); + OreSourcePolicies.initialize(refreshed, false); + boolean hasConsolidatedPolicies = hasConsolidatedPolicies( + refreshed.getAsJsonObject(OreSourcePolicies.SECTION)); + WorldGeologyProfile next = activeProfile().withRoot(refreshed); + if (!next.rootCopy().equals(activeProfile().rootCopy())) { + Path path = activeServer.getWorldPath(LevelResource.ROOT).normalize() + .resolve("serverconfig").resolve(PROFILE_FILE_NAME); + if (writeProfile(path, next)) { + activeProfile = next; + // Keep Original changes only editor metadata. Re-baking ores here + // would alter the first generated chunks for no gameplay benefit. + if (hadConsolidatedPolicies || hasConsolidatedPolicies) + OreSpawnOreGeneration.refreshWorldConfig(); + LOGGER.info("Resolved fresh-world ore-source defaults before managed ore placement"); + } + } + freshPolicyDiscoveryPending = false; + } + + private static boolean hasConsolidatedPolicies(JsonObject policies) { + if (policies == null) return false; + for (Map.Entry entry : policies.entrySet()) { + if (!entry.getValue().isJsonObject()) continue; + JsonObject policy = entry.getValue().getAsJsonObject(); + if (policy.has("mode") && policy.get("mode").isJsonPrimitive() + && "consolidated".equals(policy.get("mode").getAsString())) + return true; + } + return false; + } + + static WorldGeologyProfile initializeNewWorldPolicies(WorldGeologyProfile profile, + boolean generatedWorld) { + JsonObject initialized = profile.rootCopy(); + // A world with generated chunks keeps its original ore budgets on upgrade. + if (generatedWorld) initialized.remove(OreSourcePolicies.SECTION); + OreSourcePolicies.initialize(initialized, generatedWorld); + return profile.withRoot(initialized); + } + public static void onServerStopped(ServerStoppedEvent event) { + deferredFreshPolicies.clear(); + freshPolicyDiscoveryPending = false; activeServer = null; activeProfile = null; BiomeTypeCompatibility.clearRegistry(); GeomeConfig.applyWorldProfile(globalProfile()); BiomeWorldgenManager.clear(); + VanillaOreFeatureGate.clearInlineGates(); StoneReplacer.refreshWorldConfig(); OreSpawnOreGeneration.refreshWorldConfig(); FluidDepositFeature.refreshWorldConfig(); @@ -261,8 +367,10 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback) if (oreDefaultsRefreshed) { root = GeomeConfig.refreshWorldOreDefaults(root); } + boolean sourcePoliciesRefreshed = OreSourcePolicies.initialize(root, true); WorldGeologyProfile profile = WorldGeologyProfile.fromJson(root, fallback); - if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed) { + if (schema < WorldGeologyProfile.SCHEMA_VERSION || oreDefaultsRefreshed + || sourcePoliciesRefreshed) { boolean canWrite = schema >= WorldGeologyProfile.SCHEMA_VERSION || preserveBeforeSchemaMigration(path, schema); canWrite &= !oreDefaultsRefreshed || preserveBeforeOreDefaultsRefresh(path); @@ -275,6 +383,9 @@ static WorldGeologyProfile readProfile(Path path, WorldGeologyProfile fallback) LOGGER.info("Updated untouched managed-ore rules in world geology profile '{}' to revision {}", path, GeomeConfig.oreDefaultsRevision()); } + if (sourcePoliciesRefreshed) { + LOGGER.info("Refreshed OreSpawn ore-source policies in world geology profile '{}'", path); + } } else if (oreDefaultsRefreshed) { LOGGER.warn("Could not persist updated OreSpawn ore defaults for world profile '{}'; " + "using refreshed settings in memory", path); diff --git a/src/main/resources/assets/orespawn/lang/de_au.json b/src/main/resources/assets/orespawn/lang/de_au.json index 8727172f..65e64795 100644 --- a/src/main/resources/assets/orespawn/lang/de_au.json +++ b/src/main/resources/assets/orespawn/lang/de_au.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Installiertes Biom auswählen", "screen.orespawn.biome_placement": "Biomeinstellungen", "screen.orespawn.biome_references": "Ähnliche Biome", - "screen.orespawn.dimension_materials": "Weltmaterialien", + "screen.orespawn.dimension_materials": "%s-Materialien", "button.orespawn.biomes_world_materials": "Biome und Weltmaterialien...", "button.orespawn.biome_palette_count": "Biome: %s", "button.orespawn.dimension_materials": "Weltmaterialien...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Weltmaterialien", "guide.orespawn.materials.1": "Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.", "guide.orespawn.materials.2": "Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.", - "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert." + "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.", + "button.orespawn.dimension_materials_named": "%s-Materialien...", + "label.orespawn.dimension_materials.scope": "Gilt für jedes Biom im neu erzeugten Terrain von %s.", + "button.orespawn.mods": "Mod-Verzeichnis", + "button.orespawn.mod.configure": "Konfigurieren", + "tooltip.orespawn.main.mods": "Geladene Mods anzeigen, die in OreSpawn integriert sind.", + "screen.orespawn.mods": "OreSpawn-Mods", + "tooltip.orespawn.mod.configure": "Konfiguration von %s öffnen.", + "tooltip.orespawn.mod.unavailable": "%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.", + "label.orespawn.mod.id_version": "%s - Mod-Version %s", + "label.orespawn.mod.additional_lineage": "Auch über OreSpawn-%s-Kompatibilität erkannt.", + "label.orespawn.mod.status": "Zustand: %s", + "label.orespawn.mod.os4_version": "Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s", + "label.orespawn.mod.os4": "Nativer OS4-Anbieter", + "label.orespawn.mod.legacy_version": "OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s", + "label.orespawn.mod.legacy": "OreSpawn-%s-Kompatibilität -> OS4", + "label.orespawn.mod.config_only": "Client-Konfigurationsintegration", + "label.orespawn.mod.none": "Keine geladenen OreSpawn-Integrationen gefunden.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Aktiv", + "status.orespawn.mod.pending": "Ausstehend", + "status.orespawn.mod.inactive": "Inaktiv", + "status.orespawn.mod.rejected": "Abgelehnt", + "status.orespawn.mod.config_only": "Nur Konfigurationsbildschirm", + "error.orespawn.mod.screen_missing": "%s hat keinen Konfigurationsbildschirm erstellt.", + "error.orespawn.mod.screen_failed": "Die Konfiguration von %s konnte nicht geöffnet werden (%s).", + "button.orespawn.ore_sources": "Erzquellen...", + "tooltip.orespawn.ore_sources": "Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen.", + "screen.orespawn.ore_sources": "Erzquellen", + "screen.orespawn.ore_source_detail": "Erzquellenrichtlinie", + "label.orespawn.ore_source.none": "Keine gleichwertigen verwalteten Erzquellen gefunden.", + "label.orespawn.ore_source.row": "%s - %s - %s Kandidaten - %s", + "label.orespawn.ore_source.intro": "Wähle, wie gleichwertige Erze generiert werden.", + "label.orespawn.ore_source.summary": "%s Kandidaten - %s", + "button.orespawn.ore_source.review": "Prüfen...", + "button.orespawn.ore_source.configure": "Konfigurieren...", + "label.orespawn.ore_source.material": "Rohstoff: %s", + "label.orespawn.ore_source.domain": "Dimension/Bereich: %s", + "label.orespawn.ore_source.channels": "Platzierungskanäle: %s", + "label.orespawn.ore_source.missing": "Fehlende Platzierungsquelle", + "label.orespawn.ore_source.owner": "Besitzer: %s, Version %s", + "label.orespawn.ore_source.registry": "Registrierte Ausgabe: %s, Metadaten %s", + "label.orespawn.ore_source.dictionary": "Block-Tags: %s", + "option.orespawn.ore_source.mode": "Modus: %s", + "option.orespawn.ore_source.channel": "Kanal: %s", + "option.orespawn.ore_source.placement": "Platzierungsquelle: %s", + "option.orespawn.ore_source.weight": "Gewicht", + "mode.orespawn.ore_source.consolidated": "Zusammengeführt", + "mode.orespawn.ore_source.keep_separate": "Getrennt halten", + "status.orespawn.ore_source.consolidated": "Zusammengeführt", + "status.orespawn.ore_source.separate": "Getrennt", + "status.orespawn.ore_source.review_required": "Prüfung erforderlich", + "status.orespawn.ore_source.missing_source": "Quelle fehlt", + "status.orespawn.ore_source.external_generation": "Externe Generierung", + "tooltip.orespawn.ore_source.open": "Diese Materialrichtlinie öffnen.", + "tooltip.orespawn.ore_source.mode": "Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel.", + "tooltip.orespawn.ore_source.channel": "Platzierungs-Engines werden unabhängig verwaltet.", + "tooltip.orespawn.ore_source.placement": "Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt.", + "tooltip.orespawn.ore_source.weight": "Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen.", + "tooltip.orespawn.ore_source.external": "Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle.", + "tooltip.orespawn.ore_source.enrichment": "Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle.", + "tooltip.orespawn.ore_source.missing": "Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt.", + "error.orespawn.ore_source.weight": "Ausgabegewichte müssen positive Zahlen sein.", + "button.orespawn.back": "Zurück", + "button.orespawn.ore_source.all_groups": "Alle Gruppen", + "button.orespawn.ore_source.add_group": "Gruppe hinzufügen", + "button.orespawn.ore_source.accept": "Akzeptieren", + "tooltip.orespawn.ore_source.accept": "Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor.", + "button.orespawn.ore_source.delete_group": "Löschen", + "button.orespawn.ore_source.dissolve_group": "Gruppe auflösen", + "button.orespawn.ore_source.confirm_dissolve": "Auflösen bestätigen", + "button.orespawn.ore_source.move": "Verschieben", + "button.orespawn.ore_source.advanced": "Erweitert...", + "button.orespawn.ore_source.hide_advanced": "Erweitert ausblenden", + "option.orespawn.ore_source.group_name": "Gruppenname", + "option.orespawn.ore_source.alias": "Block-Tag-Eintrag", + "option.orespawn.ore_source.output_mode": "Ausgabe: %s", + "mode.orespawn.ore_source.keep_original": "Original beibehalten", + "mode.orespawn.ore_source.balanced": "Ausgewogen", + "mode.orespawn.ore_source.single": "Einzeln", + "mode.orespawn.ore_source.custom": "Benutzerdefiniert", + "label.orespawn.ore_source.no_aliases": "Keine Block-Tag-Einträge", + "status.orespawn.ore_source.external_short": "extern", + "status.orespawn.ore_source.missing_short": "fehlt", + "status.orespawn.ore_source.output_only": "nur Ausgabe", + "tooltip.orespawn.ore_source.output_only": "Diese Anbieterausgabe ist verfügbar, aber ihre Platzierungsregel ist inaktiv.", + "tooltip.orespawn.ore_source.output_mode": "Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen.", + "tooltip.orespawn.ore_source.alias": "Füge einen exakten Block-Tag-Tag-Namen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen.", + "tooltip.orespawn.ore_source.reset_group": "Stelle den Namen und die Block-Tag-Einträge dieser kuratierten Gruppe wieder her.", + "tooltip.orespawn.ore_source.delete_group": "Lösche diese leere benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben.", + "tooltip.orespawn.ore_source.dissolve_group": "Ordnet jeden Eintrag wieder seiner ermittelten oder kuratierten Gruppe zu. Zum Bestätigen zweimal drücken.", + "error.orespawn.ore_source.confirm_dissolve": "Drücke Auflösen bestätigen, um jeden Block-Tags-Eintrag seiner ursprünglichen Gruppe zuzuordnen.", + "error.orespawn.ore_source.alias": "Verwende einen exakten Block-Tag-Tag-Namen wie forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen.", + "error.orespawn.ore_source.manage_vanilla_first": "Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst.", + "error.orespawn.ore_source.defaults_write": "Die Standardwerte der Materialgruppen konnten nicht gespeichert werden.", + "screen.orespawn.ore_source.group_settings": "Erzquellen-Gruppeneinstellungen", + "label.orespawn.ore_source.groups": "Gruppen", + "label.orespawn.ore_source.aliases": "Block-Tag-Einträge", + "label.orespawn.ore_source.placement_rules": "Platzierungsregeln", + "label.orespawn.ore_source.placement_explanation": "Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte.", + "label.orespawn.ore_source.standard_veins": "Standardadern", + "label.orespawn.ore_source.placement_choice_count": "%s (%s Möglichkeiten)", + "label.orespawn.ore_source.no_placement_rules": "Keine verwalteten Platzierungsregeln", + "tooltip.orespawn.ore_source.group_settings": "Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten.", + "tooltip.orespawn.ore_source.remove_alias": "%s aus dieser Gruppe entfernen.", + "tooltip.orespawn.ore_source.placement_rules": "Ausgaben wählen den Erzblock; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirtsbeschränkungen.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Platzierungsbesitz gilt nur für konsolidierte Modi Ausgeglichen, Einzeln oder Benutzerdefiniert.", + "tooltip.orespawn.ore_source.placement_missing": "Die gespeicherte Platzierungsquelle fehlt; dieser Kanal bleibt deaktiviert.", + "tooltip.orespawn.ore_source.placement_readonly": "Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt.", + "tooltip.orespawn.ore_source.placement_select": "Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen.", + "tooltip.orespawn.ore_source.external_excluded": "Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden.", + "button.orespawn.ore_source.hide_single": "Einzelne ausblenden", + "tooltip.orespawn.ore_source.group_colours": "Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein.", + "button.orespawn.ore_source.reset_all": "Alles zurücksetzen", + "button.orespawn.ore_source.confirm_reset_all": "Zurücksetzen bestätigen", + "tooltip.orespawn.ore_source.reset_all": "Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend.", + "screen.orespawn.biomes": "Biome", + "screen.orespawn.biome_palettes": "Biompaletten", + "screen.orespawn.biome_palette_settings": "Biompaletten-Einstellungen", + "button.orespawn.biome.hide_routine": "Gewöhnliche ausblenden", + "button.orespawn.biome.replace_new_terrain": "In neuem Gelände ersetzen durch...", + "button.orespawn.biome.replace_with": "In neuem Gelände ersetzen durch %s...", + "button.orespawn.biome.leave_original": "Ursprüngliches Verhalten beibehalten", + "button.orespawn.biome.palettes": "Paletten: %s", + "button.orespawn.biome.reset_selected": "Biom zurücksetzen", + "button.orespawn.biome.confirm_reset": "Zurücksetzen bestätigen", + "button.orespawn.biome.reset_palette": "Palette zurücksetzen", + "button.orespawn.biome.confirm_reset_palette": "Paletten-Reset bestätigen", + "button.orespawn.biome.reset_dimension": "Dimension zurücksetzen", + "button.orespawn.biome.confirm_reset_dimension": "Dimensions-Reset bestätigen", + "button.orespawn.biome.reset_all": "Alle Biome zurücksetzen", + "button.orespawn.biome.confirm_reset_all": "Gesamten Reset bestätigen", + "value.orespawn.biome.status.user_replaced": "In neuem Gelände ersetzt", + "value.orespawn.biome.status.layered": "Mehrere Platzierungsregeln", + "value.orespawn.biome.status.disabled": "Platzierung deaktiviert", + "value.orespawn.biome.status.missing": "Biom oder Anbieter fehlt", + "value.orespawn.biome.status.provider_managed": "Vom Anbieter verwaltet", + "value.orespawn.biome.status.unmanaged": "Registriert, nicht verwaltet", + "label.orespawn.biome.rule_count": "Platzierungsregeln: %s", + "label.orespawn.biome.replaced_by": "Ersatz in neuem Gelände: %s", + "label.orespawn.biome.palette_owner_order": "Besitzer: %s; wirksame Reihenfolge: %s", + "label.orespawn.biome.surface_effective": "Diese Regel liefert die wirksame Oberfläche.", + "label.orespawn.biome.surface_overridden": "Eine spätere aktive Regel überschreibt diese Oberfläche.", + "label.orespawn.biome.placement_rules": "Platzierungsregeln", + "label.orespawn.biome.palette_entries": "Platzierungseinträge: %s", + "tooltip.orespawn.biome.no_retrogen": "Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben.", + "tooltip.orespawn.biome.override_palette": "Reservierte abschließende Ersatzebene. Bearbeitung über die Ersatzsteuerung des Bioms.", + "tooltip.orespawn.biome.palette_entries_read_only": "Die Palettenzugehörigkeit wird vom Anbieter oder Profil festgelegt. Wähle ein Biom im Verzeichnis, um eine vorhandene Regel zu bearbeiten.", + "option.orespawn.biome.coverage": "Abdeckung", + "option.orespawn.biome.fallback_weight": "Gewicht des ursprünglichen Bioms", + "option.orespawn.biome.include_namespaces": "Eingeschlossene Namensräume (kommagetrennt)", + "option.orespawn.biome.exclude_namespaces": "Ausgeschlossene Namensräume (kommagetrennt)" } diff --git a/src/main/resources/assets/orespawn/lang/de_de.json b/src/main/resources/assets/orespawn/lang/de_de.json index 8727172f..65e64795 100644 --- a/src/main/resources/assets/orespawn/lang/de_de.json +++ b/src/main/resources/assets/orespawn/lang/de_de.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Installiertes Biom auswählen", "screen.orespawn.biome_placement": "Biomeinstellungen", "screen.orespawn.biome_references": "Ähnliche Biome", - "screen.orespawn.dimension_materials": "Weltmaterialien", + "screen.orespawn.dimension_materials": "%s-Materialien", "button.orespawn.biomes_world_materials": "Biome und Weltmaterialien...", "button.orespawn.biome_palette_count": "Biome: %s", "button.orespawn.dimension_materials": "Weltmaterialien...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Weltmaterialien", "guide.orespawn.materials.1": "Weltmaterialien ändern Hintergrundmaterialien in einer ganzen Dimension. Verwende Flüssigkeitsvorkommen, wenn du nur gelegentliche unterirdische Taschen möchtest.", "guide.orespawn.materials.2": "Aquiferflüssigkeit ändert die normale Flüssigkeit unter dem Meeresspiegel. Tiefer Aquifer verwendet unterhalb der gewählten Y-Höhe eine zweite Flüssigkeit.", - "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert." + "guide.orespawn.materials.3": "Schnee und Eis ersetzen Vanilla-Schnee und gewöhnliches Eis nahe der Oberfläche. Nicht gesetzt lässt das normale Minecraft-Material unverändert.", + "button.orespawn.dimension_materials_named": "%s-Materialien...", + "label.orespawn.dimension_materials.scope": "Gilt für jedes Biom im neu erzeugten Terrain von %s.", + "button.orespawn.mods": "Mod-Verzeichnis", + "button.orespawn.mod.configure": "Konfigurieren", + "tooltip.orespawn.main.mods": "Geladene Mods anzeigen, die in OreSpawn integriert sind.", + "screen.orespawn.mods": "OreSpawn-Mods", + "tooltip.orespawn.mod.configure": "Konfiguration von %s öffnen.", + "tooltip.orespawn.mod.unavailable": "%s stellt keinen mit OreSpawn verknüpften Konfigurationsbildschirm bereit.", + "label.orespawn.mod.id_version": "%s - Mod-Version %s", + "label.orespawn.mod.additional_lineage": "Auch über OreSpawn-%s-Kompatibilität erkannt.", + "label.orespawn.mod.status": "Zustand: %s", + "label.orespawn.mod.os4_version": "Nativer OS4-Anbieter - Schema %s, Anbieterrevision %s", + "label.orespawn.mod.os4": "Nativer OS4-Anbieter", + "label.orespawn.mod.legacy_version": "OreSpawn-%s-Kompatibilität -> OS4-Schema %s, Anbieterrevision %s", + "label.orespawn.mod.legacy": "OreSpawn-%s-Kompatibilität -> OS4", + "label.orespawn.mod.config_only": "Client-Konfigurationsintegration", + "label.orespawn.mod.none": "Keine geladenen OreSpawn-Integrationen gefunden.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Aktiv", + "status.orespawn.mod.pending": "Ausstehend", + "status.orespawn.mod.inactive": "Inaktiv", + "status.orespawn.mod.rejected": "Abgelehnt", + "status.orespawn.mod.config_only": "Nur Konfigurationsbildschirm", + "error.orespawn.mod.screen_missing": "%s hat keinen Konfigurationsbildschirm erstellt.", + "error.orespawn.mod.screen_failed": "Die Konfiguration von %s konnte nicht geöffnet werden (%s).", + "button.orespawn.ore_sources": "Erzquellen...", + "tooltip.orespawn.ore_sources": "Gleichwertige Erzausgaben prüfen, ohne die Platzierungshäufigkeit zu vervielfachen.", + "screen.orespawn.ore_sources": "Erzquellen", + "screen.orespawn.ore_source_detail": "Erzquellenrichtlinie", + "label.orespawn.ore_source.none": "Keine gleichwertigen verwalteten Erzquellen gefunden.", + "label.orespawn.ore_source.row": "%s - %s - %s Kandidaten - %s", + "label.orespawn.ore_source.intro": "Wähle, wie gleichwertige Erze generiert werden.", + "label.orespawn.ore_source.summary": "%s Kandidaten - %s", + "button.orespawn.ore_source.review": "Prüfen...", + "button.orespawn.ore_source.configure": "Konfigurieren...", + "label.orespawn.ore_source.material": "Rohstoff: %s", + "label.orespawn.ore_source.domain": "Dimension/Bereich: %s", + "label.orespawn.ore_source.channels": "Platzierungskanäle: %s", + "label.orespawn.ore_source.missing": "Fehlende Platzierungsquelle", + "label.orespawn.ore_source.owner": "Besitzer: %s, Version %s", + "label.orespawn.ore_source.registry": "Registrierte Ausgabe: %s, Metadaten %s", + "label.orespawn.ore_source.dictionary": "Block-Tags: %s", + "option.orespawn.ore_source.mode": "Modus: %s", + "option.orespawn.ore_source.channel": "Kanal: %s", + "option.orespawn.ore_source.placement": "Platzierungsquelle: %s", + "option.orespawn.ore_source.weight": "Gewicht", + "mode.orespawn.ore_source.consolidated": "Zusammengeführt", + "mode.orespawn.ore_source.keep_separate": "Getrennt halten", + "status.orespawn.ore_source.consolidated": "Zusammengeführt", + "status.orespawn.ore_source.separate": "Getrennt", + "status.orespawn.ore_source.review_required": "Prüfung erforderlich", + "status.orespawn.ore_source.missing_source": "Quelle fehlt", + "status.orespawn.ore_source.external_generation": "Externe Generierung", + "tooltip.orespawn.ore_source.open": "Diese Materialrichtlinie öffnen.", + "tooltip.orespawn.ore_source.mode": "Zusammengeführt verwendet ein Platzierungsbudget pro Kanal. Getrennt halten bewahrt jede konfigurierte Regel.", + "tooltip.orespawn.ore_source.channel": "Platzierungs-Engines werden unabhängig verwaltet.", + "tooltip.orespawn.ore_source.placement": "Regel auswählen, die Häufigkeit, Form, Wirte, Filter und Höhe dieses Kanals bestimmt.", + "tooltip.orespawn.ore_source.weight": "Positive relative Chance, diese Ausgabe für einen ganzen Erzgang oder Lagerstättenkörper zu wählen.", + "tooltip.orespawn.ore_source.external": "Dieser Mod registriert Generierung außerhalb von OreSpawn; OreSpawn meldet sie, kann sie aber nicht unterdrücken.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn steuert diese konfigurierte Platzierung und Ausgabequelle.", + "tooltip.orespawn.ore_source.enrichment": "Dieser Eintrag ist eine Anreicherung und keine austauschbare Erzquelle.", + "tooltip.orespawn.ore_source.missing": "Diese Quelle ist nicht verfügbar. Die gespeicherte Auswahl wird wiederhergestellt, wenn der Mod zurückkehrt.", + "error.orespawn.ore_source.weight": "Ausgabegewichte müssen positive Zahlen sein.", + "button.orespawn.back": "Zurück", + "button.orespawn.ore_source.all_groups": "Alle Gruppen", + "button.orespawn.ore_source.add_group": "Gruppe hinzufügen", + "button.orespawn.ore_source.accept": "Akzeptieren", + "tooltip.orespawn.ore_source.accept": "Behält „Original beibehalten“ unverändert bei und markiert die Gruppe als geprüft. Gespeichert wird dies erst mit „Fertig“ im Haupteditor.", + "button.orespawn.ore_source.delete_group": "Löschen", + "button.orespawn.ore_source.dissolve_group": "Gruppe auflösen", + "button.orespawn.ore_source.confirm_dissolve": "Auflösen bestätigen", + "button.orespawn.ore_source.move": "Verschieben", + "button.orespawn.ore_source.advanced": "Erweitert...", + "button.orespawn.ore_source.hide_advanced": "Erweitert ausblenden", + "option.orespawn.ore_source.group_name": "Gruppenname", + "option.orespawn.ore_source.alias": "Block-Tag-Eintrag", + "option.orespawn.ore_source.output_mode": "Ausgabe: %s", + "mode.orespawn.ore_source.keep_original": "Original beibehalten", + "mode.orespawn.ore_source.balanced": "Ausgewogen", + "mode.orespawn.ore_source.single": "Einzeln", + "mode.orespawn.ore_source.custom": "Benutzerdefiniert", + "label.orespawn.ore_source.no_aliases": "Keine Block-Tag-Einträge", + "status.orespawn.ore_source.external_short": "extern", + "status.orespawn.ore_source.missing_short": "fehlt", + "status.orespawn.ore_source.output_only": "nur Ausgabe", + "tooltip.orespawn.ore_source.output_only": "Diese Anbieterausgabe ist verfügbar, aber ihre Platzierungsregel ist inaktiv.", + "tooltip.orespawn.ore_source.output_mode": "Original beibehalten erhält jede Regel. Ausgewogen verteilt gleichmäßig, Einzeln wählt einen Block und Benutzerdefiniert erlaubt gewichtete Auswahlen.", + "tooltip.orespawn.ore_source.alias": "Füge einen exakten Block-Tag-Tag-Namen hinzu. Drücke erneut Verschieben, um die Neuzuordnung aus einer anderen Gruppe zu bestätigen.", + "tooltip.orespawn.ore_source.reset_group": "Stelle den Namen und die Block-Tag-Einträge dieser kuratierten Gruppe wieder her.", + "tooltip.orespawn.ore_source.delete_group": "Lösche diese leere benutzerdefinierte Gruppe, ohne erzeugte Chunks neu zu schreiben.", + "tooltip.orespawn.ore_source.dissolve_group": "Ordnet jeden Eintrag wieder seiner ermittelten oder kuratierten Gruppe zu. Zum Bestätigen zweimal drücken.", + "error.orespawn.ore_source.confirm_dissolve": "Drücke Auflösen bestätigen, um jeden Block-Tags-Eintrag seiner ursprünglichen Gruppe zuzuordnen.", + "error.orespawn.ore_source.alias": "Verwende einen exakten Block-Tag-Tag-Namen wie forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s besitzt diesen Eintrag bereits. Drücke Verschieben, um ihn neu zuzuweisen.", + "error.orespawn.ore_source.manage_vanilla_first": "Aktiviere „Vanilla-Erze verwalten“, bevor du ein natives Vanilla-Erz in eine andere Gruppe verschiebst.", + "error.orespawn.ore_source.defaults_write": "Die Standardwerte der Materialgruppen konnten nicht gespeichert werden.", + "screen.orespawn.ore_source.group_settings": "Erzquellen-Gruppeneinstellungen", + "label.orespawn.ore_source.groups": "Gruppen", + "label.orespawn.ore_source.aliases": "Block-Tag-Einträge", + "label.orespawn.ore_source.placement_rules": "Platzierungsregeln", + "label.orespawn.ore_source.placement_explanation": "Ausgaben wählen den Block; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirte.", + "label.orespawn.ore_source.standard_veins": "Standardadern", + "label.orespawn.ore_source.placement_choice_count": "%s (%s Möglichkeiten)", + "label.orespawn.ore_source.no_placement_rules": "Keine verwalteten Platzierungsregeln", + "tooltip.orespawn.ore_source.group_settings": "Name, Aliase und Platzierungsregeln dieser Gruppe bearbeiten.", + "tooltip.orespawn.ore_source.remove_alias": "%s aus dieser Gruppe entfernen.", + "tooltip.orespawn.ore_source.placement_rules": "Ausgaben wählen den Erzblock; Platzierungsregeln wählen Häufigkeit, Form, Tiefe und Wirtsbeschränkungen.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Platzierungsbesitz gilt nur für konsolidierte Modi Ausgeglichen, Einzeln oder Benutzerdefiniert.", + "tooltip.orespawn.ore_source.placement_missing": "Die gespeicherte Platzierungsquelle fehlt; dieser Kanal bleibt deaktiviert.", + "tooltip.orespawn.ore_source.placement_readonly": "Nur eine aktive verwaltete Regel ist verfügbar; diese Quelle ist schreibgeschützt.", + "tooltip.orespawn.ore_source.placement_select": "Klicken, um zwischen aktiven verwalteten Platzierungsquellen zu wählen.", + "tooltip.orespawn.ore_source.external_excluded": "Unabhängige externe Generatoren werden gemeldet, können aber nicht als Platzierungsregeln gewählt werden.", + "button.orespawn.ore_source.hide_single": "Einzelne ausblenden", + "tooltip.orespawn.ore_source.group_colours": "Grüne Einträge haben einen Alias und eine Ausgabe und benötigen keine Aktion. Gelbe Gruppen haben eine gespeicherte Regel. Rote Gruppen benötigen Aufmerksamkeit. Alle anzeigen schließt grüne Einträge ein.", + "button.orespawn.ore_source.reset_all": "Alles zurücksetzen", + "button.orespawn.ore_source.confirm_reset_all": "Zurücksetzen bestätigen", + "tooltip.orespawn.ore_source.reset_all": "Stellt die eingebauten Materialgruppen wieder her und erzeugt die Quellenregeln aus den aktuell geladenen Erzen neu. Die Änderung bleibt bis Fertig im Haupteditor ausstehend.", + "screen.orespawn.biomes": "Biome", + "screen.orespawn.biome_palettes": "Biompaletten", + "screen.orespawn.biome_palette_settings": "Biompaletten-Einstellungen", + "button.orespawn.biome.hide_routine": "Gewöhnliche ausblenden", + "button.orespawn.biome.replace_new_terrain": "In neuem Gelände ersetzen durch...", + "button.orespawn.biome.replace_with": "In neuem Gelände ersetzen durch %s...", + "button.orespawn.biome.leave_original": "Ursprüngliches Verhalten beibehalten", + "button.orespawn.biome.palettes": "Paletten: %s", + "button.orespawn.biome.reset_selected": "Biom zurücksetzen", + "button.orespawn.biome.confirm_reset": "Zurücksetzen bestätigen", + "button.orespawn.biome.reset_palette": "Palette zurücksetzen", + "button.orespawn.biome.confirm_reset_palette": "Paletten-Reset bestätigen", + "button.orespawn.biome.reset_dimension": "Dimension zurücksetzen", + "button.orespawn.biome.confirm_reset_dimension": "Dimensions-Reset bestätigen", + "button.orespawn.biome.reset_all": "Alle Biome zurücksetzen", + "button.orespawn.biome.confirm_reset_all": "Gesamten Reset bestätigen", + "value.orespawn.biome.status.user_replaced": "In neuem Gelände ersetzt", + "value.orespawn.biome.status.layered": "Mehrere Platzierungsregeln", + "value.orespawn.biome.status.disabled": "Platzierung deaktiviert", + "value.orespawn.biome.status.missing": "Biom oder Anbieter fehlt", + "value.orespawn.biome.status.provider_managed": "Vom Anbieter verwaltet", + "value.orespawn.biome.status.unmanaged": "Registriert, nicht verwaltet", + "label.orespawn.biome.rule_count": "Platzierungsregeln: %s", + "label.orespawn.biome.replaced_by": "Ersatz in neuem Gelände: %s", + "label.orespawn.biome.palette_owner_order": "Besitzer: %s; wirksame Reihenfolge: %s", + "label.orespawn.biome.surface_effective": "Diese Regel liefert die wirksame Oberfläche.", + "label.orespawn.biome.surface_overridden": "Eine spätere aktive Regel überschreibt diese Oberfläche.", + "label.orespawn.biome.placement_rules": "Platzierungsregeln", + "label.orespawn.biome.palette_entries": "Platzierungseinträge: %s", + "tooltip.orespawn.biome.no_retrogen": "Biomersetzungen gelten nur beim Erzeugen neuen Geländes. Bestehende Chunks werden nie umgeschrieben.", + "tooltip.orespawn.biome.override_palette": "Reservierte abschließende Ersatzebene. Bearbeitung über die Ersatzsteuerung des Bioms.", + "tooltip.orespawn.biome.palette_entries_read_only": "Die Palettenzugehörigkeit wird vom Anbieter oder Profil festgelegt. Wähle ein Biom im Verzeichnis, um eine vorhandene Regel zu bearbeiten.", + "option.orespawn.biome.coverage": "Abdeckung", + "option.orespawn.biome.fallback_weight": "Gewicht des ursprünglichen Bioms", + "option.orespawn.biome.include_namespaces": "Eingeschlossene Namensräume (kommagetrennt)", + "option.orespawn.biome.exclude_namespaces": "Ausgeschlossene Namensräume (kommagetrennt)" } diff --git a/src/main/resources/assets/orespawn/lang/en_ca.json b/src/main/resources/assets/orespawn/lang/en_ca.json index 3a2a3706..865f424b 100644 --- a/src/main/resources/assets/orespawn/lang/en_ca.json +++ b/src/main/resources/assets/orespawn/lang/en_ca.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_en.json b/src/main/resources/assets/orespawn/lang/en_en.json index 3a2a3706..865f424b 100644 --- a/src/main/resources/assets/orespawn/lang/en_en.json +++ b/src/main/resources/assets/orespawn/lang/en_en.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_gb.json b/src/main/resources/assets/orespawn/lang/en_gb.json index 3a2a3706..865f424b 100644 --- a/src/main/resources/assets/orespawn/lang/en_gb.json +++ b/src/main/resources/assets/orespawn/lang/en_gb.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_pt.json b/src/main/resources/assets/orespawn/lang/en_pt.json index 3a2a3706..865f424b 100644 --- a/src/main/resources/assets/orespawn/lang/en_pt.json +++ b/src/main/resources/assets/orespawn/lang/en_pt.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/en_us.json b/src/main/resources/assets/orespawn/lang/en_us.json index c7a5337c..18cfba75 100644 --- a/src/main/resources/assets/orespawn/lang/en_us.json +++ b/src/main/resources/assets/orespawn/lang/en_us.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choose Installed Biome", "screen.orespawn.biome_placement": "Biome Settings", "screen.orespawn.biome_references": "Similar Biomes", - "screen.orespawn.dimension_materials": "World Materials", + "screen.orespawn.dimension_materials": "%s Materials", "button.orespawn.biomes_world_materials": "Biomes & World Materials...", "button.orespawn.biome_palette_count": "Biomes: %s", "button.orespawn.dimension_materials": "World Materials...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "World Materials", "guide.orespawn.materials.1": "World Materials changes background materials across a whole dimension. Use Fluid Deposits instead when you only want occasional underground pockets.", "guide.orespawn.materials.2": "Aquifer Fluid changes the normal fluid used below sea level. Deep Aquifer uses a second fluid below the chosen Y level.", - "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged." + "guide.orespawn.materials.3": "Snow and Ice replace vanilla snow and ordinary ice near the surface. Not set leaves Minecraft's normal material unchanged.", + "button.orespawn.dimension_materials_named": "%s Materials...", + "label.orespawn.dimension_materials.scope": "Applies to every biome in newly generated %s terrain.", + "button.orespawn.mods": "Mods", + "button.orespawn.mod.configure": "Configure", + "tooltip.orespawn.main.mods": "View loaded mods integrated with OreSpawn.", + "screen.orespawn.mods": "OreSpawn Mods", + "tooltip.orespawn.mod.configure": "Open %s configuration.", + "tooltip.orespawn.mod.unavailable": "%s does not provide an OreSpawn-linked configuration screen.", + "label.orespawn.mod.id_version": "%s - Mod version %s", + "label.orespawn.mod.additional_lineage": "Also detected through OreSpawn %s compatibility.", + "label.orespawn.mod.status": "Status: %s", + "label.orespawn.mod.os4_version": "Native OS4 - schema %s, provider revision %s", + "label.orespawn.mod.os4": "Native OS4 provider", + "label.orespawn.mod.legacy_version": "OreSpawn %s compatibility -> OS4 schema %s, provider revision %s", + "label.orespawn.mod.legacy": "OreSpawn %s compatibility -> OS4", + "label.orespawn.mod.config_only": "Client configuration integration", + "label.orespawn.mod.none": "No loaded OreSpawn integrations were found.", + "label.orespawn.mod.integration_status": "%s - %s", + "status.orespawn.mod.active": "Active", + "status.orespawn.mod.pending": "Pending", + "status.orespawn.mod.inactive": "Inactive", + "status.orespawn.mod.rejected": "Rejected", + "status.orespawn.mod.config_only": "Configuration screen only", + "error.orespawn.mod.screen_missing": "%s did not create a configuration screen.", + "error.orespawn.mod.screen_failed": "Could not open %s configuration (%s).", + "button.orespawn.ore_sources": "Ore Sources...", + "tooltip.orespawn.ore_sources": "Review equivalent ore outputs without multiplying placement frequency.", + "screen.orespawn.ore_sources": "Ore Sources", + "screen.orespawn.ore_source_detail": "Ore Source Policy", + "label.orespawn.ore_source.none": "No equivalent managed ore sources were found.", + "label.orespawn.ore_source.row": "%s - %s - %s candidates - %s", + "label.orespawn.ore_source.intro": "Choose how equivalent ores generate.", + "label.orespawn.ore_source.summary": "%s candidates - %s", + "button.orespawn.ore_source.review": "Review...", + "button.orespawn.ore_source.configure": "Configure...", + "label.orespawn.ore_source.material": "Material: %s", + "label.orespawn.ore_source.domain": "Dimension/domain: %s", + "label.orespawn.ore_source.channels": "Placement channels: %s", + "label.orespawn.ore_source.missing": "Missing placement source", + "label.orespawn.ore_source.owner": "Owner: %s, version %s", + "label.orespawn.ore_source.registry": "Registry output: %s, metadata %s", + "label.orespawn.ore_source.dictionary": "Block tags: %s", + "option.orespawn.ore_source.mode": "Mode: %s", + "option.orespawn.ore_source.channel": "Channel: %s", + "option.orespawn.ore_source.placement": "Placement source: %s", + "option.orespawn.ore_source.weight": "Weight", + "mode.orespawn.ore_source.consolidated": "Consolidated", + "mode.orespawn.ore_source.keep_separate": "Keep Separate", + "status.orespawn.ore_source.consolidated": "Consolidated", + "status.orespawn.ore_source.separate": "Separate", + "status.orespawn.ore_source.review_required": "Review required", + "status.orespawn.ore_source.missing_source": "Missing source", + "status.orespawn.ore_source.external_generation": "External generation", + "tooltip.orespawn.ore_source.open": "Open this material policy.", + "tooltip.orespawn.ore_source.mode": "Consolidated uses one placement budget per channel. Keep Separate preserves every configured rule.", + "tooltip.orespawn.ore_source.channel": "Placement engines are arbitrated independently.", + "tooltip.orespawn.ore_source.placement": "Choose the rule that owns frequency, shape, hosts, filters and height for this channel.", + "tooltip.orespawn.ore_source.weight": "Positive relative chance of selecting this output for a whole vein or identified deposit body.", + "tooltip.orespawn.ore_source.external": "This mod registers generation outside OreSpawn; OreSpawn reports it but cannot suppress it.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controls this configured placement and output source.", + "tooltip.orespawn.ore_source.enrichment": "This entry is enrichment, not an interchangeable ore source.", + "tooltip.orespawn.ore_source.missing": "This source is unavailable. Its saved selection will be restored if the owning mod returns.", + "error.orespawn.ore_source.weight": "Output weights must be positive numbers.", + "button.orespawn.back": "Back", + "button.orespawn.ore_source.all_groups": "All Groups", + "button.orespawn.ore_source.add_group": "Add Group", + "button.orespawn.ore_source.accept": "Accept", + "tooltip.orespawn.ore_source.accept": "Keep the current Keep Original policy unchanged and mark this group as reviewed. It is saved only when the main editor's Done is used.", + "button.orespawn.ore_source.delete_group": "Delete", + "button.orespawn.ore_source.dissolve_group": "Dissolve Group", + "button.orespawn.ore_source.confirm_dissolve": "Confirm Dissolve", + "button.orespawn.ore_source.move": "Move", + "button.orespawn.ore_source.advanced": "Advanced...", + "button.orespawn.ore_source.hide_advanced": "Hide Advanced", + "option.orespawn.ore_source.group_name": "Group name", + "option.orespawn.ore_source.alias": "Block tag", + "option.orespawn.ore_source.output_mode": "Output: %s", + "mode.orespawn.ore_source.keep_original": "Keep Original", + "mode.orespawn.ore_source.balanced": "Balanced", + "mode.orespawn.ore_source.single": "Single", + "mode.orespawn.ore_source.custom": "Custom", + "label.orespawn.ore_source.no_aliases": "No block tags", + "status.orespawn.ore_source.external_short": "external", + "status.orespawn.ore_source.missing_short": "missing", + "status.orespawn.ore_source.output_only": "output only", + "tooltip.orespawn.ore_source.output_only": "This provider output is available, but its placement rule is inactive.", + "tooltip.orespawn.ore_source.output_mode": "Keep Original preserves every rule. Balanced shares equally, Single chooses one block, and Custom enables weighted choices.", + "tooltip.orespawn.ore_source.alias": "Add an exact block tag ID, such as forge:ores/sulfur. Press Move again to confirm reassignment from another group.", + "tooltip.orespawn.ore_source.reset_group": "Restore this curated group name and block tag entries.", + "tooltip.orespawn.ore_source.delete_group": "Delete this empty custom group without rewriting generated chunks.", + "tooltip.orespawn.ore_source.dissolve_group": "Return every entry to its inferred or curated group. Press twice to confirm.", + "error.orespawn.ore_source.confirm_dissolve": "Press Confirm Dissolve to return every block tag entry to its original group.", + "error.orespawn.ore_source.alias": "Use an exact block tag ID such as forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s already owns this entry. Press Move to reassign it.", + "error.orespawn.ore_source.manage_vanilla_first": "Enable Manage Vanilla Ores before moving a native vanilla ore into another group.", + "error.orespawn.ore_source.defaults_write": "Could not save material-group defaults.", + "screen.orespawn.ore_source.group_settings": "Ore Source Group Settings", + "label.orespawn.ore_source.groups": "Groups", + "label.orespawn.ore_source.aliases": "Block tags", + "label.orespawn.ore_source.placement_rules": "Placement Rules", + "label.orespawn.ore_source.placement_explanation": "Outputs choose the block; placement rules choose frequency, shape, depth and hosts.", + "label.orespawn.ore_source.standard_veins": "Standard veins", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choices)", + "label.orespawn.ore_source.no_placement_rules": "No managed placement rules", + "tooltip.orespawn.ore_source.group_settings": "Edit this group's name, aliases and placement rules.", + "tooltip.orespawn.ore_source.remove_alias": "Remove %s from this group.", + "tooltip.orespawn.ore_source.placement_rules": "Outputs select the ore block; placement rules select frequency, shape, depth and host restrictions.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Placement ownership applies only to consolidated Balanced, Single or Custom modes.", + "tooltip.orespawn.ore_source.placement_missing": "The saved placement source is missing; this channel remains disabled.", + "tooltip.orespawn.ore_source.placement_readonly": "Only one active managed rule is available, so this source is read-only.", + "tooltip.orespawn.ore_source.placement_select": "Click to choose between active managed placement sources.", + "tooltip.orespawn.ore_source.external_excluded": "Independent external generators are reported but cannot be selected as placement rules.", + "button.orespawn.ore_source.hide_single": "Hide Singles", + "tooltip.orespawn.ore_source.group_colours": "Green entries have one alias and one output and need no action. Yellow groups have a saved rule. Red groups need attention. Show All includes green entries.", + "button.orespawn.ore_source.reset_all": "Reset All", + "button.orespawn.ore_source.confirm_reset_all": "Confirm Reset", + "tooltip.orespawn.ore_source.reset_all": "Restore built-in material groups and recreate source policies from the currently loaded ores. The change remains pending until the main editor's Done button is used.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Biome Palettes", + "screen.orespawn.biome_palette_settings": "Biome Palette Settings", + "button.orespawn.biome.hide_routine": "Hide Routine", + "button.orespawn.biome.replace_new_terrain": "Replace in new terrain with...", + "button.orespawn.biome.replace_with": "Replace in new terrain with %s...", + "button.orespawn.biome.leave_original": "Leave original behaviour", + "button.orespawn.biome.palettes": "Palettes: %s", + "button.orespawn.biome.reset_selected": "Reset Biome", + "button.orespawn.biome.confirm_reset": "Confirm Reset", + "button.orespawn.biome.reset_palette": "Reset Palette", + "button.orespawn.biome.confirm_reset_palette": "Confirm Palette Reset", + "button.orespawn.biome.reset_dimension": "Reset Dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirm Dimension Reset", + "button.orespawn.biome.reset_all": "Reset All Biomes", + "button.orespawn.biome.confirm_reset_all": "Confirm All-Biome Reset", + "value.orespawn.biome.status.user_replaced": "Replaced in new terrain", + "value.orespawn.biome.status.layered": "Several placement rules", + "value.orespawn.biome.status.disabled": "Placement disabled", + "value.orespawn.biome.status.missing": "Missing biome or provider", + "value.orespawn.biome.status.provider_managed": "Provider-managed", + "value.orespawn.biome.status.unmanaged": "Registered, unmanaged", + "label.orespawn.biome.rule_count": "Placement rules: %s", + "label.orespawn.biome.replaced_by": "New-terrain replacement: %s", + "label.orespawn.biome.palette_owner_order": "Owner: %s; effective order: %s", + "label.orespawn.biome.surface_effective": "This rule supplies the effective surface.", + "label.orespawn.biome.surface_overridden": "A later enabled rule overrides this surface.", + "label.orespawn.biome.placement_rules": "Placement rules", + "label.orespawn.biome.palette_entries": "Placement entries: %s", + "tooltip.orespawn.biome.no_retrogen": "Biome replacements apply only while new terrain is generated. Existing chunks are never rewritten.", + "tooltip.orespawn.biome.override_palette": "Reserved final replacement layer. Edit it through each biome's replacement control.", + "tooltip.orespawn.biome.palette_entries_read_only": "Palette membership is provider/profile-defined. Select a biome in the directory to edit an existing placement rule.", + "option.orespawn.biome.coverage": "Coverage", + "option.orespawn.biome.fallback_weight": "Original-biome weight", + "option.orespawn.biome.include_namespaces": "Included namespaces (comma-separated)", + "option.orespawn.biome.exclude_namespaces": "Excluded namespaces (comma-separated)" } diff --git a/src/main/resources/assets/orespawn/lang/es_es.json b/src/main/resources/assets/orespawn/lang/es_es.json index 01a21912..6879a3aa 100644 --- a/src/main/resources/assets/orespawn/lang/es_es.json +++ b/src/main/resources/assets/orespawn/lang/es_es.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Elegir bioma instalado", "screen.orespawn.biome_placement": "Ajustes del bioma", "screen.orespawn.biome_references": "Biomas similares", - "screen.orespawn.dimension_materials": "Materiales del mundo", + "screen.orespawn.dimension_materials": "Materiales de %s", "button.orespawn.biomes_world_materials": "Biomas y materiales del mundo...", "button.orespawn.biome_palette_count": "Biomas: %s", "button.orespawn.dimension_materials": "Materiales del mundo...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Materiales del mundo", "guide.orespawn.materials.1": "Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.", "guide.orespawn.materials.2": "Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.", - "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft." + "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Materiales de %s...", + "label.orespawn.dimension_materials.scope": "Se aplica a cada bioma del terreno nuevo de %s.", + "button.orespawn.mods": "Directorio de mods", + "button.orespawn.mod.configure": "Configurar", + "tooltip.orespawn.main.mods": "Ver los mods cargados integrados con OreSpawn.", + "screen.orespawn.mods": "Mods de OreSpawn", + "tooltip.orespawn.mod.configure": "Abrir la configuración de %s.", + "tooltip.orespawn.mod.unavailable": "%s no proporciona una pantalla de configuración vinculada con OreSpawn.", + "label.orespawn.mod.id_version": "%s - Versión del mod %s", + "label.orespawn.mod.additional_lineage": "También detectado mediante compatibilidad con OreSpawn %s.", + "label.orespawn.mod.status": "Estado: %s", + "label.orespawn.mod.os4_version": "Proveedor OS4 nativo - esquema %s, revisión del proveedor %s", + "label.orespawn.mod.os4": "Proveedor OS4 nativo", + "label.orespawn.mod.legacy_version": "Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s", + "label.orespawn.mod.legacy": "Compatibilidad con OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Integración de configuración del cliente", + "label.orespawn.mod.none": "No se encontraron integraciones de OreSpawn cargadas.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Activo", + "status.orespawn.mod.pending": "Pendiente", + "status.orespawn.mod.inactive": "Inactivo", + "status.orespawn.mod.rejected": "Rechazado", + "status.orespawn.mod.config_only": "Solo pantalla de configuración", + "error.orespawn.mod.screen_missing": "%s no creó una pantalla de configuración.", + "error.orespawn.mod.screen_failed": "No se pudo abrir la configuración de %s (%s).", + "button.orespawn.ore_sources": "Fuentes de mineral...", + "tooltip.orespawn.ore_sources": "Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación.", + "screen.orespawn.ore_sources": "Fuentes de mineral", + "screen.orespawn.ore_source_detail": "Política de fuente de mineral", + "label.orespawn.ore_source.none": "No se encontraron fuentes de mineral equivalentes gestionadas.", + "label.orespawn.ore_source.row": "%s - %s - %s candidatos - %s", + "label.orespawn.ore_source.intro": "Elige cómo se generan los minerales equivalentes.", + "label.orespawn.ore_source.summary": "%s candidatos - %s", + "button.orespawn.ore_source.review": "Revisar...", + "button.orespawn.ore_source.configure": "Configurar...", + "label.orespawn.ore_source.material": "Materia: %s", + "label.orespawn.ore_source.domain": "Dimensión/dominio: %s", + "label.orespawn.ore_source.channels": "Canales de colocación: %s", + "label.orespawn.ore_source.missing": "Falta la fuente de colocación", + "label.orespawn.ore_source.owner": "Propietario: %s, versión %s", + "label.orespawn.ore_source.registry": "Salida registrada: %s, metadatos %s", + "label.orespawn.ore_source.dictionary": "Etiquetas de bloques: %s", + "option.orespawn.ore_source.mode": "Modo: %s", + "option.orespawn.ore_source.channel": "Canal: %s", + "option.orespawn.ore_source.placement": "Fuente de colocación: %s", + "option.orespawn.ore_source.weight": "Peso", + "mode.orespawn.ore_source.consolidated": "Consolidado", + "mode.orespawn.ore_source.keep_separate": "Mantener separado", + "status.orespawn.ore_source.consolidated": "Consolidado", + "status.orespawn.ore_source.separate": "Separado", + "status.orespawn.ore_source.review_required": "Revisión necesaria", + "status.orespawn.ore_source.missing_source": "Falta la fuente", + "status.orespawn.ore_source.external_generation": "Generación externa", + "tooltip.orespawn.ore_source.open": "Abre esta política de material.", + "tooltip.orespawn.ore_source.mode": "Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada.", + "tooltip.orespawn.ore_source.channel": "Los motores de colocación se arbitran de forma independiente.", + "tooltip.orespawn.ore_source.placement": "Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal.", + "tooltip.orespawn.ore_source.weight": "Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado.", + "tooltip.orespawn.ore_source.external": "Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controla esta colocación y fuente de salida configuradas.", + "tooltip.orespawn.ore_source.enrichment": "Esta entrada es enriquecimiento, no una fuente de mineral intercambiable.", + "tooltip.orespawn.ore_source.missing": "Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario.", + "error.orespawn.ore_source.weight": "Los pesos de salida deben ser números positivos.", + "button.orespawn.back": "Atrás", + "button.orespawn.ore_source.all_groups": "Todos los grupos", + "button.orespawn.ore_source.add_group": "Añadir grupo", + "button.orespawn.ore_source.accept": "Aceptar", + "tooltip.orespawn.ore_source.accept": "Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal.", + "button.orespawn.ore_source.delete_group": "Eliminar", + "button.orespawn.ore_source.dissolve_group": "Disolver grupo", + "button.orespawn.ore_source.confirm_dissolve": "Confirmar disolución", + "button.orespawn.ore_source.move": "Mover", + "button.orespawn.ore_source.advanced": "Avanzado...", + "button.orespawn.ore_source.hide_advanced": "Ocultar avanzado", + "option.orespawn.ore_source.group_name": "Nombre del grupo", + "option.orespawn.ore_source.alias": "Entrada del sistema de etiquetas de bloques", + "option.orespawn.ore_source.output_mode": "Salida: %s", + "mode.orespawn.ore_source.keep_original": "Mantener original", + "mode.orespawn.ore_source.balanced": "Equilibrado", + "mode.orespawn.ore_source.single": "Único", + "mode.orespawn.ore_source.custom": "Personalizado", + "label.orespawn.ore_source.no_aliases": "Sin entradas del sistema de etiquetas de bloques", + "status.orespawn.ore_source.external_short": "externo", + "status.orespawn.ore_source.missing_short": "ausente", + "status.orespawn.ore_source.output_only": "solo salida", + "tooltip.orespawn.ore_source.output_only": "Esta salida del proveedor está disponible, pero su regla de colocación está inactiva.", + "tooltip.orespawn.ore_source.output_mode": "Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas.", + "tooltip.orespawn.ore_source.alias": "Añade un nombre exacto de mineral del sistema de etiquetas de bloques. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo.", + "tooltip.orespawn.ore_source.reset_group": "Restaura el nombre y las entradas del sistema de etiquetas de bloques de este grupo seleccionado.", + "tooltip.orespawn.ore_source.delete_group": "Elimina este grupo personalizado vacío sin reescribir los chunks generados.", + "tooltip.orespawn.ore_source.dissolve_group": "Devuelve cada entrada a su grupo inferido o revisado. Pulsa dos veces para confirmar.", + "error.orespawn.ore_source.confirm_dissolve": "Pulsa Confirmar disolución para devolver cada entrada del sistema de etiquetas de bloques a su grupo original.", + "error.orespawn.ore_source.alias": "Usa un nombre exacto del sistema de etiquetas de bloques, como forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s ya posee esta entrada. Pulsa Mover para reasignarla.", + "error.orespawn.ore_source.manage_vanilla_first": "Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo.", + "error.orespawn.ore_source.defaults_write": "No se pudieron guardar los valores predeterminados de los grupos de materiales.", + "screen.orespawn.ore_source.group_settings": "Configuración del grupo de fuentes de mena", + "label.orespawn.ore_source.groups": "Grupos", + "label.orespawn.ore_source.aliases": "Entradas del sistema de etiquetas de bloques de menas", + "label.orespawn.ore_source.placement_rules": "Reglas de colocación", + "label.orespawn.ore_source.placement_explanation": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona.", + "label.orespawn.ore_source.standard_veins": "Vetas estándar", + "label.orespawn.ore_source.placement_choice_count": "%s (%s opciones)", + "label.orespawn.ore_source.no_placement_rules": "Sin reglas de colocación administradas", + "tooltip.orespawn.ore_source.group_settings": "Edita el nombre, los alias y las reglas de colocación de este grupo.", + "tooltip.orespawn.ore_source.remove_alias": "Eliminar %s de este grupo.", + "tooltip.orespawn.ore_source.placement_rules": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y restricciones de roca anfitriona.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "La selección de colocación solo se aplica a los modos consolidados Equilibrado, Único o Personalizado.", + "tooltip.orespawn.ore_source.placement_missing": "Falta la fuente de colocación guardada; este canal permanece desactivado.", + "tooltip.orespawn.ore_source.placement_readonly": "Solo hay una regla administrada activa; esta fuente es de solo lectura.", + "tooltip.orespawn.ore_source.placement_select": "Haz clic para elegir entre fuentes de colocación administradas activas.", + "tooltip.orespawn.ore_source.external_excluded": "Los generadores externos se informan, pero no pueden elegirse como reglas de colocación.", + "button.orespawn.ore_source.hide_single": "Ocultar individuales", + "tooltip.orespawn.ore_source.group_colours": "Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes.", + "button.orespawn.ore_source.reset_all": "Restablecer todo", + "button.orespawn.ore_source.confirm_reset_all": "Confirmar restablecimiento", + "tooltip.orespawn.ore_source.reset_all": "Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal.", + "screen.orespawn.biomes": "Biomas", + "screen.orespawn.biome_palettes": "Paletas de biomas", + "screen.orespawn.biome_palette_settings": "Ajustes de paleta de biomas", + "button.orespawn.biome.hide_routine": "Ocultar normales", + "button.orespawn.biome.replace_new_terrain": "Reemplazar en terreno nuevo por...", + "button.orespawn.biome.replace_with": "Reemplazar en terreno nuevo por %s...", + "button.orespawn.biome.leave_original": "Mantener el comportamiento original", + "button.orespawn.biome.palettes": "Paletas: %s", + "button.orespawn.biome.reset_selected": "Restablecer bioma", + "button.orespawn.biome.confirm_reset": "Confirmar restablecimiento", + "button.orespawn.biome.reset_palette": "Restablecer paleta", + "button.orespawn.biome.confirm_reset_palette": "Confirmar paleta", + "button.orespawn.biome.reset_dimension": "Restablecer dimensión", + "button.orespawn.biome.confirm_reset_dimension": "Confirmar dimensión", + "button.orespawn.biome.reset_all": "Restablecer todos los biomas", + "button.orespawn.biome.confirm_reset_all": "Confirmar todo", + "value.orespawn.biome.status.user_replaced": "Reemplazado en terreno nuevo", + "value.orespawn.biome.status.layered": "Varias reglas de colocación", + "value.orespawn.biome.status.disabled": "Colocación desactivada", + "value.orespawn.biome.status.missing": "Falta el bioma o proveedor", + "value.orespawn.biome.status.provider_managed": "Gestionado por proveedor", + "value.orespawn.biome.status.unmanaged": "Registrado, no gestionado", + "label.orespawn.biome.rule_count": "Reglas de colocación: %s", + "label.orespawn.biome.replaced_by": "Reemplazo en terreno nuevo: %s", + "label.orespawn.biome.palette_owner_order": "Propietario: %s; orden efectivo: %s", + "label.orespawn.biome.surface_effective": "Esta regla aporta la superficie efectiva.", + "label.orespawn.biome.surface_overridden": "Una regla activa posterior reemplaza esta superficie.", + "label.orespawn.biome.placement_rules": "Reglas de colocación", + "label.orespawn.biome.palette_entries": "Entradas de colocación: %s", + "tooltip.orespawn.biome.no_retrogen": "Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben.", + "tooltip.orespawn.biome.override_palette": "Capa final reservada para reemplazos. Edítala desde el control de reemplazo de cada bioma.", + "tooltip.orespawn.biome.palette_entries_read_only": "La pertenencia a la paleta la define el proveedor o perfil. Selecciona un bioma para editar una regla existente.", + "option.orespawn.biome.coverage": "Cobertura", + "option.orespawn.biome.fallback_weight": "Peso del bioma original", + "option.orespawn.biome.include_namespaces": "Espacios de nombres incluidos (separados por comas)", + "option.orespawn.biome.exclude_namespaces": "Espacios de nombres excluidos (separados por comas)" } diff --git a/src/main/resources/assets/orespawn/lang/es_mx.json b/src/main/resources/assets/orespawn/lang/es_mx.json index 01a21912..6879a3aa 100644 --- a/src/main/resources/assets/orespawn/lang/es_mx.json +++ b/src/main/resources/assets/orespawn/lang/es_mx.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Elegir bioma instalado", "screen.orespawn.biome_placement": "Ajustes del bioma", "screen.orespawn.biome_references": "Biomas similares", - "screen.orespawn.dimension_materials": "Materiales del mundo", + "screen.orespawn.dimension_materials": "Materiales de %s", "button.orespawn.biomes_world_materials": "Biomas y materiales del mundo...", "button.orespawn.biome_palette_count": "Biomas: %s", "button.orespawn.dimension_materials": "Materiales del mundo...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Materiales del mundo", "guide.orespawn.materials.1": "Materiales del mundo cambia materiales de fondo en toda una dimensión. Usa Depósitos de fluidos si solo quieres bolsas subterráneas ocasionales.", "guide.orespawn.materials.2": "Fluido de acuífero cambia el fluido normal bajo el nivel del mar. Acuífero profundo usa un segundo fluido por debajo del nivel Y elegido.", - "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft." + "guide.orespawn.materials.3": "Nieve y Hielo sustituyen la nieve vanilla y el hielo normal cerca de la superficie. Sin configurar conserva el material normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Materiales de %s...", + "label.orespawn.dimension_materials.scope": "Se aplica a cada bioma del terreno nuevo de %s.", + "button.orespawn.mods": "Directorio de mods", + "button.orespawn.mod.configure": "Configurar", + "tooltip.orespawn.main.mods": "Ver los mods cargados integrados con OreSpawn.", + "screen.orespawn.mods": "Mods de OreSpawn", + "tooltip.orespawn.mod.configure": "Abrir la configuración de %s.", + "tooltip.orespawn.mod.unavailable": "%s no proporciona una pantalla de configuración vinculada con OreSpawn.", + "label.orespawn.mod.id_version": "%s - Versión del mod %s", + "label.orespawn.mod.additional_lineage": "También detectado mediante compatibilidad con OreSpawn %s.", + "label.orespawn.mod.status": "Estado: %s", + "label.orespawn.mod.os4_version": "Proveedor OS4 nativo - esquema %s, revisión del proveedor %s", + "label.orespawn.mod.os4": "Proveedor OS4 nativo", + "label.orespawn.mod.legacy_version": "Compatibilidad con OreSpawn %s -> esquema OS4 %s, revisión del proveedor %s", + "label.orespawn.mod.legacy": "Compatibilidad con OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Integración de configuración del cliente", + "label.orespawn.mod.none": "No se encontraron integraciones de OreSpawn cargadas.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Activo", + "status.orespawn.mod.pending": "Pendiente", + "status.orespawn.mod.inactive": "Inactivo", + "status.orespawn.mod.rejected": "Rechazado", + "status.orespawn.mod.config_only": "Solo pantalla de configuración", + "error.orespawn.mod.screen_missing": "%s no creó una pantalla de configuración.", + "error.orespawn.mod.screen_failed": "No se pudo abrir la configuración de %s (%s).", + "button.orespawn.ore_sources": "Fuentes de mineral...", + "tooltip.orespawn.ore_sources": "Revisa salidas de mineral equivalentes sin multiplicar la frecuencia de colocación.", + "screen.orespawn.ore_sources": "Fuentes de mineral", + "screen.orespawn.ore_source_detail": "Política de fuente de mineral", + "label.orespawn.ore_source.none": "No se encontraron fuentes de mineral equivalentes gestionadas.", + "label.orespawn.ore_source.row": "%s - %s - %s candidatos - %s", + "label.orespawn.ore_source.intro": "Elige cómo se generan los minerales equivalentes.", + "label.orespawn.ore_source.summary": "%s candidatos - %s", + "button.orespawn.ore_source.review": "Revisar...", + "button.orespawn.ore_source.configure": "Configurar...", + "label.orespawn.ore_source.material": "Materia: %s", + "label.orespawn.ore_source.domain": "Dimensión/dominio: %s", + "label.orespawn.ore_source.channels": "Canales de colocación: %s", + "label.orespawn.ore_source.missing": "Falta la fuente de colocación", + "label.orespawn.ore_source.owner": "Propietario: %s, versión %s", + "label.orespawn.ore_source.registry": "Salida registrada: %s, metadatos %s", + "label.orespawn.ore_source.dictionary": "Etiquetas de bloques: %s", + "option.orespawn.ore_source.mode": "Modo: %s", + "option.orespawn.ore_source.channel": "Canal: %s", + "option.orespawn.ore_source.placement": "Fuente de colocación: %s", + "option.orespawn.ore_source.weight": "Peso", + "mode.orespawn.ore_source.consolidated": "Consolidado", + "mode.orespawn.ore_source.keep_separate": "Mantener separado", + "status.orespawn.ore_source.consolidated": "Consolidado", + "status.orespawn.ore_source.separate": "Separado", + "status.orespawn.ore_source.review_required": "Revisión necesaria", + "status.orespawn.ore_source.missing_source": "Falta la fuente", + "status.orespawn.ore_source.external_generation": "Generación externa", + "tooltip.orespawn.ore_source.open": "Abre esta política de material.", + "tooltip.orespawn.ore_source.mode": "Consolidado usa un presupuesto de colocación por canal. Mantener separado conserva cada regla configurada.", + "tooltip.orespawn.ore_source.channel": "Los motores de colocación se arbitran de forma independiente.", + "tooltip.orespawn.ore_source.placement": "Elige la regla que controla frecuencia, forma, huéspedes, filtros y altura de este canal.", + "tooltip.orespawn.ore_source.weight": "Probabilidad relativa positiva de elegir esta salida para una veta completa o un depósito identificado.", + "tooltip.orespawn.ore_source.external": "Este mod registra generación fuera de OreSpawn; OreSpawn la muestra, pero no puede impedirla.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn controla esta colocación y fuente de salida configuradas.", + "tooltip.orespawn.ore_source.enrichment": "Esta entrada es enriquecimiento, no una fuente de mineral intercambiable.", + "tooltip.orespawn.ore_source.missing": "Esta fuente no está disponible. La selección guardada se restaurará si vuelve el mod propietario.", + "error.orespawn.ore_source.weight": "Los pesos de salida deben ser números positivos.", + "button.orespawn.back": "Atrás", + "button.orespawn.ore_source.all_groups": "Todos los grupos", + "button.orespawn.ore_source.add_group": "Añadir grupo", + "button.orespawn.ore_source.accept": "Aceptar", + "tooltip.orespawn.ore_source.accept": "Conserva sin cambios «Mantener original» y marca el grupo como revisado. Solo se guarda al pulsar Hecho en el editor principal.", + "button.orespawn.ore_source.delete_group": "Eliminar", + "button.orespawn.ore_source.dissolve_group": "Disolver grupo", + "button.orespawn.ore_source.confirm_dissolve": "Confirmar disolución", + "button.orespawn.ore_source.move": "Mover", + "button.orespawn.ore_source.advanced": "Avanzado...", + "button.orespawn.ore_source.hide_advanced": "Ocultar avanzado", + "option.orespawn.ore_source.group_name": "Nombre del grupo", + "option.orespawn.ore_source.alias": "Entrada del sistema de etiquetas de bloques", + "option.orespawn.ore_source.output_mode": "Salida: %s", + "mode.orespawn.ore_source.keep_original": "Mantener original", + "mode.orespawn.ore_source.balanced": "Equilibrado", + "mode.orespawn.ore_source.single": "Único", + "mode.orespawn.ore_source.custom": "Personalizado", + "label.orespawn.ore_source.no_aliases": "Sin entradas del sistema de etiquetas de bloques", + "status.orespawn.ore_source.external_short": "externo", + "status.orespawn.ore_source.missing_short": "ausente", + "status.orespawn.ore_source.output_only": "solo salida", + "tooltip.orespawn.ore_source.output_only": "Esta salida del proveedor está disponible, pero su regla de colocación está inactiva.", + "tooltip.orespawn.ore_source.output_mode": "Mantener original conserva cada regla. Equilibrado reparte por igual, Único elige un bloque y Personalizado permite elecciones ponderadas.", + "tooltip.orespawn.ore_source.alias": "Añade un nombre exacto de mineral del sistema de etiquetas de bloques. Pulsa Mover otra vez para confirmar la reasignación desde otro grupo.", + "tooltip.orespawn.ore_source.reset_group": "Restaura el nombre y las entradas del sistema de etiquetas de bloques de este grupo seleccionado.", + "tooltip.orespawn.ore_source.delete_group": "Elimina este grupo personalizado vacío sin reescribir los chunks generados.", + "tooltip.orespawn.ore_source.dissolve_group": "Devuelve cada entrada a su grupo inferido o revisado. Pulsa dos veces para confirmar.", + "error.orespawn.ore_source.confirm_dissolve": "Pulsa Confirmar disolución para devolver cada entrada del sistema de etiquetas de bloques a su grupo original.", + "error.orespawn.ore_source.alias": "Usa un nombre exacto del sistema de etiquetas de bloques, como forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s ya posee esta entrada. Pulsa Mover para reasignarla.", + "error.orespawn.ore_source.manage_vanilla_first": "Activa «Gestionar minerales de Vanilla» antes de mover un mineral nativo de Vanilla a otro grupo.", + "error.orespawn.ore_source.defaults_write": "No se pudieron guardar los valores predeterminados de los grupos de materiales.", + "screen.orespawn.ore_source.group_settings": "Configuración del grupo de fuentes de mena", + "label.orespawn.ore_source.groups": "Grupos", + "label.orespawn.ore_source.aliases": "Entradas del sistema de etiquetas de bloques de menas", + "label.orespawn.ore_source.placement_rules": "Reglas de colocación", + "label.orespawn.ore_source.placement_explanation": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y roca anfitriona.", + "label.orespawn.ore_source.standard_veins": "Vetas estándar", + "label.orespawn.ore_source.placement_choice_count": "%s (%s opciones)", + "label.orespawn.ore_source.no_placement_rules": "Sin reglas de colocación administradas", + "tooltip.orespawn.ore_source.group_settings": "Edita el nombre, los alias y las reglas de colocación de este grupo.", + "tooltip.orespawn.ore_source.remove_alias": "Eliminar %s de este grupo.", + "tooltip.orespawn.ore_source.placement_rules": "Las salidas eligen el bloque; las reglas eligen frecuencia, forma, profundidad y restricciones de roca anfitriona.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "La selección de colocación solo se aplica a los modos consolidados Equilibrado, Único o Personalizado.", + "tooltip.orespawn.ore_source.placement_missing": "Falta la fuente de colocación guardada; este canal permanece desactivado.", + "tooltip.orespawn.ore_source.placement_readonly": "Solo hay una regla administrada activa; esta fuente es de solo lectura.", + "tooltip.orespawn.ore_source.placement_select": "Haz clic para elegir entre fuentes de colocación administradas activas.", + "tooltip.orespawn.ore_source.external_excluded": "Los generadores externos se informan, pero no pueden elegirse como reglas de colocación.", + "button.orespawn.ore_source.hide_single": "Ocultar individuales", + "tooltip.orespawn.ore_source.group_colours": "Las entradas verdes tienen un alias y una salida y no requieren acción. Los grupos amarillos tienen una regla guardada. Los grupos rojos requieren atención. Mostrar todo incluye las entradas verdes.", + "button.orespawn.ore_source.reset_all": "Restablecer todo", + "button.orespawn.ore_source.confirm_reset_all": "Confirmar restablecimiento", + "tooltip.orespawn.ore_source.reset_all": "Restaura los grupos de materiales incorporados y vuelve a crear las políticas desde los minerales cargados. El cambio queda pendiente hasta pulsar Hecho en el editor principal.", + "screen.orespawn.biomes": "Biomas", + "screen.orespawn.biome_palettes": "Paletas de biomas", + "screen.orespawn.biome_palette_settings": "Ajustes de paleta de biomas", + "button.orespawn.biome.hide_routine": "Ocultar normales", + "button.orespawn.biome.replace_new_terrain": "Reemplazar en terreno nuevo por...", + "button.orespawn.biome.replace_with": "Reemplazar en terreno nuevo por %s...", + "button.orespawn.biome.leave_original": "Mantener el comportamiento original", + "button.orespawn.biome.palettes": "Paletas: %s", + "button.orespawn.biome.reset_selected": "Restablecer bioma", + "button.orespawn.biome.confirm_reset": "Confirmar restablecimiento", + "button.orespawn.biome.reset_palette": "Restablecer paleta", + "button.orespawn.biome.confirm_reset_palette": "Confirmar paleta", + "button.orespawn.biome.reset_dimension": "Restablecer dimensión", + "button.orespawn.biome.confirm_reset_dimension": "Confirmar dimensión", + "button.orespawn.biome.reset_all": "Restablecer todos los biomas", + "button.orespawn.biome.confirm_reset_all": "Confirmar todo", + "value.orespawn.biome.status.user_replaced": "Reemplazado en terreno nuevo", + "value.orespawn.biome.status.layered": "Varias reglas de colocación", + "value.orespawn.biome.status.disabled": "Colocación desactivada", + "value.orespawn.biome.status.missing": "Falta el bioma o proveedor", + "value.orespawn.biome.status.provider_managed": "Gestionado por proveedor", + "value.orespawn.biome.status.unmanaged": "Registrado, no gestionado", + "label.orespawn.biome.rule_count": "Reglas de colocación: %s", + "label.orespawn.biome.replaced_by": "Reemplazo en terreno nuevo: %s", + "label.orespawn.biome.palette_owner_order": "Propietario: %s; orden efectivo: %s", + "label.orespawn.biome.surface_effective": "Esta regla aporta la superficie efectiva.", + "label.orespawn.biome.surface_overridden": "Una regla activa posterior reemplaza esta superficie.", + "label.orespawn.biome.placement_rules": "Reglas de colocación", + "label.orespawn.biome.palette_entries": "Entradas de colocación: %s", + "tooltip.orespawn.biome.no_retrogen": "Los reemplazos de bioma solo se aplican al generar terreno nuevo. Los chunks existentes nunca se reescriben.", + "tooltip.orespawn.biome.override_palette": "Capa final reservada para reemplazos. Edítala desde el control de reemplazo de cada bioma.", + "tooltip.orespawn.biome.palette_entries_read_only": "La pertenencia a la paleta la define el proveedor o perfil. Selecciona un bioma para editar una regla existente.", + "option.orespawn.biome.coverage": "Cobertura", + "option.orespawn.biome.fallback_weight": "Peso del bioma original", + "option.orespawn.biome.include_namespaces": "Espacios de nombres incluidos (separados por comas)", + "option.orespawn.biome.exclude_namespaces": "Espacios de nombres excluidos (separados por comas)" } diff --git a/src/main/resources/assets/orespawn/lang/fr_ca.json b/src/main/resources/assets/orespawn/lang/fr_ca.json index d65f16ca..4505a4da 100644 --- a/src/main/resources/assets/orespawn/lang/fr_ca.json +++ b/src/main/resources/assets/orespawn/lang/fr_ca.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choisir un biome installé", "screen.orespawn.biome_placement": "Paramètres du biome", "screen.orespawn.biome_references": "Biomes similaires", - "screen.orespawn.dimension_materials": "Matériaux du monde", + "screen.orespawn.dimension_materials": "Matériaux de %s", "button.orespawn.biomes_world_materials": "Biomes et matériaux du monde...", "button.orespawn.biome_palette_count": "Biomes : %s", "button.orespawn.dimension_materials": "Matériaux du monde...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Matériaux du monde", "guide.orespawn.materials.1": "Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.", "guide.orespawn.materials.2": "Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.", - "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft." + "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Matériaux de %s...", + "label.orespawn.dimension_materials.scope": "S'applique à chaque biome du nouveau terrain de %s.", + "button.orespawn.mods": "Répertoire des mods", + "button.orespawn.mod.configure": "Configurer", + "tooltip.orespawn.main.mods": "Afficher les mods chargés intégrés à OreSpawn.", + "screen.orespawn.mods": "Mods OreSpawn", + "tooltip.orespawn.mod.configure": "Ouvrir la configuration de %s.", + "tooltip.orespawn.mod.unavailable": "%s ne fournit pas d'écran de configuration lié à OreSpawn.", + "label.orespawn.mod.id_version": "%s - Version du mod %s", + "label.orespawn.mod.additional_lineage": "Également détecté via la compatibilité OreSpawn %s.", + "label.orespawn.mod.status": "État : %s", + "label.orespawn.mod.os4_version": "Fournisseur OS4 natif - schéma %s, révision du fournisseur %s", + "label.orespawn.mod.os4": "Fournisseur OS4 natif", + "label.orespawn.mod.legacy_version": "Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s", + "label.orespawn.mod.legacy": "Compatibilité OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Intégration de configuration client", + "label.orespawn.mod.none": "Aucune intégration OreSpawn chargée n'a été trouvée.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Actif", + "status.orespawn.mod.pending": "En attente", + "status.orespawn.mod.inactive": "Inactif", + "status.orespawn.mod.rejected": "Rejeté", + "status.orespawn.mod.config_only": "Écran de configuration uniquement", + "error.orespawn.mod.screen_missing": "%s n'a pas créé d'écran de configuration.", + "error.orespawn.mod.screen_failed": "Impossible d'ouvrir la configuration de %s (%s).", + "button.orespawn.ore_sources": "Sources de minerai...", + "tooltip.orespawn.ore_sources": "Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement.", + "screen.orespawn.ore_sources": "Sources de minerai", + "screen.orespawn.ore_source_detail": "Politique de source de minerai", + "label.orespawn.ore_source.none": "Aucune source de minerai gérée équivalente n'a été trouvée.", + "label.orespawn.ore_source.row": "%s - %s - %s candidats - %s", + "label.orespawn.ore_source.intro": "Choisissez comment les minerais équivalents sont générés.", + "label.orespawn.ore_source.summary": "%s candidats - %s", + "button.orespawn.ore_source.review": "Examiner...", + "button.orespawn.ore_source.configure": "Configurer...", + "label.orespawn.ore_source.material": "Matériau : %s", + "label.orespawn.ore_source.domain": "Dimension/domaine : %s", + "label.orespawn.ore_source.channels": "Canaux de placement : %s", + "label.orespawn.ore_source.missing": "Source de placement manquante", + "label.orespawn.ore_source.owner": "Propriétaire : %s, version %s", + "label.orespawn.ore_source.registry": "Sortie enregistrée : %s, métadonnées %s", + "label.orespawn.ore_source.dictionary": "Tags de blocs : %s", + "option.orespawn.ore_source.mode": "Mode : %s", + "option.orespawn.ore_source.channel": "Canal : %s", + "option.orespawn.ore_source.placement": "Source de placement : %s", + "option.orespawn.ore_source.weight": "Poids", + "mode.orespawn.ore_source.consolidated": "Consolidé", + "mode.orespawn.ore_source.keep_separate": "Garder séparé", + "status.orespawn.ore_source.consolidated": "Consolidé", + "status.orespawn.ore_source.separate": "Séparé", + "status.orespawn.ore_source.review_required": "Révision requise", + "status.orespawn.ore_source.missing_source": "Source manquante", + "status.orespawn.ore_source.external_generation": "Génération externe", + "tooltip.orespawn.ore_source.open": "Ouvrir cette politique de matériau.", + "tooltip.orespawn.ore_source.mode": "Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée.", + "tooltip.orespawn.ore_source.channel": "Les moteurs de placement sont arbitrés indépendamment.", + "tooltip.orespawn.ore_source.placement": "Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal.", + "tooltip.orespawn.ore_source.weight": "Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié.", + "tooltip.orespawn.ore_source.external": "Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn contrôle cette source de placement et de sortie configurée.", + "tooltip.orespawn.ore_source.enrichment": "Cette entrée est un enrichissement, pas une source de minerai interchangeable.", + "tooltip.orespawn.ore_source.missing": "Cette source est indisponible. Le choix enregistré sera restauré si son mod revient.", + "error.orespawn.ore_source.weight": "Les poids de sortie doivent être des nombres positifs.", + "button.orespawn.back": "Retour", + "button.orespawn.ore_source.all_groups": "Tous les groupes", + "button.orespawn.ore_source.add_group": "Ajouter un groupe", + "button.orespawn.ore_source.accept": "Accepter", + "tooltip.orespawn.ore_source.accept": "Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal.", + "button.orespawn.ore_source.delete_group": "Supprimer", + "button.orespawn.ore_source.dissolve_group": "Dissoudre le groupe", + "button.orespawn.ore_source.confirm_dissolve": "Confirmer la dissolution", + "button.orespawn.ore_source.move": "Déplacer", + "button.orespawn.ore_source.advanced": "Avancé...", + "button.orespawn.ore_source.hide_advanced": "Masquer les options avancées", + "option.orespawn.ore_source.group_name": "Nom du groupe", + "option.orespawn.ore_source.alias": "Entrée du système de tags de blocs", + "option.orespawn.ore_source.output_mode": "Sortie : %s", + "mode.orespawn.ore_source.keep_original": "Conserver l'origine", + "mode.orespawn.ore_source.balanced": "Équilibré", + "mode.orespawn.ore_source.single": "Unique", + "mode.orespawn.ore_source.custom": "Personnalisé", + "label.orespawn.ore_source.no_aliases": "Aucune entrée du système de tags de blocs", + "status.orespawn.ore_source.external_short": "externe", + "status.orespawn.ore_source.missing_short": "absente", + "status.orespawn.ore_source.output_only": "sortie uniquement", + "tooltip.orespawn.ore_source.output_only": "Cette sortie de fournisseur est disponible, mais sa règle de placement est inactive.", + "tooltip.orespawn.ore_source.output_mode": "Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés.", + "tooltip.orespawn.ore_source.alias": "Ajoutez un nom exact de minerai du système de tags de blocs. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe.", + "tooltip.orespawn.ore_source.reset_group": "Restaure le nom et les entrées du système de tags de blocs de ce groupe sélectionné.", + "tooltip.orespawn.ore_source.delete_group": "Supprime ce groupe personnalisé vide sans réécrire les chunks générés.", + "tooltip.orespawn.ore_source.dissolve_group": "Replace chaque entrée dans son groupe déduit ou prédéfini. Appuyez deux fois pour confirmer.", + "error.orespawn.ore_source.confirm_dissolve": "Appuyez sur Confirmer la dissolution pour replacer chaque entrée du système de tags de blocs dans son groupe d'origine.", + "error.orespawn.ore_source.alias": "Utilisez un nom exact du système de tags de blocs, comme forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter.", + "error.orespawn.ore_source.manage_vanilla_first": "Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe.", + "error.orespawn.ore_source.defaults_write": "Impossible d'enregistrer les valeurs par défaut des groupes de matériaux.", + "screen.orespawn.ore_source.group_settings": "Paramètres du groupe de sources de minerai", + "label.orespawn.ore_source.groups": "Groupes", + "label.orespawn.ore_source.aliases": "Entrées du système de tags de blocs", + "label.orespawn.ore_source.placement_rules": "Règles de placement", + "label.orespawn.ore_source.placement_explanation": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes.", + "label.orespawn.ore_source.standard_veins": "Veines standard", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choix)", + "label.orespawn.ore_source.no_placement_rules": "Aucune règle de placement gérée", + "tooltip.orespawn.ore_source.group_settings": "Modifier le nom, les alias et les règles de placement de ce groupe.", + "tooltip.orespawn.ore_source.remove_alias": "Retirer %s de ce groupe.", + "tooltip.orespawn.ore_source.placement_rules": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et restrictions d'hôte.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Le propriétaire du placement s'applique seulement aux modes consolidés Équilibré, Unique ou Personnalisé.", + "tooltip.orespawn.ore_source.placement_missing": "La source de placement enregistrée manque; ce canal reste désactivé.", + "tooltip.orespawn.ore_source.placement_readonly": "Une seule règle gérée active est disponible; cette source est en lecture seule.", + "tooltip.orespawn.ore_source.placement_select": "Cliquez pour choisir entre les sources de placement gérées actives.", + "tooltip.orespawn.ore_source.external_excluded": "Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement.", + "button.orespawn.ore_source.hide_single": "Masquer les entrées simples", + "tooltip.orespawn.ore_source.group_colours": "Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes.", + "button.orespawn.ore_source.reset_all": "Tout réinitialiser", + "button.orespawn.ore_source.confirm_reset_all": "Confirmer la réinitialisation", + "tooltip.orespawn.ore_source.reset_all": "Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Palettes de biomes", + "screen.orespawn.biome_palette_settings": "Paramètres de palette de biomes", + "button.orespawn.biome.hide_routine": "Masquer les ordinaires", + "button.orespawn.biome.replace_new_terrain": "Remplacer dans le nouveau terrain par...", + "button.orespawn.biome.replace_with": "Remplacer dans le nouveau terrain par %s...", + "button.orespawn.biome.leave_original": "Conserver le comportement d'origine", + "button.orespawn.biome.palettes": "Palettes : %s", + "button.orespawn.biome.reset_selected": "Réinitialiser le biome", + "button.orespawn.biome.confirm_reset": "Confirmer la réinitialisation", + "button.orespawn.biome.reset_palette": "Réinitialiser la palette", + "button.orespawn.biome.confirm_reset_palette": "Confirmer la palette", + "button.orespawn.biome.reset_dimension": "Réinitialiser la dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirmer la dimension", + "button.orespawn.biome.reset_all": "Réinitialiser tous les biomes", + "button.orespawn.biome.confirm_reset_all": "Tout confirmer", + "value.orespawn.biome.status.user_replaced": "Remplacé dans le nouveau terrain", + "value.orespawn.biome.status.layered": "Plusieurs règles de placement", + "value.orespawn.biome.status.disabled": "Placement désactivé", + "value.orespawn.biome.status.missing": "Biome ou fournisseur manquant", + "value.orespawn.biome.status.provider_managed": "Géré par le fournisseur", + "value.orespawn.biome.status.unmanaged": "Enregistré, non géré", + "label.orespawn.biome.rule_count": "Règles de placement : %s", + "label.orespawn.biome.replaced_by": "Remplacement du nouveau terrain : %s", + "label.orespawn.biome.palette_owner_order": "Propriétaire : %s ; ordre effectif : %s", + "label.orespawn.biome.surface_effective": "Cette règle fournit la surface effective.", + "label.orespawn.biome.surface_overridden": "Une règle active ultérieure remplace cette surface.", + "label.orespawn.biome.placement_rules": "Règles de placement", + "label.orespawn.biome.palette_entries": "Entrées de placement : %s", + "tooltip.orespawn.biome.no_retrogen": "Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits.", + "tooltip.orespawn.biome.override_palette": "Couche finale réservée aux remplacements. Modifiez-la avec le contrôle de remplacement de chaque biome.", + "tooltip.orespawn.biome.palette_entries_read_only": "L'appartenance à la palette est définie par le fournisseur ou le profil. Sélectionnez un biome pour modifier une règle existante.", + "option.orespawn.biome.coverage": "Couverture", + "option.orespawn.biome.fallback_weight": "Poids du biome d'origine", + "option.orespawn.biome.include_namespaces": "Espaces de noms inclus (séparés par des virgules)", + "option.orespawn.biome.exclude_namespaces": "Espaces de noms exclus (séparés par des virgules)" } diff --git a/src/main/resources/assets/orespawn/lang/fr_fr.json b/src/main/resources/assets/orespawn/lang/fr_fr.json index d65f16ca..4505a4da 100644 --- a/src/main/resources/assets/orespawn/lang/fr_fr.json +++ b/src/main/resources/assets/orespawn/lang/fr_fr.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Choisir un biome installé", "screen.orespawn.biome_placement": "Paramètres du biome", "screen.orespawn.biome_references": "Biomes similaires", - "screen.orespawn.dimension_materials": "Matériaux du monde", + "screen.orespawn.dimension_materials": "Matériaux de %s", "button.orespawn.biomes_world_materials": "Biomes et matériaux du monde...", "button.orespawn.biome_palette_count": "Biomes : %s", "button.orespawn.dimension_materials": "Matériaux du monde...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Matériaux du monde", "guide.orespawn.materials.1": "Matériaux du monde change les matériaux de fond dans toute une dimension. Utilisez Gisements de fluides pour de simples poches souterraines occasionnelles.", "guide.orespawn.materials.2": "Fluide d'aquifère change le fluide normal sous le niveau de la mer. Aquifère profond utilise un second fluide sous le niveau Y choisi.", - "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft." + "guide.orespawn.materials.3": "Neige et Glace remplacent la neige vanilla et la glace ordinaire près de la surface. Non défini conserve le matériau normal de Minecraft.", + "button.orespawn.dimension_materials_named": "Matériaux de %s...", + "label.orespawn.dimension_materials.scope": "S'applique à chaque biome du nouveau terrain de %s.", + "button.orespawn.mods": "Répertoire des mods", + "button.orespawn.mod.configure": "Configurer", + "tooltip.orespawn.main.mods": "Afficher les mods chargés intégrés à OreSpawn.", + "screen.orespawn.mods": "Mods OreSpawn", + "tooltip.orespawn.mod.configure": "Ouvrir la configuration de %s.", + "tooltip.orespawn.mod.unavailable": "%s ne fournit pas d'écran de configuration lié à OreSpawn.", + "label.orespawn.mod.id_version": "%s - Version du mod %s", + "label.orespawn.mod.additional_lineage": "Également détecté via la compatibilité OreSpawn %s.", + "label.orespawn.mod.status": "État : %s", + "label.orespawn.mod.os4_version": "Fournisseur OS4 natif - schéma %s, révision du fournisseur %s", + "label.orespawn.mod.os4": "Fournisseur OS4 natif", + "label.orespawn.mod.legacy_version": "Compatibilité OreSpawn %s -> schéma OS4 %s, révision du fournisseur %s", + "label.orespawn.mod.legacy": "Compatibilité OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Intégration de configuration client", + "label.orespawn.mod.none": "Aucune intégration OreSpawn chargée n'a été trouvée.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Actif", + "status.orespawn.mod.pending": "En attente", + "status.orespawn.mod.inactive": "Inactif", + "status.orespawn.mod.rejected": "Rejeté", + "status.orespawn.mod.config_only": "Écran de configuration uniquement", + "error.orespawn.mod.screen_missing": "%s n'a pas créé d'écran de configuration.", + "error.orespawn.mod.screen_failed": "Impossible d'ouvrir la configuration de %s (%s).", + "button.orespawn.ore_sources": "Sources de minerai...", + "tooltip.orespawn.ore_sources": "Examinez les sorties de minerai équivalentes sans multiplier la fréquence de placement.", + "screen.orespawn.ore_sources": "Sources de minerai", + "screen.orespawn.ore_source_detail": "Politique de source de minerai", + "label.orespawn.ore_source.none": "Aucune source de minerai gérée équivalente n'a été trouvée.", + "label.orespawn.ore_source.row": "%s - %s - %s candidats - %s", + "label.orespawn.ore_source.intro": "Choisissez comment les minerais équivalents sont générés.", + "label.orespawn.ore_source.summary": "%s candidats - %s", + "button.orespawn.ore_source.review": "Examiner...", + "button.orespawn.ore_source.configure": "Configurer...", + "label.orespawn.ore_source.material": "Matériau : %s", + "label.orespawn.ore_source.domain": "Dimension/domaine : %s", + "label.orespawn.ore_source.channels": "Canaux de placement : %s", + "label.orespawn.ore_source.missing": "Source de placement manquante", + "label.orespawn.ore_source.owner": "Propriétaire : %s, version %s", + "label.orespawn.ore_source.registry": "Sortie enregistrée : %s, métadonnées %s", + "label.orespawn.ore_source.dictionary": "Tags de blocs : %s", + "option.orespawn.ore_source.mode": "Mode : %s", + "option.orespawn.ore_source.channel": "Canal : %s", + "option.orespawn.ore_source.placement": "Source de placement : %s", + "option.orespawn.ore_source.weight": "Poids", + "mode.orespawn.ore_source.consolidated": "Consolidé", + "mode.orespawn.ore_source.keep_separate": "Garder séparé", + "status.orespawn.ore_source.consolidated": "Consolidé", + "status.orespawn.ore_source.separate": "Séparé", + "status.orespawn.ore_source.review_required": "Révision requise", + "status.orespawn.ore_source.missing_source": "Source manquante", + "status.orespawn.ore_source.external_generation": "Génération externe", + "tooltip.orespawn.ore_source.open": "Ouvrir cette politique de matériau.", + "tooltip.orespawn.ore_source.mode": "Consolidé utilise un budget de placement par canal. Garder séparé conserve chaque règle configurée.", + "tooltip.orespawn.ore_source.channel": "Les moteurs de placement sont arbitrés indépendamment.", + "tooltip.orespawn.ore_source.placement": "Choisissez la règle qui contrôle fréquence, forme, hôtes, filtres et hauteur pour ce canal.", + "tooltip.orespawn.ore_source.weight": "Chance relative positive de choisir cette sortie pour une veine entière ou un gisement identifié.", + "tooltip.orespawn.ore_source.external": "Ce mod enregistre une génération hors d'OreSpawn ; OreSpawn la signale sans pouvoir la supprimer.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn contrôle cette source de placement et de sortie configurée.", + "tooltip.orespawn.ore_source.enrichment": "Cette entrée est un enrichissement, pas une source de minerai interchangeable.", + "tooltip.orespawn.ore_source.missing": "Cette source est indisponible. Le choix enregistré sera restauré si son mod revient.", + "error.orespawn.ore_source.weight": "Les poids de sortie doivent être des nombres positifs.", + "button.orespawn.back": "Retour", + "button.orespawn.ore_source.all_groups": "Tous les groupes", + "button.orespawn.ore_source.add_group": "Ajouter un groupe", + "button.orespawn.ore_source.accept": "Accepter", + "tooltip.orespawn.ore_source.accept": "Conserve « Garder l'original » sans modification et marque le groupe comme vérifié. L'enregistrement se fait seulement avec Terminé dans l'éditeur principal.", + "button.orespawn.ore_source.delete_group": "Supprimer", + "button.orespawn.ore_source.dissolve_group": "Dissoudre le groupe", + "button.orespawn.ore_source.confirm_dissolve": "Confirmer la dissolution", + "button.orespawn.ore_source.move": "Déplacer", + "button.orespawn.ore_source.advanced": "Avancé...", + "button.orespawn.ore_source.hide_advanced": "Masquer les options avancées", + "option.orespawn.ore_source.group_name": "Nom du groupe", + "option.orespawn.ore_source.alias": "Entrée du système de tags de blocs", + "option.orespawn.ore_source.output_mode": "Sortie : %s", + "mode.orespawn.ore_source.keep_original": "Conserver l'origine", + "mode.orespawn.ore_source.balanced": "Équilibré", + "mode.orespawn.ore_source.single": "Unique", + "mode.orespawn.ore_source.custom": "Personnalisé", + "label.orespawn.ore_source.no_aliases": "Aucune entrée du système de tags de blocs", + "status.orespawn.ore_source.external_short": "externe", + "status.orespawn.ore_source.missing_short": "absente", + "status.orespawn.ore_source.output_only": "sortie uniquement", + "tooltip.orespawn.ore_source.output_only": "Cette sortie de fournisseur est disponible, mais sa règle de placement est inactive.", + "tooltip.orespawn.ore_source.output_mode": "Conserver l'origine préserve chaque règle. Équilibré partage également, Unique choisit un bloc et Personnalisé permet des choix pondérés.", + "tooltip.orespawn.ore_source.alias": "Ajoutez un nom exact de minerai du système de tags de blocs. Appuyez à nouveau sur Déplacer pour confirmer sa réaffectation depuis un autre groupe.", + "tooltip.orespawn.ore_source.reset_group": "Restaure le nom et les entrées du système de tags de blocs de ce groupe sélectionné.", + "tooltip.orespawn.ore_source.delete_group": "Supprime ce groupe personnalisé vide sans réécrire les chunks générés.", + "tooltip.orespawn.ore_source.dissolve_group": "Replace chaque entrée dans son groupe déduit ou prédéfini. Appuyez deux fois pour confirmer.", + "error.orespawn.ore_source.confirm_dissolve": "Appuyez sur Confirmer la dissolution pour replacer chaque entrée du système de tags de blocs dans son groupe d'origine.", + "error.orespawn.ore_source.alias": "Utilisez un nom exact du système de tags de blocs, comme forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s possède déjà cette entrée. Appuyez sur Déplacer pour la réaffecter.", + "error.orespawn.ore_source.manage_vanilla_first": "Activez « Gérer les minerais vanilla » avant de déplacer un minerai vanilla natif vers un autre groupe.", + "error.orespawn.ore_source.defaults_write": "Impossible d'enregistrer les valeurs par défaut des groupes de matériaux.", + "screen.orespawn.ore_source.group_settings": "Paramètres du groupe de sources de minerai", + "label.orespawn.ore_source.groups": "Groupes", + "label.orespawn.ore_source.aliases": "Entrées du système de tags de blocs", + "label.orespawn.ore_source.placement_rules": "Règles de placement", + "label.orespawn.ore_source.placement_explanation": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et hôtes.", + "label.orespawn.ore_source.standard_veins": "Veines standard", + "label.orespawn.ore_source.placement_choice_count": "%s (%s choix)", + "label.orespawn.ore_source.no_placement_rules": "Aucune règle de placement gérée", + "tooltip.orespawn.ore_source.group_settings": "Modifier le nom, les alias et les règles de placement de ce groupe.", + "tooltip.orespawn.ore_source.remove_alias": "Retirer %s de ce groupe.", + "tooltip.orespawn.ore_source.placement_rules": "Les sorties choisissent le bloc; les règles choisissent fréquence, forme, profondeur et restrictions d'hôte.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Le propriétaire du placement s'applique seulement aux modes consolidés Équilibré, Unique ou Personnalisé.", + "tooltip.orespawn.ore_source.placement_missing": "La source de placement enregistrée manque; ce canal reste désactivé.", + "tooltip.orespawn.ore_source.placement_readonly": "Une seule règle gérée active est disponible; cette source est en lecture seule.", + "tooltip.orespawn.ore_source.placement_select": "Cliquez pour choisir entre les sources de placement gérées actives.", + "tooltip.orespawn.ore_source.external_excluded": "Les générateurs externes sont signalés mais ne peuvent pas être choisis comme règles de placement.", + "button.orespawn.ore_source.hide_single": "Masquer les entrées simples", + "tooltip.orespawn.ore_source.group_colours": "Les entrées vertes ont un alias et une sortie et ne demandent aucune action. Les groupes jaunes ont une règle enregistrée. Les groupes rouges demandent votre attention. Afficher tout inclut les entrées vertes.", + "button.orespawn.ore_source.reset_all": "Tout réinitialiser", + "button.orespawn.ore_source.confirm_reset_all": "Confirmer la réinitialisation", + "tooltip.orespawn.ore_source.reset_all": "Restaure les groupes intégrés et recrée les règles depuis les minerais chargés. Le changement reste en attente jusqu'au bouton Terminé de l'éditeur principal.", + "screen.orespawn.biomes": "Biomes", + "screen.orespawn.biome_palettes": "Palettes de biomes", + "screen.orespawn.biome_palette_settings": "Paramètres de palette de biomes", + "button.orespawn.biome.hide_routine": "Masquer les ordinaires", + "button.orespawn.biome.replace_new_terrain": "Remplacer dans le nouveau terrain par...", + "button.orespawn.biome.replace_with": "Remplacer dans le nouveau terrain par %s...", + "button.orespawn.biome.leave_original": "Conserver le comportement d'origine", + "button.orespawn.biome.palettes": "Palettes : %s", + "button.orespawn.biome.reset_selected": "Réinitialiser le biome", + "button.orespawn.biome.confirm_reset": "Confirmer la réinitialisation", + "button.orespawn.biome.reset_palette": "Réinitialiser la palette", + "button.orespawn.biome.confirm_reset_palette": "Confirmer la palette", + "button.orespawn.biome.reset_dimension": "Réinitialiser la dimension", + "button.orespawn.biome.confirm_reset_dimension": "Confirmer la dimension", + "button.orespawn.biome.reset_all": "Réinitialiser tous les biomes", + "button.orespawn.biome.confirm_reset_all": "Tout confirmer", + "value.orespawn.biome.status.user_replaced": "Remplacé dans le nouveau terrain", + "value.orespawn.biome.status.layered": "Plusieurs règles de placement", + "value.orespawn.biome.status.disabled": "Placement désactivé", + "value.orespawn.biome.status.missing": "Biome ou fournisseur manquant", + "value.orespawn.biome.status.provider_managed": "Géré par le fournisseur", + "value.orespawn.biome.status.unmanaged": "Enregistré, non géré", + "label.orespawn.biome.rule_count": "Règles de placement : %s", + "label.orespawn.biome.replaced_by": "Remplacement du nouveau terrain : %s", + "label.orespawn.biome.palette_owner_order": "Propriétaire : %s ; ordre effectif : %s", + "label.orespawn.biome.surface_effective": "Cette règle fournit la surface effective.", + "label.orespawn.biome.surface_overridden": "Une règle active ultérieure remplace cette surface.", + "label.orespawn.biome.placement_rules": "Règles de placement", + "label.orespawn.biome.palette_entries": "Entrées de placement : %s", + "tooltip.orespawn.biome.no_retrogen": "Les remplacements de biome ne s'appliquent qu'à la génération de nouveau terrain. Les chunks existants ne sont jamais réécrits.", + "tooltip.orespawn.biome.override_palette": "Couche finale réservée aux remplacements. Modifiez-la avec le contrôle de remplacement de chaque biome.", + "tooltip.orespawn.biome.palette_entries_read_only": "L'appartenance à la palette est définie par le fournisseur ou le profil. Sélectionnez un biome pour modifier une règle existante.", + "option.orespawn.biome.coverage": "Couverture", + "option.orespawn.biome.fallback_weight": "Poids du biome d'origine", + "option.orespawn.biome.include_namespaces": "Espaces de noms inclus (séparés par des virgules)", + "option.orespawn.biome.exclude_namespaces": "Espaces de noms exclus (séparés par des virgules)" } diff --git a/src/main/resources/assets/orespawn/lang/ja_jp.json b/src/main/resources/assets/orespawn/lang/ja_jp.json index 830168ec..9d252222 100644 --- a/src/main/resources/assets/orespawn/lang/ja_jp.json +++ b/src/main/resources/assets/orespawn/lang/ja_jp.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "導入済みバイオームを選択", "screen.orespawn.biome_placement": "バイオーム設定", "screen.orespawn.biome_references": "類似バイオーム", - "screen.orespawn.dimension_materials": "ワールド素材", + "screen.orespawn.dimension_materials": "%s の素材", "button.orespawn.biomes_world_materials": "バイオームとワールド素材...", "button.orespawn.biome_palette_count": "バイオーム: %s", "button.orespawn.dimension_materials": "ワールド素材...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "ワールド素材", "guide.orespawn.materials.1": "ワールド素材はディメンション全体の基本素材を変更します。ときどき現れる地下ポケットだけが欲しい場合は流体鉱床を使います。", "guide.orespawn.materials.2": "帯水層の流体は海面より下で使う通常の流体を変更します。深部帯水層は選んだYより下で2種類目の流体を使います。", - "guide.orespawn.materials.3": "雪と氷は地表付近のバニラの雪と通常の氷を置き換えます。「未設定」ならMinecraft標準の素材を変えません。" + "guide.orespawn.materials.3": "雪と氷は地表付近のバニラの雪と通常の氷を置き換えます。「未設定」ならMinecraft標準の素材を変えません。", + "button.orespawn.dimension_materials_named": "%s の素材...", + "label.orespawn.dimension_materials.scope": "新しく生成される %s 地形のすべてのバイオームに適用されます。", + "button.orespawn.mods": "MOD一覧", + "button.orespawn.mod.configure": "設定", + "tooltip.orespawn.main.mods": "OreSpawnと連携している読み込み済みMODを表示します。", + "screen.orespawn.mods": "OreSpawn MOD", + "tooltip.orespawn.mod.configure": "%sの設定を開きます。", + "tooltip.orespawn.mod.unavailable": "%sにはOreSpawn連携設定画面がありません。", + "label.orespawn.mod.id_version": "%s - MODバージョン %s", + "label.orespawn.mod.additional_lineage": "OreSpawn %s互換としても検出されました。", + "label.orespawn.mod.status": "状態:%s", + "label.orespawn.mod.os4_version": "ネイティブOS4 - スキーマ%s、プロバイダーリビジョン%s", + "label.orespawn.mod.os4": "ネイティブOS4プロバイダー", + "label.orespawn.mod.legacy_version": "OreSpawn %s互換 -> OS4スキーマ%s、プロバイダーリビジョン%s", + "label.orespawn.mod.legacy": "OreSpawn %s互換 -> OS4", + "label.orespawn.mod.config_only": "クライアント設定連携", + "label.orespawn.mod.none": "読み込み済みのOreSpawn連携は見つかりませんでした。", + "label.orespawn.mod.integration_status": "%s|%s", + "status.orespawn.mod.active": "有効", + "status.orespawn.mod.pending": "保留中", + "status.orespawn.mod.inactive": "無効", + "status.orespawn.mod.rejected": "拒否", + "status.orespawn.mod.config_only": "設定画面のみ", + "error.orespawn.mod.screen_missing": "%sは設定画面を作成しませんでした。", + "error.orespawn.mod.screen_failed": "%sの設定を開けませんでした(%s)。", + "button.orespawn.ore_sources": "鉱石ソース...", + "tooltip.orespawn.ore_sources": "配置頻度を増やさずに同等の鉱石出力を確認します。", + "screen.orespawn.ore_sources": "鉱石ソース", + "screen.orespawn.ore_source_detail": "鉱石ソース方針", + "label.orespawn.ore_source.none": "同等の管理対象鉱石ソースは見つかりませんでした。", + "label.orespawn.ore_source.row": "%s - %s - 候補 %s 件 - %s", + "label.orespawn.ore_source.intro": "同等の鉱石の生成方法を選択します。", + "label.orespawn.ore_source.summary": "候補 %s 件 - %s", + "button.orespawn.ore_source.review": "確認...", + "button.orespawn.ore_source.configure": "設定...", + "label.orespawn.ore_source.material": "素材: %s", + "label.orespawn.ore_source.domain": "ディメンション/領域: %s", + "label.orespawn.ore_source.channels": "配置チャンネル: %s", + "label.orespawn.ore_source.missing": "配置ソースがありません", + "label.orespawn.ore_source.owner": "所有元: %s、バージョン %s", + "label.orespawn.ore_source.registry": "登録出力: %s、メタデータ %s", + "label.orespawn.ore_source.dictionary": "ブロックタグ: %s", + "option.orespawn.ore_source.mode": "モード: %s", + "option.orespawn.ore_source.channel": "チャンネル: %s", + "option.orespawn.ore_source.placement": "配置ソース: %s", + "option.orespawn.ore_source.weight": "重み", + "mode.orespawn.ore_source.consolidated": "統合", + "mode.orespawn.ore_source.keep_separate": "個別に保持", + "status.orespawn.ore_source.consolidated": "統合", + "status.orespawn.ore_source.separate": "個別", + "status.orespawn.ore_source.review_required": "確認が必要", + "status.orespawn.ore_source.missing_source": "ソースがありません", + "status.orespawn.ore_source.external_generation": "外部生成", + "tooltip.orespawn.ore_source.open": "この素材の方針を開きます。", + "tooltip.orespawn.ore_source.mode": "統合はチャンネルごとに1つの配置枠を使います。個別に保持は設定済みの各ルールを維持します。", + "tooltip.orespawn.ore_source.channel": "配置エンジンは個別に調整されます。", + "tooltip.orespawn.ore_source.placement": "このチャンネルの頻度、形状、母岩、フィルター、高さを決めるルールを選びます。", + "tooltip.orespawn.ore_source.weight": "鉱脈全体または識別済み鉱床全体でこの出力を選ぶ正の相対確率です。", + "tooltip.orespawn.ore_source.external": "このModはOreSpawn外で生成を登録しています。OreSpawnは表示できますが抑止できません。", + "tooltip.orespawn.ore_source.controlled": "OreSpawnがこの設定済み配置と出力ソースを制御します。", + "tooltip.orespawn.ore_source.enrichment": "この項目は富化用であり、交換可能な鉱石ソースではありません。", + "tooltip.orespawn.ore_source.missing": "このソースは利用できません。所有Modが戻ると保存済み選択が復元されます。", + "error.orespawn.ore_source.weight": "出力の重みは正の数である必要があります。", + "button.orespawn.back": "戻る", + "button.orespawn.ore_source.all_groups": "すべてのグループ", + "button.orespawn.ore_source.add_group": "グループを追加", + "button.orespawn.ore_source.accept": "承認", + "tooltip.orespawn.ore_source.accept": "「元のまま」を変更せず、このグループを確認済みにします。保存されるのはメインエディターで「完了」を押したときだけです。", + "button.orespawn.ore_source.delete_group": "削除", + "button.orespawn.ore_source.dissolve_group": "グループを解散", + "button.orespawn.ore_source.confirm_dissolve": "解散を確認", + "button.orespawn.ore_source.move": "移動", + "button.orespawn.ore_source.advanced": "詳細...", + "button.orespawn.ore_source.hide_advanced": "詳細を隠す", + "option.orespawn.ore_source.group_name": "グループ名", + "option.orespawn.ore_source.alias": "ブロックタグエントリ", + "option.orespawn.ore_source.output_mode": "出力: %s", + "mode.orespawn.ore_source.keep_original": "元の設定を維持", + "mode.orespawn.ore_source.balanced": "均等", + "mode.orespawn.ore_source.single": "単一", + "mode.orespawn.ore_source.custom": "カスタム", + "label.orespawn.ore_source.no_aliases": "ブロックタグエントリなし", + "status.orespawn.ore_source.external_short": "外部", + "status.orespawn.ore_source.missing_short": "不足", + "status.orespawn.ore_source.output_only": "出力のみ", + "tooltip.orespawn.ore_source.output_only": "このプロバイダー出力は利用できますが、配置ルールは無効です。", + "tooltip.orespawn.ore_source.output_mode": "元の設定を維持は各ルールを保持します。均等は同じ比率、単一は1ブロック、カスタムは重み付き選択を使用します。", + "tooltip.orespawn.ore_source.alias": "ブロックタグの正確な鉱石名を追加します。別グループからの再割り当てを確定するには、もう一度「移動」を押します。", + "tooltip.orespawn.ore_source.reset_group": "この既定グループの名前とブロックタグエントリを復元します。", + "tooltip.orespawn.ore_source.delete_group": "生成済みチャンクを書き換えずに、この空のカスタムグループを削除します。", + "tooltip.orespawn.ore_source.dissolve_group": "各項目を推定または既定のグループへ戻します。2回押すと確定します。", + "error.orespawn.ore_source.confirm_dissolve": "「解散を確認」を押すと、すべてのブロックタグ項目を元のグループへ戻します。", + "error.orespawn.ore_source.alias": "forge:ores/sulfur のような正確なブロックタグ名を使用してください。", + "error.orespawn.ore_source.alias_owned": "%s がこのエントリを既に所有しています。再割り当てするには「移動」を押してください。", + "error.orespawn.ore_source.manage_vanilla_first": "バニラ鉱石を別のグループへ移動する前に、「バニラ鉱石を管理」を有効にしてください。", + "error.orespawn.ore_source.defaults_write": "素材グループの既定値を保存できませんでした。", + "screen.orespawn.ore_source.group_settings": "鉱石ソースグループ設定", + "label.orespawn.ore_source.groups": "グループ", + "label.orespawn.ore_source.aliases": "ブロックタグエントリ", + "label.orespawn.ore_source.placement_rules": "配置ルール", + "label.orespawn.ore_source.placement_explanation": "出力はブロックを、配置ルールは頻度、形状、深さ、母岩を選びます。", + "label.orespawn.ore_source.standard_veins": "標準鉱脈", + "label.orespawn.ore_source.placement_choice_count": "%s(%s 個の候補)", + "label.orespawn.ore_source.no_placement_rules": "管理対象の配置ルールはありません", + "tooltip.orespawn.ore_source.group_settings": "このグループの名前、別名、配置ルールを編集します。", + "tooltip.orespawn.ore_source.remove_alias": "%s をこのグループから削除します。", + "tooltip.orespawn.ore_source.placement_rules": "出力は鉱石ブロックを、配置ルールは頻度、形状、深さ、母岩制限を選びます。", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "配置元の選択は統合された均等、単一、カスタムモードだけに適用されます。", + "tooltip.orespawn.ore_source.placement_missing": "保存された配置元がありません。このチャンネルは無効のままです。", + "tooltip.orespawn.ore_source.placement_readonly": "有効な管理ルールは1つだけなので、このソースは読み取り専用です。", + "tooltip.orespawn.ore_source.placement_select": "クリックして有効な管理対象配置元を選択します。", + "tooltip.orespawn.ore_source.external_excluded": "独立した外部生成は報告されますが、配置ルールには選択できません。", + "button.orespawn.ore_source.hide_single": "単一項目を隠す", + "tooltip.orespawn.ore_source.group_colours": "緑は別名と出力が各1つで操作不要です。黄は保存済みルールがあるグループです。赤は確認が必要です。すべて表示で緑の項目も表示します。", + "button.orespawn.ore_source.reset_all": "すべてリセット", + "button.orespawn.ore_source.confirm_reset_all": "リセットを確認", + "tooltip.orespawn.ore_source.reset_all": "組み込みの素材グループを復元し、現在読み込まれている鉱石からソースポリシーを再作成します。変更はメイン画面で完了を押すまで保留されます。", + "screen.orespawn.biomes": "バイオーム", + "screen.orespawn.biome_palettes": "バイオームパレット", + "screen.orespawn.biome_palette_settings": "バイオームパレット設定", + "button.orespawn.biome.hide_routine": "通常項目を隠す", + "button.orespawn.biome.replace_new_terrain": "新しい地形で置き換える...", + "button.orespawn.biome.replace_with": "新しい地形で %s に置き換える...", + "button.orespawn.biome.leave_original": "元の動作を維持", + "button.orespawn.biome.palettes": "パレット: %s", + "button.orespawn.biome.reset_selected": "バイオームをリセット", + "button.orespawn.biome.confirm_reset": "リセットを確認", + "button.orespawn.biome.reset_palette": "パレットをリセット", + "button.orespawn.biome.confirm_reset_palette": "パレットのリセットを確認", + "button.orespawn.biome.reset_dimension": "ディメンションをリセット", + "button.orespawn.biome.confirm_reset_dimension": "ディメンションのリセットを確認", + "button.orespawn.biome.reset_all": "全バイオームをリセット", + "button.orespawn.biome.confirm_reset_all": "全リセットを確認", + "value.orespawn.biome.status.user_replaced": "新しい地形で置換", + "value.orespawn.biome.status.layered": "複数の配置ルール", + "value.orespawn.biome.status.disabled": "配置は無効", + "value.orespawn.biome.status.missing": "バイオームまたはプロバイダーが不足", + "value.orespawn.biome.status.provider_managed": "プロバイダー管理", + "value.orespawn.biome.status.unmanaged": "登録済み、未管理", + "label.orespawn.biome.rule_count": "配置ルール: %s", + "label.orespawn.biome.replaced_by": "新しい地形での置換: %s", + "label.orespawn.biome.palette_owner_order": "所有者: %s、適用順: %s", + "label.orespawn.biome.surface_effective": "このルールが有効な表面を提供します。", + "label.orespawn.biome.surface_overridden": "後の有効なルールがこの表面を上書きします。", + "label.orespawn.biome.placement_rules": "配置ルール", + "label.orespawn.biome.palette_entries": "配置項目: %s", + "tooltip.orespawn.biome.no_retrogen": "バイオーム置換は新しい地形の生成時のみ適用されます。既存チャンクは書き換えません。", + "tooltip.orespawn.biome.override_palette": "予約済みの最終置換レイヤーです。各バイオームの置換操作から編集します。", + "tooltip.orespawn.biome.palette_entries_read_only": "パレットの所属はプロバイダーまたはプロファイルで定義されます。既存ルールを編集するにはバイオームを選択してください。", + "option.orespawn.biome.coverage": "適用範囲", + "option.orespawn.biome.fallback_weight": "元のバイオームの重み", + "option.orespawn.biome.include_namespaces": "含める名前空間(カンマ区切り)", + "option.orespawn.biome.exclude_namespaces": "除外する名前空間(カンマ区切り)" } diff --git a/src/main/resources/assets/orespawn/lang/ko_kr.json b/src/main/resources/assets/orespawn/lang/ko_kr.json index 7c63fc75..6103cbdf 100644 --- a/src/main/resources/assets/orespawn/lang/ko_kr.json +++ b/src/main/resources/assets/orespawn/lang/ko_kr.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "설치된 생물군계 선택", "screen.orespawn.biome_placement": "생물군계 설정", "screen.orespawn.biome_references": "유사 생물군계", - "screen.orespawn.dimension_materials": "월드 재료", + "screen.orespawn.dimension_materials": "%s 재료", "button.orespawn.biomes_world_materials": "생물군계 및 월드 재료...", "button.orespawn.biome_palette_count": "생물군계: %s", "button.orespawn.dimension_materials": "월드 재료...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "월드 재료", "guide.orespawn.materials.1": "월드 재료는 차원 전체의 기본 재료를 바꿉니다. 가끔 나타나는 지하 주머니만 원한다면 유체 광상을 사용하세요.", "guide.orespawn.materials.2": "대수층 유체는 해수면 아래에서 쓰는 기본 유체를 바꿉니다. 깊은 대수층은 선택한 Y 아래에서 두 번째 유체를 사용합니다.", - "guide.orespawn.materials.3": "눈과 얼음은 지표 근처의 바닐라 눈과 일반 얼음을 바꿉니다. 설정 안 함은 Minecraft 기본 재료를 그대로 둡니다." + "guide.orespawn.materials.3": "눈과 얼음은 지표 근처의 바닐라 눈과 일반 얼음을 바꿉니다. 설정 안 함은 Minecraft 기본 재료를 그대로 둡니다.", + "button.orespawn.dimension_materials_named": "%s 재료...", + "label.orespawn.dimension_materials.scope": "새로 생성되는 %s 지형의 모든 생물 군계에 적용됩니다.", + "button.orespawn.mods": "모드 목록", + "button.orespawn.mod.configure": "설정", + "tooltip.orespawn.main.mods": "OreSpawn과 연동된 불러온 모드를 표시합니다.", + "screen.orespawn.mods": "OreSpawn 모드", + "tooltip.orespawn.mod.configure": "%s 설정을 엽니다.", + "tooltip.orespawn.mod.unavailable": "%s 모드는 OreSpawn 연동 설정 화면을 제공하지 않습니다.", + "label.orespawn.mod.id_version": "%s - 모드 버전 %s", + "label.orespawn.mod.additional_lineage": "OreSpawn %s 호환으로도 감지되었습니다.", + "label.orespawn.mod.status": "상태: %s", + "label.orespawn.mod.os4_version": "기본 OS4 공급자 - 스키마 %s, 공급자 리비전 %s", + "label.orespawn.mod.os4": "기본 OS4 공급자", + "label.orespawn.mod.legacy_version": "OreSpawn %s 호환 -> OS4 스키마 %s, 공급자 리비전 %s", + "label.orespawn.mod.legacy": "OreSpawn %s 호환 -> OS4", + "label.orespawn.mod.config_only": "클라이언트 설정 연동", + "label.orespawn.mod.none": "불러온 OreSpawn 연동 모드를 찾지 못했습니다.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "활성", + "status.orespawn.mod.pending": "대기 중", + "status.orespawn.mod.inactive": "비활성", + "status.orespawn.mod.rejected": "거부됨", + "status.orespawn.mod.config_only": "설정 화면만 제공", + "error.orespawn.mod.screen_missing": "%s 모드가 설정 화면을 만들지 않았습니다.", + "error.orespawn.mod.screen_failed": "%s 설정을 열 수 없습니다(%s).", + "button.orespawn.ore_sources": "광석 소스...", + "tooltip.orespawn.ore_sources": "배치 빈도를 늘리지 않고 동등한 광석 출력을 검토합니다.", + "screen.orespawn.ore_sources": "광석 소스", + "screen.orespawn.ore_source_detail": "광석 소스 정책", + "label.orespawn.ore_source.none": "동등한 관리 광석 소스를 찾지 못했습니다.", + "label.orespawn.ore_source.row": "%s - %s - 후보 %s개 - %s", + "label.orespawn.ore_source.intro": "동등한 광석의 생성 방식을 선택하세요.", + "label.orespawn.ore_source.summary": "후보 %s개 - %s", + "button.orespawn.ore_source.review": "검토...", + "button.orespawn.ore_source.configure": "설정...", + "label.orespawn.ore_source.material": "재료: %s", + "label.orespawn.ore_source.domain": "차원/영역: %s", + "label.orespawn.ore_source.channels": "배치 채널: %s", + "label.orespawn.ore_source.missing": "배치 소스 없음", + "label.orespawn.ore_source.owner": "소유자: %s, 버전 %s", + "label.orespawn.ore_source.registry": "등록 출력: %s, 메타데이터 %s", + "label.orespawn.ore_source.dictionary": "블록 태그: %s", + "option.orespawn.ore_source.mode": "모드: %s", + "option.orespawn.ore_source.channel": "채널: %s", + "option.orespawn.ore_source.placement": "배치 소스: %s", + "option.orespawn.ore_source.weight": "가중치", + "mode.orespawn.ore_source.consolidated": "통합", + "mode.orespawn.ore_source.keep_separate": "분리 유지", + "status.orespawn.ore_source.consolidated": "통합", + "status.orespawn.ore_source.separate": "분리", + "status.orespawn.ore_source.review_required": "검토 필요", + "status.orespawn.ore_source.missing_source": "소스 없음", + "status.orespawn.ore_source.external_generation": "외부 생성", + "tooltip.orespawn.ore_source.open": "이 재료 정책을 엽니다.", + "tooltip.orespawn.ore_source.mode": "통합은 채널마다 하나의 배치 예산을 사용합니다. 분리 유지는 설정된 모든 규칙을 보존합니다.", + "tooltip.orespawn.ore_source.channel": "배치 엔진은 서로 독립적으로 조정됩니다.", + "tooltip.orespawn.ore_source.placement": "이 채널의 빈도, 모양, 모암, 필터 및 높이를 제어하는 규칙을 선택합니다.", + "tooltip.orespawn.ore_source.weight": "전체 광맥 또는 식별된 광상에 이 출력을 선택할 양의 상대 확률입니다.", + "tooltip.orespawn.ore_source.external": "이 Mod는 OreSpawn 밖에서 생성을 등록합니다. OreSpawn은 표시할 수 있지만 억제할 수 없습니다.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn이 이 설정된 배치와 출력 소스를 제어합니다.", + "tooltip.orespawn.ore_source.enrichment": "이 항목은 농축용이며 교환 가능한 광석 소스가 아닙니다.", + "tooltip.orespawn.ore_source.missing": "이 소스를 사용할 수 없습니다. 소유 Mod가 돌아오면 저장된 선택이 복원됩니다.", + "error.orespawn.ore_source.weight": "출력 가중치는 양수여야 합니다.", + "button.orespawn.back": "뒤로", + "button.orespawn.ore_source.all_groups": "모든 그룹", + "button.orespawn.ore_source.add_group": "그룹 추가", + "button.orespawn.ore_source.accept": "승인", + "tooltip.orespawn.ore_source.accept": "'원래대로 유지' 설정은 바꾸지 않고 이 그룹을 검토 완료로 표시합니다. 기본 편집기에서 완료를 눌러야 저장됩니다.", + "button.orespawn.ore_source.delete_group": "삭제", + "button.orespawn.ore_source.dissolve_group": "그룹 해제", + "button.orespawn.ore_source.confirm_dissolve": "해제 확인", + "button.orespawn.ore_source.move": "이동", + "button.orespawn.ore_source.advanced": "고급...", + "button.orespawn.ore_source.hide_advanced": "고급 숨기기", + "option.orespawn.ore_source.group_name": "그룹 이름", + "option.orespawn.ore_source.alias": "블록 태그 항목", + "option.orespawn.ore_source.output_mode": "출력: %s", + "mode.orespawn.ore_source.keep_original": "원본 유지", + "mode.orespawn.ore_source.balanced": "균등", + "mode.orespawn.ore_source.single": "단일", + "mode.orespawn.ore_source.custom": "사용자 지정", + "label.orespawn.ore_source.no_aliases": "블록 태그 항목 없음", + "status.orespawn.ore_source.external_short": "외부", + "status.orespawn.ore_source.missing_short": "누락", + "status.orespawn.ore_source.output_only": "출력 전용", + "tooltip.orespawn.ore_source.output_only": "이 공급자 출력은 사용할 수 있지만 배치 규칙은 비활성 상태입니다.", + "tooltip.orespawn.ore_source.output_mode": "원본 유지는 각 규칙을 보존합니다. 균등은 같은 비율로, 단일은 한 블록을, 사용자 지정은 가중 선택을 사용합니다.", + "tooltip.orespawn.ore_source.alias": "정확한 블록 태그 이름을 추가합니다. 다른 그룹에서 재할당하려면 이동을 다시 누르세요.", + "tooltip.orespawn.ore_source.reset_group": "이 선별 그룹의 이름과 블록 태그 항목을 복원합니다.", + "tooltip.orespawn.ore_source.delete_group": "생성된 청크를 다시 쓰지 않고 비어 있는 사용자 지정 그룹을 삭제합니다.", + "tooltip.orespawn.ore_source.dissolve_group": "각 항목을 추론되었거나 선별된 그룹으로 되돌립니다. 두 번 눌러 확인합니다.", + "error.orespawn.ore_source.confirm_dissolve": "해제 확인을 눌러 모든 블록 태그 항목을 원래 그룹으로 되돌립니다.", + "error.orespawn.ore_source.alias": "forge:ores/sulfur 같은 정확한 블록 태그 이름을 사용하세요.", + "error.orespawn.ore_source.alias_owned": "%s에서 이미 이 항목을 소유합니다. 이동을 눌러 재할당하세요.", + "error.orespawn.ore_source.manage_vanilla_first": "바닐라 광석을 다른 그룹으로 이동하기 전에 바닐라 광석 관리를 활성화하세요.", + "error.orespawn.ore_source.defaults_write": "재료 그룹 기본값을 저장하지 못했습니다.", + "screen.orespawn.ore_source.group_settings": "광석 소스 그룹 설정", + "label.orespawn.ore_source.groups": "그룹", + "label.orespawn.ore_source.aliases": "블록 태그 항목", + "label.orespawn.ore_source.placement_rules": "배치 규칙", + "label.orespawn.ore_source.placement_explanation": "출력은 블록을, 배치 규칙은 빈도, 모양, 깊이와 모암을 선택합니다.", + "label.orespawn.ore_source.standard_veins": "표준 광맥", + "label.orespawn.ore_source.placement_choice_count": "%s (선택지 %s개)", + "label.orespawn.ore_source.no_placement_rules": "관리되는 배치 규칙 없음", + "tooltip.orespawn.ore_source.group_settings": "이 그룹의 이름, 별칭과 배치 규칙을 편집합니다.", + "tooltip.orespawn.ore_source.remove_alias": "이 그룹에서 %s 항목을 제거합니다.", + "tooltip.orespawn.ore_source.placement_rules": "출력은 광석 블록을, 배치 규칙은 빈도, 모양, 깊이와 모암 제한을 선택합니다.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "배치 소유권은 통합된 균형, 단일 또는 사용자 지정 모드에만 적용됩니다.", + "tooltip.orespawn.ore_source.placement_missing": "저장된 배치 소스가 없습니다. 이 채널은 비활성화 상태로 유지됩니다.", + "tooltip.orespawn.ore_source.placement_readonly": "활성 관리 규칙이 하나뿐이므로 이 소스는 읽기 전용입니다.", + "tooltip.orespawn.ore_source.placement_select": "활성 관리 배치 소스 중 하나를 선택하려면 클릭하세요.", + "tooltip.orespawn.ore_source.external_excluded": "독립 외부 생성기는 보고되지만 배치 규칙으로 선택할 수 없습니다.", + "button.orespawn.ore_source.hide_single": "단일 항목 숨기기", + "tooltip.orespawn.ore_source.group_colours": "녹색 항목은 별칭과 출력이 각각 하나라 조치가 필요 없습니다. 노란색 그룹에는 저장된 규칙이 있습니다. 빨간색 그룹은 확인이 필요합니다. 모두 표시를 누르면 녹색 항목도 표시됩니다.", + "button.orespawn.ore_source.reset_all": "모두 초기화", + "button.orespawn.ore_source.confirm_reset_all": "초기화 확인", + "tooltip.orespawn.ore_source.reset_all": "기본 재료 그룹을 복원하고 현재 불러온 광석에서 소스 정책을 다시 만듭니다. 변경 사항은 기본 편집기에서 완료를 누를 때까지 보류됩니다.", + "screen.orespawn.biomes": "생물군계", + "screen.orespawn.biome_palettes": "생물군계 팔레트", + "screen.orespawn.biome_palette_settings": "생물군계 팔레트 설정", + "button.orespawn.biome.hide_routine": "일반 항목 숨기기", + "button.orespawn.biome.replace_new_terrain": "새 지형에서 다음으로 교체...", + "button.orespawn.biome.replace_with": "새 지형에서 %s(으)로 교체...", + "button.orespawn.biome.leave_original": "원래 동작 유지", + "button.orespawn.biome.palettes": "팔레트: %s", + "button.orespawn.biome.reset_selected": "생물군계 초기화", + "button.orespawn.biome.confirm_reset": "초기화 확인", + "button.orespawn.biome.reset_palette": "팔레트 초기화", + "button.orespawn.biome.confirm_reset_palette": "팔레트 초기화 확인", + "button.orespawn.biome.reset_dimension": "차원 초기화", + "button.orespawn.biome.confirm_reset_dimension": "차원 초기화 확인", + "button.orespawn.biome.reset_all": "모든 생물군계 초기화", + "button.orespawn.biome.confirm_reset_all": "전체 초기화 확인", + "value.orespawn.biome.status.user_replaced": "새 지형에서 교체됨", + "value.orespawn.biome.status.layered": "여러 배치 규칙", + "value.orespawn.biome.status.disabled": "배치 비활성화", + "value.orespawn.biome.status.missing": "생물군계 또는 제공자 누락", + "value.orespawn.biome.status.provider_managed": "제공자 관리", + "value.orespawn.biome.status.unmanaged": "등록됨, 관리되지 않음", + "label.orespawn.biome.rule_count": "배치 규칙: %s", + "label.orespawn.biome.replaced_by": "새 지형 교체: %s", + "label.orespawn.biome.palette_owner_order": "소유자: %s; 적용 순서: %s", + "label.orespawn.biome.surface_effective": "이 규칙이 적용되는 표면을 제공합니다.", + "label.orespawn.biome.surface_overridden": "뒤의 활성 규칙이 이 표면을 덮어씁니다.", + "label.orespawn.biome.placement_rules": "배치 규칙", + "label.orespawn.biome.palette_entries": "배치 항목: %s", + "tooltip.orespawn.biome.no_retrogen": "생물군계 교체는 새 지형 생성 시에만 적용됩니다. 기존 청크는 다시 쓰지 않습니다.", + "tooltip.orespawn.biome.override_palette": "예약된 최종 교체 레이어입니다. 각 생물군계의 교체 컨트롤에서 편집하세요.", + "tooltip.orespawn.biome.palette_entries_read_only": "팔레트 구성은 제공자 또는 프로필에서 정의합니다. 기존 규칙을 편집하려면 생물군계를 선택하세요.", + "option.orespawn.biome.coverage": "적용 범위", + "option.orespawn.biome.fallback_weight": "원래 생물군계 가중치", + "option.orespawn.biome.include_namespaces": "포함할 네임스페이스(쉼표로 구분)", + "option.orespawn.biome.exclude_namespaces": "제외할 네임스페이스(쉼표로 구분)" } diff --git a/src/main/resources/assets/orespawn/lang/pt_br.json b/src/main/resources/assets/orespawn/lang/pt_br.json index d5b5beae..dcaa1aaf 100644 --- a/src/main/resources/assets/orespawn/lang/pt_br.json +++ b/src/main/resources/assets/orespawn/lang/pt_br.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Escolher bioma instalado", "screen.orespawn.biome_placement": "Configurações do bioma", "screen.orespawn.biome_references": "Biomas semelhantes", - "screen.orespawn.dimension_materials": "Materiais do mundo", + "screen.orespawn.dimension_materials": "Materiais de %s", "button.orespawn.biomes_world_materials": "Biomas e materiais do mundo...", "button.orespawn.biome_palette_count": "Biomas: %s", "button.orespawn.dimension_materials": "Materiais do mundo...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Materiais do mundo", "guide.orespawn.materials.1": "Materiais do mundo altera materiais de fundo em uma dimensão inteira. Use Depósitos de fluidos quando quiser apenas bolsas subterrâneas ocasionais.", "guide.orespawn.materials.2": "Fluido do aquífero altera o fluido normal abaixo do nível do mar. Aquífero profundo usa um segundo fluido abaixo do nível Y escolhido.", - "guide.orespawn.materials.3": "Neve e Gelo substituem a neve vanilla e o gelo comum perto da superfície. Não definido mantém o material normal do Minecraft." + "guide.orespawn.materials.3": "Neve e Gelo substituem a neve vanilla e o gelo comum perto da superfície. Não definido mantém o material normal do Minecraft.", + "button.orespawn.dimension_materials_named": "Materiais de %s...", + "label.orespawn.dimension_materials.scope": "Aplica-se a cada bioma no novo terreno de %s.", + "button.orespawn.mods": "Diretório de mods", + "button.orespawn.mod.configure": "Configurar", + "tooltip.orespawn.main.mods": "Ver os mods carregados integrados ao OreSpawn.", + "screen.orespawn.mods": "Mods do OreSpawn", + "tooltip.orespawn.mod.configure": "Abrir a configuração de %s.", + "tooltip.orespawn.mod.unavailable": "%s não fornece uma tela de configuração vinculada ao OreSpawn.", + "label.orespawn.mod.id_version": "%s - Versão do mod %s", + "label.orespawn.mod.additional_lineage": "Também detectado pela compatibilidade com OreSpawn %s.", + "label.orespawn.mod.status": "Situação: %s", + "label.orespawn.mod.os4_version": "Provedor OS4 nativo - esquema %s, revisão do provedor %s", + "label.orespawn.mod.os4": "Provedor OS4 nativo", + "label.orespawn.mod.legacy_version": "Compatibilidade com OreSpawn %s -> esquema OS4 %s, revisão do provedor %s", + "label.orespawn.mod.legacy": "Compatibilidade com OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Integração de configuração do cliente", + "label.orespawn.mod.none": "Nenhuma integração carregada do OreSpawn foi encontrada.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Ativo", + "status.orespawn.mod.pending": "Pendente", + "status.orespawn.mod.inactive": "Inativo", + "status.orespawn.mod.rejected": "Rejeitado", + "status.orespawn.mod.config_only": "Apenas tela de configuração", + "error.orespawn.mod.screen_missing": "%s não criou uma tela de configuração.", + "error.orespawn.mod.screen_failed": "Não foi possível abrir a configuração de %s (%s).", + "button.orespawn.ore_sources": "Fontes de minério...", + "tooltip.orespawn.ore_sources": "Revise saídas de minério equivalentes sem multiplicar a frequência de colocação.", + "screen.orespawn.ore_sources": "Fontes de minério", + "screen.orespawn.ore_source_detail": "Política de fonte de minério", + "label.orespawn.ore_source.none": "Nenhuma fonte de minério gerenciada equivalente foi encontrada.", + "label.orespawn.ore_source.row": "%s - %s - %s candidatos - %s", + "label.orespawn.ore_source.intro": "Escolha como minérios equivalentes são gerados.", + "label.orespawn.ore_source.summary": "%s candidatos - %s", + "button.orespawn.ore_source.review": "Revisar...", + "button.orespawn.ore_source.configure": "Configurar...", + "label.orespawn.ore_source.material": "Matéria-prima: %s", + "label.orespawn.ore_source.domain": "Dimensão/domínio: %s", + "label.orespawn.ore_source.channels": "Canais de colocação: %s", + "label.orespawn.ore_source.missing": "Fonte de colocação ausente", + "label.orespawn.ore_source.owner": "Proprietário: %s, versão %s", + "label.orespawn.ore_source.registry": "Saída registrada: %s, metadados %s", + "label.orespawn.ore_source.dictionary": "Tags de blocos: %s", + "option.orespawn.ore_source.mode": "Modo: %s", + "option.orespawn.ore_source.channel": "Canal: %s", + "option.orespawn.ore_source.placement": "Fonte de colocação: %s", + "option.orespawn.ore_source.weight": "Peso", + "mode.orespawn.ore_source.consolidated": "Consolidado", + "mode.orespawn.ore_source.keep_separate": "Manter separado", + "status.orespawn.ore_source.consolidated": "Consolidado", + "status.orespawn.ore_source.separate": "Separado", + "status.orespawn.ore_source.review_required": "Revisão necessária", + "status.orespawn.ore_source.missing_source": "Fonte ausente", + "status.orespawn.ore_source.external_generation": "Geração externa", + "tooltip.orespawn.ore_source.open": "Abrir esta política de material.", + "tooltip.orespawn.ore_source.mode": "Consolidado usa um orçamento de colocação por canal. Manter separado preserva cada regra configurada.", + "tooltip.orespawn.ore_source.channel": "Os mecanismos de colocação são arbitrados separadamente.", + "tooltip.orespawn.ore_source.placement": "Escolha a regra que controla frequência, forma, hospedeiros, filtros e altura neste canal.", + "tooltip.orespawn.ore_source.weight": "Chance relativa positiva de escolher esta saída para um veio inteiro ou corpo de depósito identificado.", + "tooltip.orespawn.ore_source.external": "Este mod registra geração fora do OreSpawn; o OreSpawn a informa, mas não pode impedi-la.", + "tooltip.orespawn.ore_source.controlled": "O OreSpawn controla esta colocação e fonte de saída configuradas.", + "tooltip.orespawn.ore_source.enrichment": "Esta entrada é um enriquecimento, não uma fonte de minério intercambiável.", + "tooltip.orespawn.ore_source.missing": "Esta fonte está indisponível. A seleção salva será restaurada se o mod proprietário retornar.", + "error.orespawn.ore_source.weight": "Os pesos de saída devem ser números positivos.", + "button.orespawn.back": "Voltar", + "button.orespawn.ore_source.all_groups": "Todos os grupos", + "button.orespawn.ore_source.add_group": "Adicionar grupo", + "button.orespawn.ore_source.accept": "Aceitar", + "tooltip.orespawn.ore_source.accept": "Mantém Conservar original sem alterações e marca o grupo como revisado. Só é salvo ao usar Concluído no editor principal.", + "button.orespawn.ore_source.delete_group": "Excluir", + "button.orespawn.ore_source.dissolve_group": "Dissolver grupo", + "button.orespawn.ore_source.confirm_dissolve": "Confirmar dissolução", + "button.orespawn.ore_source.move": "Mover", + "button.orespawn.ore_source.advanced": "Avançado...", + "button.orespawn.ore_source.hide_advanced": "Ocultar avançado", + "option.orespawn.ore_source.group_name": "Nome do grupo", + "option.orespawn.ore_source.alias": "Entrada do sistema de tags de blocos", + "option.orespawn.ore_source.output_mode": "Saída: %s", + "mode.orespawn.ore_source.keep_original": "Manter original", + "mode.orespawn.ore_source.balanced": "Equilibrado", + "mode.orespawn.ore_source.single": "Único", + "mode.orespawn.ore_source.custom": "Personalizado", + "label.orespawn.ore_source.no_aliases": "Sem entradas do sistema de tags de blocos", + "status.orespawn.ore_source.external_short": "externo", + "status.orespawn.ore_source.missing_short": "ausente", + "status.orespawn.ore_source.output_only": "somente saída", + "tooltip.orespawn.ore_source.output_only": "Esta saída do provedor está disponível, mas sua regra de posicionamento está inativa.", + "tooltip.orespawn.ore_source.output_mode": "Manter original preserva cada regra. Equilibrado divide igualmente, Único escolhe um bloco e Personalizado permite escolhas ponderadas.", + "tooltip.orespawn.ore_source.alias": "Adicione um nome exato de minério do sistema de tags de blocos. Pressione Mover novamente para confirmar a transferência de outro grupo.", + "tooltip.orespawn.ore_source.reset_group": "Restaura o nome e as entradas do sistema de tags de blocos deste grupo selecionado.", + "tooltip.orespawn.ore_source.delete_group": "Exclui este grupo personalizado vazio sem reescrever os chunks gerados.", + "tooltip.orespawn.ore_source.dissolve_group": "Devolve cada entrada ao grupo inferido ou revisado. Pressione duas vezes para confirmar.", + "error.orespawn.ore_source.confirm_dissolve": "Pressione Confirmar dissolução para devolver cada entrada do sistema de tags de blocos ao grupo original.", + "error.orespawn.ore_source.alias": "Use um nome exato do sistema de tags de blocos, como forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s já possui esta entrada. Pressione Mover para transferi-la.", + "error.orespawn.ore_source.manage_vanilla_first": "Ative Gerenciar minérios vanilla antes de mover um minério vanilla nativo para outro grupo.", + "error.orespawn.ore_source.defaults_write": "Não foi possível salvar os padrões dos grupos de materiais.", + "screen.orespawn.ore_source.group_settings": "Configurações do grupo de fontes de minério", + "label.orespawn.ore_source.groups": "Grupos", + "label.orespawn.ore_source.aliases": "Entradas do Dicionário de Minérios", + "label.orespawn.ore_source.placement_rules": "Regras de colocação", + "label.orespawn.ore_source.placement_explanation": "Saídas escolhem o bloco; regras escolhem frequência, forma, profundidade e hospedeiros.", + "label.orespawn.ore_source.standard_veins": "Veios padrão", + "label.orespawn.ore_source.placement_choice_count": "%s (%s opções)", + "label.orespawn.ore_source.no_placement_rules": "Nenhuma regra de colocação gerenciada", + "tooltip.orespawn.ore_source.group_settings": "Edite o nome, aliases e regras de colocação deste grupo.", + "tooltip.orespawn.ore_source.remove_alias": "Remover %s deste grupo.", + "tooltip.orespawn.ore_source.placement_rules": "Saídas escolhem o bloco; regras escolhem frequência, forma, profundidade e restrições de hospedeiro.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "A seleção de colocação vale apenas nos modos consolidados Balanceado, Único ou Personalizado.", + "tooltip.orespawn.ore_source.placement_missing": "A fonte de colocação salva está ausente; este canal permanece desativado.", + "tooltip.orespawn.ore_source.placement_readonly": "Há apenas uma regra gerenciada ativa; esta fonte é somente leitura.", + "tooltip.orespawn.ore_source.placement_select": "Clique para escolher entre fontes de colocação gerenciadas ativas.", + "tooltip.orespawn.ore_source.external_excluded": "Geradores externos independentes são informados, mas não podem ser escolhidos como regras de colocação.", + "button.orespawn.ore_source.hide_single": "Ocultar individuais", + "tooltip.orespawn.ore_source.group_colours": "Entradas verdes têm um alias e uma saída e não exigem ação. Grupos amarelos têm uma regra salva. Grupos vermelhos exigem atenção. Mostrar tudo inclui as entradas verdes.", + "button.orespawn.ore_source.reset_all": "Redefinir tudo", + "button.orespawn.ore_source.confirm_reset_all": "Confirmar redefinição", + "tooltip.orespawn.ore_source.reset_all": "Restaura os grupos de materiais internos e recria as políticas a partir dos minérios carregados. A mudança fica pendente até usar Concluído no editor principal.", + "screen.orespawn.biomes": "Biomas", + "screen.orespawn.biome_palettes": "Paletas de biomas", + "screen.orespawn.biome_palette_settings": "Configurações da paleta de biomas", + "button.orespawn.biome.hide_routine": "Ocultar comuns", + "button.orespawn.biome.replace_new_terrain": "Substituir em terreno novo por...", + "button.orespawn.biome.replace_with": "Substituir em terreno novo por %s...", + "button.orespawn.biome.leave_original": "Manter o comportamento original", + "button.orespawn.biome.palettes": "Paletas: %s", + "button.orespawn.biome.reset_selected": "Redefinir bioma", + "button.orespawn.biome.confirm_reset": "Confirmar redefinição", + "button.orespawn.biome.reset_palette": "Redefinir paleta", + "button.orespawn.biome.confirm_reset_palette": "Confirmar paleta", + "button.orespawn.biome.reset_dimension": "Redefinir dimensão", + "button.orespawn.biome.confirm_reset_dimension": "Confirmar dimensão", + "button.orespawn.biome.reset_all": "Redefinir todos os biomas", + "button.orespawn.biome.confirm_reset_all": "Confirmar tudo", + "value.orespawn.biome.status.user_replaced": "Substituído em terreno novo", + "value.orespawn.biome.status.layered": "Várias regras de colocação", + "value.orespawn.biome.status.disabled": "Colocação desativada", + "value.orespawn.biome.status.missing": "Bioma ou provedor ausente", + "value.orespawn.biome.status.provider_managed": "Gerenciado pelo provedor", + "value.orespawn.biome.status.unmanaged": "Registrado, não gerenciado", + "label.orespawn.biome.rule_count": "Regras de colocação: %s", + "label.orespawn.biome.replaced_by": "Substituição em terreno novo: %s", + "label.orespawn.biome.palette_owner_order": "Proprietário: %s; ordem efetiva: %s", + "label.orespawn.biome.surface_effective": "Esta regra fornece a superfície efetiva.", + "label.orespawn.biome.surface_overridden": "Uma regra ativa posterior substitui esta superfície.", + "label.orespawn.biome.placement_rules": "Regras de colocação", + "label.orespawn.biome.palette_entries": "Entradas de colocação: %s", + "tooltip.orespawn.biome.no_retrogen": "As substituições de bioma só se aplicam ao gerar terreno novo. Chunks existentes nunca são reescritos.", + "tooltip.orespawn.biome.override_palette": "Camada final reservada de substituição. Edite-a pelo controle de substituição de cada bioma.", + "tooltip.orespawn.biome.palette_entries_read_only": "A associação à paleta é definida pelo provedor ou perfil. Selecione um bioma para editar uma regra existente.", + "option.orespawn.biome.coverage": "Cobertura", + "option.orespawn.biome.fallback_weight": "Peso do bioma original", + "option.orespawn.biome.include_namespaces": "Namespaces incluídos (separados por vírgulas)", + "option.orespawn.biome.exclude_namespaces": "Namespaces excluídos (separados por vírgulas)" } diff --git a/src/main/resources/assets/orespawn/lang/ru_ru.json b/src/main/resources/assets/orespawn/lang/ru_ru.json index 492bfd62..c5339060 100644 --- a/src/main/resources/assets/orespawn/lang/ru_ru.json +++ b/src/main/resources/assets/orespawn/lang/ru_ru.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "Выбрать установленный биом", "screen.orespawn.biome_placement": "Настройки биома", "screen.orespawn.biome_references": "Похожие биомы", - "screen.orespawn.dimension_materials": "Материалы мира", + "screen.orespawn.dimension_materials": "Материалы: %s", "button.orespawn.biomes_world_materials": "Биомы и материалы мира...", "button.orespawn.biome_palette_count": "Биомы: %s", "button.orespawn.dimension_materials": "Материалы мира...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "Материалы мира", "guide.orespawn.materials.1": "Материалы мира меняют фоновые материалы во всём измерении. Для редких подземных карманов используйте Месторождения жидкостей.", "guide.orespawn.materials.2": "Жидкость водоносного слоя меняет обычную жидкость ниже уровня моря. Глубокий слой использует вторую жидкость ниже выбранной Y.", - "guide.orespawn.materials.3": "Снег и лёд заменяют ванильный снег и обычный лёд у поверхности. Не задано сохраняет обычный материал Minecraft." + "guide.orespawn.materials.3": "Снег и лёд заменяют ванильный снег и обычный лёд у поверхности. Не задано сохраняет обычный материал Minecraft.", + "button.orespawn.dimension_materials_named": "Материалы: %s...", + "label.orespawn.dimension_materials.scope": "Применяется ко всем биомам новой местности %s.", + "button.orespawn.mods": "Каталог модов", + "button.orespawn.mod.configure": "Настроить", + "tooltip.orespawn.main.mods": "Показать загруженные моды, интегрированные с OreSpawn.", + "screen.orespawn.mods": "Моды OreSpawn", + "tooltip.orespawn.mod.configure": "Открыть настройки %s.", + "tooltip.orespawn.mod.unavailable": "%s не предоставляет экран настроек для OreSpawn.", + "label.orespawn.mod.id_version": "%s - Версия мода %s", + "label.orespawn.mod.additional_lineage": "Также обнаружена совместимость с OreSpawn %s.", + "label.orespawn.mod.status": "Состояние: %s", + "label.orespawn.mod.os4_version": "Встроенный поставщик OS4 - схема %s, ревизия поставщика %s", + "label.orespawn.mod.os4": "Встроенный поставщик OS4", + "label.orespawn.mod.legacy_version": "Совместимость с OreSpawn %s -> схема OS4 %s, ревизия поставщика %s", + "label.orespawn.mod.legacy": "Совместимость с OreSpawn %s -> OS4", + "label.orespawn.mod.config_only": "Интеграция клиентских настроек", + "label.orespawn.mod.none": "Загруженные интеграции OreSpawn не найдены.", + "label.orespawn.mod.integration_status": "%s | %s", + "status.orespawn.mod.active": "Активно", + "status.orespawn.mod.pending": "Ожидание", + "status.orespawn.mod.inactive": "Неактивно", + "status.orespawn.mod.rejected": "Отклонено", + "status.orespawn.mod.config_only": "Только экран настроек", + "error.orespawn.mod.screen_missing": "%s не создал экран настроек.", + "error.orespawn.mod.screen_failed": "Не удалось открыть настройки %s (%s).", + "button.orespawn.ore_sources": "Источники руд...", + "tooltip.orespawn.ore_sources": "Проверить равнозначные руды без умножения частоты размещения.", + "screen.orespawn.ore_sources": "Источники руд", + "screen.orespawn.ore_source_detail": "Правило источника руды", + "label.orespawn.ore_source.none": "Равнозначные управляемые источники руд не найдены.", + "label.orespawn.ore_source.row": "%s - %s - кандидатов: %s - %s", + "label.orespawn.ore_source.intro": "Выберите способ генерации равнозначных руд.", + "label.orespawn.ore_source.summary": "Кандидатов: %s - %s", + "button.orespawn.ore_source.review": "Проверить...", + "button.orespawn.ore_source.configure": "Настроить...", + "label.orespawn.ore_source.material": "Материал: %s", + "label.orespawn.ore_source.domain": "Измерение/область: %s", + "label.orespawn.ore_source.channels": "Каналы размещения: %s", + "label.orespawn.ore_source.missing": "Нет источника размещения", + "label.orespawn.ore_source.owner": "Владелец: %s, версия %s", + "label.orespawn.ore_source.registry": "Зарегистрированный выход: %s, метаданные %s", + "label.orespawn.ore_source.dictionary": "Теги блоков: %s", + "option.orespawn.ore_source.mode": "Режим: %s", + "option.orespawn.ore_source.channel": "Канал: %s", + "option.orespawn.ore_source.placement": "Источник размещения: %s", + "option.orespawn.ore_source.weight": "Вес", + "mode.orespawn.ore_source.consolidated": "Объединено", + "mode.orespawn.ore_source.keep_separate": "Хранить отдельно", + "status.orespawn.ore_source.consolidated": "Объединено", + "status.orespawn.ore_source.separate": "Раздельно", + "status.orespawn.ore_source.review_required": "Требуется проверка", + "status.orespawn.ore_source.missing_source": "Источник отсутствует", + "status.orespawn.ore_source.external_generation": "Внешняя генерация", + "tooltip.orespawn.ore_source.open": "Открыть правило этого материала.", + "tooltip.orespawn.ore_source.mode": "Объединение использует один бюджет размещения на канал. Раздельный режим сохраняет каждое настроенное правило.", + "tooltip.orespawn.ore_source.channel": "Механизмы размещения регулируются независимо.", + "tooltip.orespawn.ore_source.placement": "Выберите правило частоты, формы, вмещающих пород, фильтров и высоты для этого канала.", + "tooltip.orespawn.ore_source.weight": "Положительный относительный шанс выбрать этот выход для всей жилы или определённого рудного тела.", + "tooltip.orespawn.ore_source.external": "Этот мод регистрирует генерацию вне OreSpawn; OreSpawn показывает её, но не может отключить.", + "tooltip.orespawn.ore_source.controlled": "OreSpawn управляет этим настроенным размещением и источником выхода.", + "tooltip.orespawn.ore_source.enrichment": "Эта запись является обогащением, а не взаимозаменяемым источником руды.", + "tooltip.orespawn.ore_source.missing": "Источник недоступен. Сохранённый выбор восстановится, когда вернётся его мод.", + "error.orespawn.ore_source.weight": "Веса выходов должны быть положительными числами.", + "button.orespawn.back": "Назад", + "button.orespawn.ore_source.all_groups": "Все группы", + "button.orespawn.ore_source.add_group": "Добавить группу", + "button.orespawn.ore_source.accept": "Принять", + "tooltip.orespawn.ore_source.accept": "Сохраняет режим «Оставить исходное» без изменений и отмечает группу как проверенную. Изменение сохраняется только кнопкой «Готово» в главном редакторе.", + "button.orespawn.ore_source.delete_group": "Удалить", + "button.orespawn.ore_source.dissolve_group": "Расформировать группу", + "button.orespawn.ore_source.confirm_dissolve": "Подтвердить расформирование", + "button.orespawn.ore_source.move": "Переместить", + "button.orespawn.ore_source.advanced": "Расширенные...", + "button.orespawn.ore_source.hide_advanced": "Скрыть расширенные", + "option.orespawn.ore_source.group_name": "Название группы", + "option.orespawn.ore_source.alias": "Запись тегов блоков", + "option.orespawn.ore_source.output_mode": "Результат: %s", + "mode.orespawn.ore_source.keep_original": "Сохранить исходное", + "mode.orespawn.ore_source.balanced": "Равномерно", + "mode.orespawn.ore_source.single": "Один", + "mode.orespawn.ore_source.custom": "Настраиваемый", + "label.orespawn.ore_source.no_aliases": "Нет записей тегов блоков", + "status.orespawn.ore_source.external_short": "внешний", + "status.orespawn.ore_source.missing_short": "отсутствует", + "status.orespawn.ore_source.output_only": "только результат", + "tooltip.orespawn.ore_source.output_only": "Результат этого поставщика доступен, но его правило размещения неактивно.", + "tooltip.orespawn.ore_source.output_mode": "Сохранить исходное оставляет каждое правило. Равномерно делит поровну, Один выбирает один блок, а Настраиваемый использует веса.", + "tooltip.orespawn.ore_source.alias": "Добавьте точное имя руды из тегов блоков. Нажмите Переместить ещё раз, чтобы подтвердить перенос из другой группы.", + "tooltip.orespawn.ore_source.reset_group": "Восстанавливает название и записи тегов блоков этой подготовленной группы.", + "tooltip.orespawn.ore_source.delete_group": "Удаляет эту пустую настраиваемую группу без перезаписи созданных чанков.", + "tooltip.orespawn.ore_source.dissolve_group": "Возвращает каждую запись в вычисленную или подготовленную группу. Нажмите дважды для подтверждения.", + "error.orespawn.ore_source.confirm_dissolve": "Нажмите «Подтвердить расформирование», чтобы вернуть все записи тегов блоков в исходные группы.", + "error.orespawn.ore_source.alias": "Используйте точное имя из тегов блоков, например forge:ores/sulfur.", + "error.orespawn.ore_source.alias_owned": "%s уже владеет этой записью. Нажмите Переместить для переназначения.", + "error.orespawn.ore_source.manage_vanilla_first": "Включите управление ванильными рудами, прежде чем перемещать ванильную руду в другую группу.", + "error.orespawn.ore_source.defaults_write": "Не удалось сохранить значения групп материалов по умолчанию.", + "screen.orespawn.ore_source.group_settings": "Настройки группы источников руды", + "label.orespawn.ore_source.groups": "Группы", + "label.orespawn.ore_source.aliases": "Записи тегов блоков", + "label.orespawn.ore_source.placement_rules": "Правила размещения", + "label.orespawn.ore_source.placement_explanation": "Выходы выбирают блок; правила выбирают частоту, форму, глубину и вмещающие породы.", + "label.orespawn.ore_source.standard_veins": "Стандартные жилы", + "label.orespawn.ore_source.placement_choice_count": "%s (вариантов: %s)", + "label.orespawn.ore_source.no_placement_rules": "Нет управляемых правил размещения", + "tooltip.orespawn.ore_source.group_settings": "Изменить имя, псевдонимы и правила размещения этой группы.", + "tooltip.orespawn.ore_source.remove_alias": "Удалить %s из этой группы.", + "tooltip.orespawn.ore_source.placement_rules": "Выходы выбирают блок руды; правила выбирают частоту, форму, глубину и ограничения вмещающих пород.", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "Выбор размещения применяется только к объединённым режимам Равный, Один или Пользовательский.", + "tooltip.orespawn.ore_source.placement_missing": "Сохранённый источник размещения отсутствует; канал остаётся отключённым.", + "tooltip.orespawn.ore_source.placement_readonly": "Доступно только одно активное управляемое правило; источник доступен только для чтения.", + "tooltip.orespawn.ore_source.placement_select": "Нажмите для выбора активного управляемого источника размещения.", + "tooltip.orespawn.ore_source.external_excluded": "Независимые внешние генераторы отображаются, но не могут быть выбраны как правила размещения.", + "button.orespawn.ore_source.hide_single": "Скрыть одиночные", + "tooltip.orespawn.ore_source.group_colours": "Зелёные записи имеют один псевдоним и один выход и не требуют действий. Жёлтые группы имеют сохранённое правило. Красные группы требуют внимания. Показать все также показывает зелёные записи.", + "button.orespawn.ore_source.reset_all": "Сбросить всё", + "button.orespawn.ore_source.confirm_reset_all": "Подтвердить сброс", + "tooltip.orespawn.ore_source.reset_all": "Восстанавливает встроенные группы материалов и заново создаёт правила по загруженным рудам. Изменение остаётся ожидающим до нажатия Готово в главном редакторе.", + "screen.orespawn.biomes": "Биомы", + "screen.orespawn.biome_palettes": "Палитры биомов", + "screen.orespawn.biome_palette_settings": "Настройки палитры биомов", + "button.orespawn.biome.hide_routine": "Скрыть обычные", + "button.orespawn.biome.replace_new_terrain": "Заменять в новой местности на...", + "button.orespawn.biome.replace_with": "Заменять в новой местности на %s...", + "button.orespawn.biome.leave_original": "Оставить исходное поведение", + "button.orespawn.biome.palettes": "Палитры: %s", + "button.orespawn.biome.reset_selected": "Сбросить биом", + "button.orespawn.biome.confirm_reset": "Подтвердить сброс", + "button.orespawn.biome.reset_palette": "Сбросить палитру", + "button.orespawn.biome.confirm_reset_palette": "Подтвердить палитру", + "button.orespawn.biome.reset_dimension": "Сбросить измерение", + "button.orespawn.biome.confirm_reset_dimension": "Подтвердить измерение", + "button.orespawn.biome.reset_all": "Сбросить все биомы", + "button.orespawn.biome.confirm_reset_all": "Подтвердить всё", + "value.orespawn.biome.status.user_replaced": "Заменён в новой местности", + "value.orespawn.biome.status.layered": "Несколько правил размещения", + "value.orespawn.biome.status.disabled": "Размещение отключено", + "value.orespawn.biome.status.missing": "Биом или поставщик отсутствует", + "value.orespawn.biome.status.provider_managed": "Управляется поставщиком", + "value.orespawn.biome.status.unmanaged": "Зарегистрирован, не управляется", + "label.orespawn.biome.rule_count": "Правила размещения: %s", + "label.orespawn.biome.replaced_by": "Замена в новой местности: %s", + "label.orespawn.biome.palette_owner_order": "Владелец: %s; итоговый порядок: %s", + "label.orespawn.biome.surface_effective": "Эта правило задаёт итоговую поверхность.", + "label.orespawn.biome.surface_overridden": "Более позднее активное правило заменяет эту поверхность.", + "label.orespawn.biome.placement_rules": "Правила размещения", + "label.orespawn.biome.palette_entries": "Записи размещения: %s", + "tooltip.orespawn.biome.no_retrogen": "Замены биомов применяются только при создании новой местности. Существующие чанки не переписываются.", + "tooltip.orespawn.biome.override_palette": "Зарезервированный финальный слой замен. Изменяйте его через управление заменой каждого биома.", + "tooltip.orespawn.biome.palette_entries_read_only": "Состав палитры задаётся поставщиком или профилем. Выберите биом для изменения существующего правила.", + "option.orespawn.biome.coverage": "Покрытие", + "option.orespawn.biome.fallback_weight": "Вес исходного биома", + "option.orespawn.biome.include_namespaces": "Включённые пространства имён (через запятую)", + "option.orespawn.biome.exclude_namespaces": "Исключённые пространства имён (через запятую)" } diff --git a/src/main/resources/assets/orespawn/lang/zh_cn.json b/src/main/resources/assets/orespawn/lang/zh_cn.json index 79cb0e5b..d95a358b 100644 --- a/src/main/resources/assets/orespawn/lang/zh_cn.json +++ b/src/main/resources/assets/orespawn/lang/zh_cn.json @@ -302,7 +302,7 @@ "screen.orespawn.choose_biome": "选择已安装的生物群系", "screen.orespawn.biome_placement": "生物群系设置", "screen.orespawn.biome_references": "相似生物群系", - "screen.orespawn.dimension_materials": "世界材料", + "screen.orespawn.dimension_materials": "%s 材料", "button.orespawn.biomes_world_materials": "生物群系和世界材料...", "button.orespawn.biome_palette_count": "生物群系: %s", "button.orespawn.dimension_materials": "世界材料...", @@ -355,5 +355,158 @@ "guide.orespawn.materials.title": "世界材料", "guide.orespawn.materials.1": "世界材料会改变整个维度的基础材料。若只想要偶尔出现的地下囊体,请使用流体矿床。", "guide.orespawn.materials.2": "含水层流体会改变海平面以下使用的普通流体。深层含水层会在所选Y值以下使用第二种流体。", - "guide.orespawn.materials.3": "雪和冰会替换地表附近的原版雪与普通冰。“未设置”会保留Minecraft的普通材料。" + "guide.orespawn.materials.3": "雪和冰会替换地表附近的原版雪与普通冰。“未设置”会保留Minecraft的普通材料。", + "button.orespawn.dimension_materials_named": "%s 材料...", + "label.orespawn.dimension_materials.scope": "应用于新生成的 %s 地形中的每个生物群系。", + "button.orespawn.mods": "模组目录", + "button.orespawn.mod.configure": "配置", + "tooltip.orespawn.main.mods": "查看已加载并与OreSpawn集成的模组。", + "screen.orespawn.mods": "OreSpawn模组", + "tooltip.orespawn.mod.configure": "打开%s的配置。", + "tooltip.orespawn.mod.unavailable": "%s未提供与OreSpawn关联的配置界面。", + "label.orespawn.mod.id_version": "%s - 模组版本%s", + "label.orespawn.mod.additional_lineage": "同时检测到OreSpawn %s兼容方式。", + "label.orespawn.mod.status": "状态:%s", + "label.orespawn.mod.os4_version": "原生OS4提供方 - 架构%s,提供方修订%s", + "label.orespawn.mod.os4": "原生OS4提供方", + "label.orespawn.mod.legacy_version": "OreSpawn %s兼容 -> OS4架构%s,提供方修订%s", + "label.orespawn.mod.legacy": "OreSpawn %s兼容 -> OS4", + "label.orespawn.mod.config_only": "客户端配置集成", + "label.orespawn.mod.none": "未找到已加载的OreSpawn集成。", + "label.orespawn.mod.integration_status": "%s|%s", + "status.orespawn.mod.active": "已启用", + "status.orespawn.mod.pending": "待处理", + "status.orespawn.mod.inactive": "未启用", + "status.orespawn.mod.rejected": "已拒绝", + "status.orespawn.mod.config_only": "仅配置界面", + "error.orespawn.mod.screen_missing": "%s未创建配置界面。", + "error.orespawn.mod.screen_failed": "无法打开%s的配置(%s)。", + "button.orespawn.ore_sources": "矿石来源...", + "tooltip.orespawn.ore_sources": "检查等价矿石输出,同时避免生成频率叠加。", + "screen.orespawn.ore_sources": "矿石来源", + "screen.orespawn.ore_source_detail": "矿石来源策略", + "label.orespawn.ore_source.none": "未找到等价的受管理矿石来源。", + "label.orespawn.ore_source.row": "%s - %s - %s 个候选 - %s", + "label.orespawn.ore_source.intro": "选择等价矿石的生成方式。", + "label.orespawn.ore_source.summary": "%s 个候选 - %s", + "button.orespawn.ore_source.review": "审查...", + "button.orespawn.ore_source.configure": "配置...", + "label.orespawn.ore_source.material": "材料:%s", + "label.orespawn.ore_source.domain": "维度/范围:%s", + "label.orespawn.ore_source.channels": "放置通道:%s", + "label.orespawn.ore_source.missing": "缺少放置来源", + "label.orespawn.ore_source.owner": "所有者:%s,版本 %s", + "label.orespawn.ore_source.registry": "注册输出:%s,元数据 %s", + "label.orespawn.ore_source.dictionary": "矿石词典:%s", + "option.orespawn.ore_source.mode": "模式:%s", + "option.orespawn.ore_source.channel": "通道:%s", + "option.orespawn.ore_source.placement": "放置来源:%s", + "option.orespawn.ore_source.weight": "权重", + "mode.orespawn.ore_source.consolidated": "合并", + "mode.orespawn.ore_source.keep_separate": "保持独立", + "status.orespawn.ore_source.consolidated": "已合并", + "status.orespawn.ore_source.separate": "独立", + "status.orespawn.ore_source.review_required": "需要检查", + "status.orespawn.ore_source.missing_source": "来源缺失", + "status.orespawn.ore_source.external_generation": "外部生成", + "tooltip.orespawn.ore_source.open": "打开此材料策略。", + "tooltip.orespawn.ore_source.mode": "合并模式每个通道只使用一份放置预算。保持独立会保留每条已配置规则。", + "tooltip.orespawn.ore_source.channel": "各放置引擎会被独立协调。", + "tooltip.orespawn.ore_source.placement": "选择控制此通道频率、形状、宿主、筛选和高度的规则。", + "tooltip.orespawn.ore_source.weight": "为整条矿脉或已识别矿体选择此输出的正相对概率。", + "tooltip.orespawn.ore_source.external": "此模组在 OreSpawn 外部注册生成;OreSpawn 可以报告但无法抑制。", + "tooltip.orespawn.ore_source.controlled": "OreSpawn 控制此已配置的放置与输出来源。", + "tooltip.orespawn.ore_source.enrichment": "此条目属于富集矿,不是可互换的矿石来源。", + "tooltip.orespawn.ore_source.missing": "此来源不可用。其所属模组恢复后将自动恢复已保存的选择。", + "error.orespawn.ore_source.weight": "输出权重必须为正数。", + "button.orespawn.back": "返回", + "button.orespawn.ore_source.all_groups": "所有分组", + "button.orespawn.ore_source.add_group": "添加分组", + "button.orespawn.ore_source.accept": "接受", + "tooltip.orespawn.ore_source.accept": "保持“保留原始设置”不变,并将此分组标记为已审核。只有在主编辑器中点击“完成”后才会保存。", + "button.orespawn.ore_source.delete_group": "删除", + "button.orespawn.ore_source.dissolve_group": "解散分组", + "button.orespawn.ore_source.confirm_dissolve": "确认解散", + "button.orespawn.ore_source.move": "移动", + "button.orespawn.ore_source.advanced": "高级...", + "button.orespawn.ore_source.hide_advanced": "隐藏高级选项", + "option.orespawn.ore_source.group_name": "分组名称", + "option.orespawn.ore_source.alias": "方块标签条目", + "option.orespawn.ore_source.output_mode": "输出:%s", + "mode.orespawn.ore_source.keep_original": "保持原样", + "mode.orespawn.ore_source.balanced": "均衡", + "mode.orespawn.ore_source.single": "单一", + "mode.orespawn.ore_source.custom": "自定义", + "label.orespawn.ore_source.no_aliases": "无方块标签条目", + "status.orespawn.ore_source.external_short": "外部", + "status.orespawn.ore_source.missing_short": "缺失", + "status.orespawn.ore_source.output_only": "仅输出", + "tooltip.orespawn.ore_source.output_only": "此提供方输出可用,但其放置规则未启用。", + "tooltip.orespawn.ore_source.output_mode": "保持原样会保留每条规则;均衡会平均选择;单一只选择一个方块;自定义允许加权选择。", + "tooltip.orespawn.ore_source.alias": "添加准确的方块标签名称。再次按“移动”以确认从其他分组重新分配。", + "tooltip.orespawn.ore_source.reset_group": "恢复此预设分组的名称和方块标签条目。", + "tooltip.orespawn.ore_source.delete_group": "删除此空的自定义分组,不重写已生成的区块。", + "tooltip.orespawn.ore_source.dissolve_group": "将每个条目还原到推断或预设的分组。按两次以确认。", + "error.orespawn.ore_source.confirm_dissolve": "按“确认解散”可将每个方块标签条目还原到原分组。", + "error.orespawn.ore_source.alias": "请使用准确的方块标签名称,例如 forge:ores/sulfur。", + "error.orespawn.ore_source.alias_owned": "%s 已拥有此条目。按“移动”可重新分配。", + "error.orespawn.ore_source.manage_vanilla_first": "将原版矿石移入其他分组前,请先启用“管理原版矿石”。", + "error.orespawn.ore_source.defaults_write": "无法保存材料分组默认值。", + "screen.orespawn.ore_source.group_settings": "矿石来源分组设置", + "label.orespawn.ore_source.groups": "分组", + "label.orespawn.ore_source.aliases": "方块标签条目", + "label.orespawn.ore_source.placement_rules": "放置规则", + "label.orespawn.ore_source.placement_explanation": "输出选择方块;放置规则选择频率、形状、深度和宿主。", + "label.orespawn.ore_source.standard_veins": "标准矿脉", + "label.orespawn.ore_source.placement_choice_count": "%s(%s 个选项)", + "label.orespawn.ore_source.no_placement_rules": "没有受管理的放置规则", + "tooltip.orespawn.ore_source.group_settings": "编辑此分组的名称、别名和放置规则。", + "tooltip.orespawn.ore_source.remove_alias": "从此分组移除 %s。", + "tooltip.orespawn.ore_source.placement_rules": "输出选择矿石方块;放置规则选择频率、形状、深度和宿主限制。", + "tooltip.orespawn.ore_source.placement_requires_consolidated": "放置来源仅适用于合并的均衡、单一或自定义模式。", + "tooltip.orespawn.ore_source.placement_missing": "保存的放置来源缺失;此通道保持禁用。", + "tooltip.orespawn.ore_source.placement_readonly": "只有一个有效的受管理规则,因此此来源为只读。", + "tooltip.orespawn.ore_source.placement_select": "点击以选择有效的受管理放置来源。", + "tooltip.orespawn.ore_source.external_excluded": "独立外部生成器会显示,但不能选作放置规则。", + "button.orespawn.ore_source.hide_single": "隐藏单项", + "tooltip.orespawn.ore_source.group_colours": "绿色条目只有一个别名和一个输出,无需处理。黄色分组已有保存的规则。红色分组需要处理。显示全部会包含绿色条目。", + "button.orespawn.ore_source.reset_all": "全部重置", + "button.orespawn.ore_source.confirm_reset_all": "确认重置", + "tooltip.orespawn.ore_source.reset_all": "恢复内置材料分组,并根据当前加载的矿石重新创建来源策略。更改会保持待定,直到在主编辑器中点击完成。", + "screen.orespawn.biomes": "生物群系", + "screen.orespawn.biome_palettes": "生物群系调色板", + "screen.orespawn.biome_palette_settings": "生物群系调色板设置", + "button.orespawn.biome.hide_routine": "隐藏常规项", + "button.orespawn.biome.replace_new_terrain": "在新地形中替换为...", + "button.orespawn.biome.replace_with": "在新地形中替换为 %s...", + "button.orespawn.biome.leave_original": "保留原始行为", + "button.orespawn.biome.palettes": "调色板:%s", + "button.orespawn.biome.reset_selected": "重置生物群系", + "button.orespawn.biome.confirm_reset": "确认重置", + "button.orespawn.biome.reset_palette": "重置调色板", + "button.orespawn.biome.confirm_reset_palette": "确认调色板重置", + "button.orespawn.biome.reset_dimension": "重置维度", + "button.orespawn.biome.confirm_reset_dimension": "确认维度重置", + "button.orespawn.biome.reset_all": "重置全部生物群系", + "button.orespawn.biome.confirm_reset_all": "确认全部重置", + "value.orespawn.biome.status.user_replaced": "已在新地形中替换", + "value.orespawn.biome.status.layered": "多个放置规则", + "value.orespawn.biome.status.disabled": "放置已禁用", + "value.orespawn.biome.status.missing": "缺少生物群系或提供者", + "value.orespawn.biome.status.provider_managed": "由提供者管理", + "value.orespawn.biome.status.unmanaged": "已注册,未管理", + "label.orespawn.biome.rule_count": "放置规则:%s", + "label.orespawn.biome.replaced_by": "新地形替换:%s", + "label.orespawn.biome.palette_owner_order": "所有者:%s;生效顺序:%s", + "label.orespawn.biome.surface_effective": "此规则提供最终生效的表面。", + "label.orespawn.biome.surface_overridden": "后续启用的规则会覆盖此表面。", + "label.orespawn.biome.placement_rules": "放置规则", + "label.orespawn.biome.palette_entries": "放置项:%s", + "tooltip.orespawn.biome.no_retrogen": "生物群系替换仅在生成新地形时应用。现有区块绝不会被重写。", + "tooltip.orespawn.biome.override_palette": "保留的最终替换层。请通过各生物群系的替换控件进行编辑。", + "tooltip.orespawn.biome.palette_entries_read_only": "调色板成员由提供者或配置定义。请在目录中选择生物群系以编辑现有规则。", + "option.orespawn.biome.coverage": "覆盖率", + "option.orespawn.biome.fallback_weight": "原始生物群系权重", + "option.orespawn.biome.include_namespaces": "包含的命名空间(逗号分隔)", + "option.orespawn.biome.exclude_namespaces": "排除的命名空间(逗号分隔)" } diff --git a/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeBridge.java b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeBridge.java new file mode 100644 index 00000000..0f44fb08 --- /dev/null +++ b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeBridge.java @@ -0,0 +1,37 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.Map; +import net.minecraft.resources.ResourceKey; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; + +/** Inspects generation caches without adding public API or sharing a mod's Java package. */ +final class NativeOreProbeBridge { + private NativeOreProbeBridge() { } + + static boolean controlsQuartz() { + try { + Class generation = Class.forName("zone.moddev.mc.orespawn.worldgen.OreSpawnOreGeneration"); + Method takeover = generation.getDeclaredMethod("takesOverVanillaOre", ResourceKey.class, Block.class); + takeover.setAccessible(true); + return (Boolean) takeover.invoke(null, Level.NETHER, Blocks.NETHER_QUARTZ_ORE); + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not inspect quartz startup", failure); + } + } + + static boolean controlsFluid() { + try { + Class generation = Class.forName("zone.moddev.mc.orespawn.worldgen.FluidDepositFeature"); + Field field = generation.getDeclaredField("depositsByDimension"); + field.setAccessible(true); + Object[] deposits = (Object[]) ((Map) field.get(null)).get(Level.NETHER); + return deposits != null && deposits.length == 1; + } catch (ReflectiveOperationException failure) { + throw new IllegalStateException("Could not inspect fluid startup", failure); + } + } +} diff --git a/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java new file mode 100644 index 00000000..df7a1002 --- /dev/null +++ b/src/nativeOreIntegrationTest/java/zone/moddev/mc/orespawn/testmod/NativeOreProbeTestMod.java @@ -0,0 +1,252 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Properties; + +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraft.core.Registry; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.event.server.ServerAboutToStartEvent; +import net.minecraftforge.event.server.ServerStartedEvent; +import net.minecraftforge.event.server.ServerStoppedEvent; +import zone.moddev.mc.orespawn.worldgen.GeomeConfig; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; + +/** Exercises managed quartz in real Nether terrain, including tag and explicit block hosts. */ +@Mod("nativeoreprobe") +public final class NativeOreProbeTestMod { + private static final String QUARTZ = "minecraft:nether_quartz_ore"; + private static final String NETHER = "minecraft:the_nether"; + + public NativeOreProbeTestMod() { + MinecraftForge.EVENT_BUS.addListener(EventPriority.HIGHEST, this::prepare); + MinecraftForge.EVENT_BUS.addListener(this::audit); + MinecraftForge.EVENT_BUS.addListener(EventPriority.LOWEST, this::checkShutdown); + } + + private void checkShutdown(ServerStoppedEvent event) { + if (WorldGeologyProfileManager.activeServer() != null) { + throw new IllegalStateException("World profile survived server shutdown"); + } + System.out.println("NATIVE_ORE_PROBE shutdown_verified=true"); + } + + private void prepare(ServerAboutToStartEvent event) { + Path profile = profilePath(event.getServer()); + String phase = System.getProperty("nativeoreprobe.phase", "fresh"); + if ("reload".equals(phase)) return; + try { + JsonObject root; + if ("edited".equals(phase)) { + root = new JsonParser().parse(new String(Files.readAllBytes(profile), + StandardCharsets.UTF_8)).getAsJsonObject(); + } else { + root = GeomeConfig.globalBaseProfile().rootCopy(); + Method defaults = GeomeConfig.class.getDeclaredMethod("defaultOreConfig"); + defaults.setAccessible(true); + JsonObject ores = (JsonObject) defaults.invoke(null); + JsonObject onlyQuartz = new JsonObject(); + onlyQuartz.add(QUARTZ, ores.get(QUARTZ)); + root.add("ores", onlyQuartz); + root.addProperty("manage_vanilla_ores", true); + root.addProperty("place_fluid_deposits", true); + root.add("fluid_deposits", fluidProfile()); + root.add("terrain_dimensions", new JsonObject()); + root.add("ore_source_policies", new JsonObject()); + JsonObject retrogen = new JsonObject(); + retrogen.addProperty("enabled", false); + retrogen.addProperty("force", false); + retrogen.addProperty("revision", 0); + root.add("retrogen", retrogen); + } + JsonObject rule = root.getAsJsonObject("ores").getAsJsonObject(QUARTZ) + .getAsJsonObject("dimensions").getAsJsonObject(NETHER); + rule.addProperty("min_y", "edited".equals(phase) ? 100 : 24); + rule.addProperty("max_y", "edited".equals(phase) ? 104 : 28); + rule.addProperty("frequency", 20); + rule.addProperty("quantity", 14); + if ("edited".equals(phase)) { + JsonObject fluid = root.getAsJsonObject("fluid_deposits") + .getAsJsonObject("nativeoreprobe:tag_water") + .getAsJsonObject("dimensions").getAsJsonObject(NETHER); + fluid.addProperty("min_y", 100); + fluid.addProperty("max_y", 104); + } + if ("explicit".equals(System.getProperty("nativeoreprobe.host"))) { + rule.remove("host_tags"); + JsonArray blocks = new JsonArray(); + blocks.add("minecraft:netherrack"); + blocks.add("minecraft:basalt"); + blocks.add("minecraft:blackstone"); + rule.add("host_blocks", blocks); + } + Files.createDirectories(profile.getParent()); + Files.write(profile, new GsonBuilder().setPrettyPrinting().create().toJson(root) + .getBytes(StandardCharsets.UTF_8)); + } catch (IOException | ReflectiveOperationException failure) { + throw new IllegalStateException("Could not prepare disposable quartz profile", failure); + } + } + + private void audit(ServerStartedEvent event) { + MinecraftServer server = event.getServer(); + String phase = System.getProperty("nativeoreprobe.phase", "fresh"); + if (!NativeOreProbeBridge.controlsQuartz()) { + throw new IllegalStateException("Quartz is not controlled by OreSpawn before generation"); + } + if (!NativeOreProbeBridge.controlsFluid()) { + throw new IllegalStateException("Tag-only fluid was not baked before generation"); + } + if (WorldGeologyProfileManager.activeProfile().oreRetrogenEnabled()) { + throw new IllegalStateException("The quartz test must not enable retrogen"); + } + ServerLevel world = server.getLevel(Level.NETHER); + if (world == null) throw new IllegalStateException("Nether is unavailable"); + Path marker = worldRoot(server).resolve("nativeoreprobe.properties"); + Properties previous = new Properties(); + try { + if (!"fresh".equals(phase)) { + try (InputStream input = Files.newInputStream(marker)) { previous.load(input); } + } + Properties current = inspect(world, -8, 24, 28); + current.setProperty("profile_sha256", hex(digest().digest(Files.readAllBytes(profilePath(server))))); + if (!"fresh".equals(phase)) { + for (String name : current.stringPropertyNames()) { + if ("edited".equals(phase) && "profile_sha256".equals(name)) continue; + if (!current.getProperty(name).equals(previous.getProperty(name))) { + throw new IllegalStateException("Reload changed " + name + ": " + previous + " -> " + current); + } + } + } + if ("edited".equals(phase)) { + Properties newTerrain = inspect(world, -24, 100, 104); + current.setProperty("new_terrain_quartz", newTerrain.getProperty("quartz")); + current.setProperty("new_terrain_water", newTerrain.getProperty("water")); + current.setProperty("old_profile_sha256", previous.getProperty("profile_sha256")); + } + current.setProperty(phase + "_verified", "true"); + try (OutputStream output = Files.newOutputStream(marker)) { current.store(output, "Managed Nether quartz"); } + System.out.println("NATIVE_ORE_PROBE " + phase + " " + current); + } catch (IOException failure) { + throw new IllegalStateException("Could not save quartz audit", failure); + } + server.halt(false); + } + + private static Properties inspect(ServerLevel world, int origin, int minY, int maxY) { + // Finish the surrounding row before hashing veins that cross chunk edges. + for (int z = origin - 1; z <= origin + 3; z++) { + for (int x = origin - 1; x <= origin + 3; x++) world.getChunk(x, z); + } + MessageDigest blocks = digest(); + MessageDigest biomes = digest(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + int quartz = 0; + int water = 0; + for (int z = origin; z <= origin + 2; z++) { + for (int x = origin; x <= origin + 2; x++) { + LevelChunk chunk = world.getChunk(x, z); + for (int dz = 0; dz < 16; dz++) { + for (int dx = 0; dx < 16; dx++) { + cursor.set((x << 4) + dx, 64, (z << 4) + dz); + biomes.update(world.registryAccess().registryOrThrow(Registry.BIOME_REGISTRY).getKey(world.getBiome(cursor).value()).toString().getBytes(StandardCharsets.UTF_8)); + biomes.update((byte) 0); + for (int y = 0; y < 256; y++) { + BlockState state = chunk.getBlockState(cursor.set((x << 4) + dx, y, (z << 4) + dz)); + int id = Block.getId(state); + blocks.update((byte) (id >>> 24)); + blocks.update((byte) (id >>> 16)); + blocks.update((byte) (id >>> 8)); + blocks.update((byte) id); + if (state.getBlock() == Blocks.WATER) water++; + if (state.getBlock() == Blocks.NETHER_QUARTZ_ORE) { + if (y < minY || y > maxY) throw new IllegalStateException("Quartz outside managed range at " + cursor); + quartz++; + } + } + } + } + } + } + if (quartz == 0) throw new IllegalStateException("Managed quartz did not generate"); + if (water == 0) throw new IllegalStateException("Tag-only fluid deposits did not generate"); + Properties result = new Properties(); + result.setProperty("quartz", Integer.toString(quartz)); + result.setProperty("water", Integer.toString(water)); + result.setProperty("blocks_sha256", hex(blocks.digest())); + result.setProperty("biomes_sha256", hex(biomes.digest())); + return result; + } + + private static JsonObject fluidProfile() { + JsonObject rule = new JsonObject(); + rule.addProperty("enabled", true); + rule.addProperty("min_y", 50); + rule.addProperty("max_y", 54); + rule.addProperty("frequency", 64); + rule.addProperty("min_radius", 2); + rule.addProperty("max_radius", 2); + rule.addProperty("min_vertical_radius", 1); + rule.addProperty("max_vertical_radius", 1); + rule.addProperty("max_lobes", 1); + rule.addProperty("min_solid_cover", 1); + rule.addProperty("min_solid_shell", 1); + JsonArray hosts = new JsonArray(); + hosts.add("minecraft:base_stone_nether"); + rule.add("host_tags", hosts); + JsonObject dimensions = new JsonObject(); + dimensions.add(NETHER, rule); + JsonObject deposit = new JsonObject(); + deposit.addProperty("enabled", true); + deposit.addProperty("block", "minecraft:water"); + deposit.add("dimensions", dimensions); + JsonObject result = new JsonObject(); + result.add("nativeoreprobe:tag_water", deposit); + return result; + } + + private static Path worldRoot(MinecraftServer server) { + return server.getWorldPath(LevelResource.ROOT).toAbsolutePath().normalize(); + } + + private static Path profilePath(MinecraftServer server) { + return worldRoot(server).resolve("serverconfig/orespawn-worldgen.json"); + } + + private static MessageDigest digest() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); } + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(Character.forDigit((value >>> 4) & 15, 16)); + result.append(Character.forDigit(value & 15, 16)); + } + return result.toString(); + } +} diff --git a/src/nativeOreIntegrationTest/resources/META-INF/mods.toml b/src/nativeOreIntegrationTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..9a645462 --- /dev/null +++ b/src/nativeOreIntegrationTest/resources/META-INF/mods.toml @@ -0,0 +1,23 @@ +modLoader="javafml" +loaderVersion="[40,)" +license="LGPL-2.1" + +[[mods]] +modId="nativeoreprobe" +version="1" +displayName="OreSpawn Native Ore Test" +description='''Test-only managed Nether quartz and tag-only fluid audit.''' + +[[dependencies.nativeoreprobe]] +modId="orespawn" +mandatory=true +versionRange="[4.1.0,5.0.0)" +ordering="AFTER" +side="BOTH" + +[[dependencies.nativeoreprobe]] +modId="minecraft" +mandatory=true +versionRange="[1.18.2]" +ordering="NONE" +side="BOTH" diff --git a/src/nativeOreIntegrationTest/resources/pack.mcmeta b/src/nativeOreIntegrationTest/resources/pack.mcmeta new file mode 100644 index 00000000..69786976 --- /dev/null +++ b/src/nativeOreIntegrationTest/resources/pack.mcmeta @@ -0,0 +1,6 @@ +{ + "pack": { + "description": "OreSpawn native ore qualification fixture", + "pack_format": 9 + } +} diff --git a/src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/advantagefixture/FutureAdvantageTestMod.java b/src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/advantagefixture/FutureAdvantageTestMod.java new file mode 100644 index 00000000..f4d5a049 --- /dev/null +++ b/src/oreSourceAdvantageTest/java/zone/moddev/mc/orespawn/advantagefixture/FutureAdvantageTestMod.java @@ -0,0 +1,91 @@ +package zone.moddev.mc.orespawn.advantagefixture; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.data.BuiltinRegistries; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.levelgen.GenerationStep; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext; +import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.event.world.BiomeLoadingEvent; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent; +import net.minecraftforge.fml.event.lifecycle.InterModEnqueueEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import net.minecraftforge.registries.DeferredRegister; +import net.minecraftforge.registries.ForgeRegistries; +import net.minecraftforge.registries.RegistryObject; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.WorldgenProvider; + +/** Test-only stand-in for a future Advantage ore provider. */ +@Mod(FutureAdvantageTestMod.MODID) +public final class FutureAdvantageTestMod { + static final String MODID = "electricadvantage"; + private static final DeferredRegister> FEATURES = + DeferredRegister.create(ForgeRegistries.FEATURES, MODID); + private static final RegistryObject EXTERNAL_FEATURE = + FEATURES.register("independent_marker", ExternalMarkerFeature::new); + private static Holder externalPlaced; + + public FutureAdvantageTestMod() { + FEATURES.register(FMLJavaModLoadingContext.get().getModEventBus()); + FMLJavaModLoadingContext.get().getModEventBus().addListener(this::setup); + FMLJavaModLoadingContext.get().getModEventBus().addListener(this::enqueue); + MinecraftForge.EVENT_BUS.addListener(this::addExternalGenerator); + } + + private void setup(FMLCommonSetupEvent event) { + event.enqueueWork(() -> { + ResourceLocation id = new ResourceLocation(MODID, "independent_marker"); + Holder> configured = BuiltinRegistries.register( + BuiltinRegistries.CONFIGURED_FEATURE, id, + new ConfiguredFeature<>(EXTERNAL_FEATURE.get(), NoneFeatureConfiguration.INSTANCE)); + externalPlaced = BuiltinRegistries.register(BuiltinRegistries.PLACED_FEATURE, id, + new PlacedFeature(configured, java.util.Collections.emptyList())); + }); + } + + private void addExternalGenerator(BiomeLoadingEvent event) { + if (event.getName() == null || !"minecraft".equals(event.getName().getNamespace())) return; + if (externalPlaced == null) throw new IllegalStateException("External fixture feature was not registered"); + event.getGeneration().getFeatures(GenerationStep.Decoration.UNDERGROUND_ORES) + .add(externalPlaced); + } + + private void enqueue(InterModEnqueueEvent event) { + WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); + provider.ore(id("electricadvantage:sulfur"), id("minecraft:yellow_wool"), ore -> ore + .material(id("orespawn:sulfur")) + .dimension(id("minecraft:overworld"), rule -> rule + .yRange(20, 42).attempts(20.0D).quantity(7) + .hostBlock(id("minecraft:stone")))); + provider.ore(id("electricadvantage:sulfur_output"), id("minecraft:green_wool"), ore -> ore + .material(id("orespawn:sulfur")) + .enabled(false) + .dimension(id("minecraft:overworld"), rule -> rule + .yRange(20, 42).attempts(20.0D).quantity(7) + .hostBlock(id("minecraft:stone")))); + if (!OreSpawnApi.enqueue(provider.build())) { + throw new IllegalStateException("Advantage fixture was not sent to OreSpawn"); + } + } + + private static ResourceLocation id(String value) { return new ResourceLocation(value); } + + private static final class ExternalMarkerFeature extends Feature { + private ExternalMarkerFeature() { super(NoneFeatureConfiguration.CODEC); } + + @Override + public boolean place(FeaturePlaceContext context) { + BlockPos origin = context.origin(); + return context.level().setBlock(new BlockPos(origin.getX(), 60, origin.getZ()), + Blocks.RED_WOOL.defaultBlockState(), 2); + } + } +} diff --git a/src/oreSourceAdvantageTest/resources/META-INF/mods.toml b/src/oreSourceAdvantageTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..7242f423 --- /dev/null +++ b/src/oreSourceAdvantageTest/resources/META-INF/mods.toml @@ -0,0 +1,16 @@ +modLoader="javafml" +loaderVersion="[40,)" +license="LGPL-2.1" + +[[mods]] +modId="electricadvantage" +version="1" +displayName="Future Electric Advantage Test" +description='''Test-only OreSpawn provider fixture.''' + +[[dependencies.electricadvantage]] +modId="orespawn" +mandatory=true +versionRange="[4.1.0,5.0.0)" +ordering="AFTER" +side="BOTH" diff --git a/src/oreSourceAdvantageTest/resources/data/forge/tags/blocks/ores/sulfur.json b/src/oreSourceAdvantageTest/resources/data/forge/tags/blocks/ores/sulfur.json new file mode 100644 index 00000000..e3a06399 --- /dev/null +++ b/src/oreSourceAdvantageTest/resources/data/forge/tags/blocks/ores/sulfur.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": ["minecraft:yellow_wool", "minecraft:green_wool", "minecraft:redstone_ore"] +} diff --git a/src/oreSourceAdvantageTest/resources/pack.mcmeta b/src/oreSourceAdvantageTest/resources/pack.mcmeta new file mode 100644 index 00000000..33f26fda --- /dev/null +++ b/src/oreSourceAdvantageTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "Test-only Advantage OreSpawn fixture", + "forge:resource_pack_format": 8, + "forge:data_pack_format": 9, + "pack_format": 9 + } +} diff --git a/src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java b/src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java new file mode 100644 index 00000000..710df136 --- /dev/null +++ b/src/oreSourceMineralsTest/java/zone/moddev/mc/orespawn/testmod/FutureMineralsTestMod.java @@ -0,0 +1,133 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Properties; + +import com.google.gson.JsonObject; + +import net.minecraft.world.level.block.Blocks; +import net.minecraft.server.MinecraftServer; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.fml.ModList; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.fml.event.lifecycle.InterModEnqueueEvent; +import net.minecraftforge.event.server.ServerStartedEvent; +import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext; +import zone.moddev.mc.orespawn.api.OreSpawnApi; +import zone.moddev.mc.orespawn.api.WorldgenProvider; +import zone.moddev.mc.orespawn.worldgen.WorldGeologyProfileManager; + +/** Test-only stand-in for a future Base Minerals provider. */ +@Mod(FutureMineralsTestMod.MODID) +public final class FutureMineralsTestMod { + static final String MODID = "baseminerals"; + private static final String PHASE = "oresourceprobe.phase"; + + public FutureMineralsTestMod() { + FMLJavaModLoadingContext.get().getModEventBus().addListener(this::enqueue); + MinecraftForge.EVENT_BUS.addListener(this::audit); + } + + private void enqueue(InterModEnqueueEvent event) { + WorldgenProvider.Builder provider = WorldgenProvider.builder(MODID, 1); + provider.ore(id("baseminerals:sulfur"), id("minecraft:blue_wool"), ore -> ore + .material(id("orespawn:sulfur")) + .dimension(id("minecraft:overworld"), rule -> rule + .yRange(20, 42).attempts(20.0D).quantity(7) + .hostBlock(id("minecraft:stone")))); + if (!OreSpawnApi.enqueue(provider.build())) { + throw new IllegalStateException("Base Minerals fixture was not sent to OreSpawn"); + } + } + + private void audit(ServerStartedEvent event) { + String phase = System.getProperty(PHASE, ""); + if (!"fresh".equals(phase) && !"reload".equals(phase) + && !"missing".equals(phase) && !"restored".equals(phase)) return; + boolean both = ModList.get().isLoaded("electricadvantage"); + boolean sharedGroup = both || "missing".equals(phase); + MinecraftServer server = event.getServer(); + JsonObject profile = WorldGeologyProfileManager.activeProfile().rootCopy(); + String key = "orespawn:sulfur|minecraft:overworld"; + JsonObject policy = profile.getAsJsonObject("ore_source_policies").getAsJsonObject(key); + if (policy == null) throw new IllegalStateException("Sulfur policy is missing"); + String mode = policy.get("mode").getAsString(); + if (!mode.equals(sharedGroup ? "consolidated" : "keep_separate")) { + throw new IllegalStateException("Unexpected Sulfur policy with " + (both ? "both" : "one") + + " providers: " + mode); + } + if (sharedGroup) { + if (!"balanced".equals(policy.get("output_mode").getAsString()) + || policy.getAsJsonObject("outputs").size() != 3 + || policy.getAsJsonObject("placement_sources").size() != 1 + || !"baseminerals:sulfur".equals(policy.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString())) { + throw new IllegalStateException("Balanced Sulfur policy lacks the three outputs and one budget: " + + policy); + } + } + ServerLevel world = server.getLevel(Level.OVERWORLD); + if (world == null) throw new IllegalStateException("Overworld is unavailable"); + int blue = 0, yellow = 0, green = 0, external = 0; + BlockPos.MutableBlockPos pos = new BlockPos.MutableBlockPos(); + int spawnChunkX = world.getSharedSpawnPos().getX() >> 4; + int spawnChunkZ = world.getSharedSpawnPos().getZ() >> 4; + for (int chunkX = spawnChunkX - 1; chunkX <= spawnChunkX + 1; chunkX++) { + for (int chunkZ = spawnChunkZ - 1; chunkZ <= spawnChunkZ + 1; chunkZ++) { + if ("fresh".equals(phase) && !world.hasChunk(chunkX, chunkZ)) { + throw new IllegalStateException("Spawn chunk was not generated before the audit"); + } + world.getChunk(chunkX, chunkZ); + for (int x = chunkX * 16; x < (chunkX + 1) * 16; x++) { + for (int z = chunkZ * 16; z < (chunkZ + 1) * 16; z++) { + if (world.getBlockState(pos.set(x, 60, z)).getBlock() == Blocks.RED_WOOL) external++; + for (int y = 20; y <= 42; y++) { + pos.set(x, y, z); + if (world.getBlockState(pos).getBlock() == Blocks.BLUE_WOOL) blue++; + if (world.getBlockState(pos).getBlock() == Blocks.YELLOW_WOOL) yellow++; + if (world.getBlockState(pos).getBlock() == Blocks.GREEN_WOOL) green++; + } + } + } + } + } + if (blue == 0 || (sharedGroup && (yellow == 0 || green == 0)) + || (!sharedGroup && (yellow != 0 || green != 0)) + || (both && external == 0)) { + throw new IllegalStateException("Wrong ore outputs in generated chunks: blue=" + blue + + " yellow=" + yellow + " green=" + green + " external=" + external); + } + Path marker = server.getWorldPath(LevelResource.ROOT).normalize().resolve("oresourceprobe.properties"); + Properties values = new Properties(); + values.setProperty("mode", mode); + values.setProperty("blue", Integer.toString(blue)); + values.setProperty("yellow", Integer.toString(yellow)); + values.setProperty("green", Integer.toString(green)); + values.setProperty("external", Integer.toString(external)); + if (!"fresh".equals(phase)) { + Properties previous = new Properties(); + try (InputStream input = Files.newInputStream(marker)) { previous.load(input); } + catch (IOException failure) { throw new IllegalStateException("Missing fresh ore audit", failure); } + for (String name : values.stringPropertyNames()) { + if (!values.getProperty(name).equals(previous.getProperty(name))) { + throw new IllegalStateException("Reload changed " + name + ": " + previous + " -> " + values); + } + } + values.setProperty(phase + "_verified", "true"); + } + try (OutputStream output = Files.newOutputStream(marker)) { values.store(output, "Ore source fixture"); } + catch (IOException failure) { throw new IllegalStateException("Could not save ore audit", failure); } + server.halt(false); + } + + private static ResourceLocation id(String value) { return new ResourceLocation(value); } +} diff --git a/src/oreSourceMineralsTest/resources/META-INF/mods.toml b/src/oreSourceMineralsTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..07330894 --- /dev/null +++ b/src/oreSourceMineralsTest/resources/META-INF/mods.toml @@ -0,0 +1,16 @@ +modLoader="javafml" +loaderVersion="[40,)" +license="LGPL-2.1" + +[[mods]] +modId="baseminerals" +version="1" +displayName="Future Base Minerals Test" +description='''Test-only OreSpawn provider fixture.''' + +[[dependencies.baseminerals]] +modId="orespawn" +mandatory=true +versionRange="[4.1.0,5.0.0)" +ordering="AFTER" +side="BOTH" diff --git a/src/oreSourceMineralsTest/resources/data/forge/tags/blocks/ores/sulfur.json b/src/oreSourceMineralsTest/resources/data/forge/tags/blocks/ores/sulfur.json new file mode 100644 index 00000000..439ddefb --- /dev/null +++ b/src/oreSourceMineralsTest/resources/data/forge/tags/blocks/ores/sulfur.json @@ -0,0 +1,4 @@ +{ + "replace": false, + "values": ["minecraft:blue_wool"] +} diff --git a/src/oreSourceMineralsTest/resources/pack.mcmeta b/src/oreSourceMineralsTest/resources/pack.mcmeta new file mode 100644 index 00000000..f4dc0b9e --- /dev/null +++ b/src/oreSourceMineralsTest/resources/pack.mcmeta @@ -0,0 +1,8 @@ +{ + "pack": { + "description": "Test-only Base Minerals OreSpawn fixture", + "forge:resource_pack_format": 8, + "forge:data_pack_format": 9, + "pack_format": 9 + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java index ce5231e9..9c989341 100644 --- a/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/LocalizationParityTest.java @@ -16,6 +16,7 @@ import com.google.gson.JsonElement; import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.util.JsonCopies; import com.google.gson.JsonParser; import org.junit.jupiter.api.Test; @@ -32,6 +33,7 @@ class LocalizationParityTest { * Human-facing prose must never be added here merely to make this test pass. */ private static final Map> INTENTIONAL_ENGLISH_VALUES = Map.ofEntries( + Map.entry("screen.orespawn.biomes", Set.of("fr_ca.json", "fr_fr.json")), Map.entry("button.orespawn.world_settings", Set.of( "de_au.json", "de_de.json", "es_es.json", "es_mx.json", "fr_ca.json", "fr_fr.json", "ja_jp.json", "ko_kr.json", "pt_br.json", "ru_ru.json", @@ -78,8 +80,8 @@ class LocalizationParityTest { @Test void everyLocaleMatchesEnglishKeysAndFormatting() throws Exception { JsonObject english = read(LANG_DIR.resolve("en_us.json")); - Set englishKeys = english.keySet(); - assertEquals(357, englishKeys.size(), + Set englishKeys = JsonCopies.keys(english); + assertEquals(510, englishKeys.size(), "The target locale contract changed; review every shipped translation"); Set localeFiles = new HashSet<>(); Set observedIntentionalEnglishValues = new HashSet<>(); diff --git a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java index 9b453a4a..ece05feb 100644 --- a/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/ReleaseWorkflowContractTest.java @@ -25,8 +25,13 @@ void usesOreSpawnSpecificMavenNamespace() throws Exception { @Test void verifiesGeneratedMavenCoordinatesBeforeCheckAndPublication() throws Exception { - Path buildFile = Paths.get("build.gradle"); - String build = new String(Files.readAllBytes(buildFile), StandardCharsets.UTF_8); + StringBuilder sources = new StringBuilder(); + for (Path buildFile : new Path[] { Paths.get("build.gradle"), + Paths.get("gradle", "release", "artifacts.gradle"), + Paths.get("gradle", "release", "publishing.gradle") }) { + sources.append(new String(Files.readAllBytes(buildFile), StandardCharsets.UTF_8)); + } + String build = sources.toString(); assertTrue(build.contains("tasks.register('verifyMavenCoordinates')")); assertTrue(build.contains("generatePomFileForMavenJavaPublication")); assertTrue(build.contains("dependsOn tasks.named('verifyMavenCoordinates')")); @@ -34,12 +39,26 @@ void verifiesGeneratedMavenCoordinatesBeforeCheckAndPublication() throws Excepti } @Test - void hostedWorkflowsUseThePinnedTemurinJdkForGradleAndCompilation() throws Exception { + void hostedWorkflowsUsePinnedJdksAndExerciseAColdForgeBootstrap() throws Exception { for (String workflow : new String[] { "ci.yml", "codeql-analysis.yml" }) { String text = new String(Files.readAllBytes( Paths.get(".github", "workflows", workflow)), StandardCharsets.UTF_8); - assertEquals(1, occurrences(text, "actions/setup-java@"), - workflow + " must not replace the pinned toolchain with a second JDK"); + int jobCount = workflow.equals("ci.yml") ? 2 : 1; + int invocationCount = workflow.equals("ci.yml") ? 3 : 1; + assertEquals(jobCount * 3, occurrences(text, "actions/setup-java@")); + assertEquals(jobCount, occurrences(text, "java-version: '25.0.3+9.0.LTS'")); + assertEquals(jobCount, occurrences(text, "java-version: '8.0.502+7'")); + assertEquals(jobCount, occurrences(text, "java-version: '17.0.1+12'")); + assertTrue(text.lastIndexOf("java-version: '17.0.1+12'") + > text.lastIndexOf("java-version: '25.0.3+9.0.LTS'")); + assertTrue(text.lastIndexOf("java-version: '17.0.1+12'") + > text.lastIndexOf("java-version: '8.0.502+7'")); + assertEquals(invocationCount, occurrences(text, + "$JAVA_HOME,$JAVA_HOME_8_X64,$JAVA_HOME_25_X64")); + assertEquals(invocationCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-detect=false")); + assertEquals(invocationCount, occurrences(text, + "-Dorg.gradle.java.installations.auto-download=false")); assertTrue(text.contains("distribution: temurin"), workflow + " must use Temurin"); assertTrue(text.contains("java-version: '17.0.1+12'"), @@ -47,6 +66,12 @@ void hostedWorkflowsUseThePinnedTemurinJdkForGradleAndCompilation() throws Excep assertFalse(text.contains("distribution: microsoft"), workflow + " must not replace the exact Temurin Gradle runtime"); } + String ci = Files.readString(Paths.get(".github", "workflows", "ci.yml")); + assertTrue(ci.contains("name: Cold Forge bootstrap")); + assertTrue(ci.contains("test ! -e .gradle")); + assertTrue(ci.contains("test ! -e \"$GRADLE_USER_HOME\"")); + assertTrue(ci.contains("classes verifyLegacyFixtures verifyMavenizerCompatibilityFixture")); + assertTrue(ci.contains("--rerun-tasks --offline --no-daemon --no-build-cache")); } private static int occurrences(String text, String needle) { diff --git a/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java new file mode 100644 index 00000000..b4f79ac4 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/OreGenerationContextTest.java @@ -0,0 +1,56 @@ +package zone.moddev.mc.orespawn.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.lang.reflect.Method; +import java.util.Arrays; +import java.util.Optional; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import net.minecraft.resources.ResourceLocation; + +class OreGenerationContextTest { + @Test + void extendsTheOriginalPlacementContractWithoutChangingItsAbi() { + assertTrue(OrePlacementContext.class.isAssignableFrom(OreGenerationContext.class)); + Set baseMethods = Arrays.stream(OrePlacementContext.class.getDeclaredMethods()) + .map(Method::getName).collect(Collectors.toSet()); + assertFalse(baseMethods.contains("worldSeed")); + assertFalse(baseMethods.contains("dimension")); + assertFalse(baseMethods.contains("chunkX")); + assertFalse(baseMethods.contains("chunkZ")); + assertFalse(baseMethods.contains("geologySampler")); + assertEquals(boolean.class, method(OrePlacementContext.class, "tryPlace", + int.class, int.class, int.class).getReturnType()); + } + + private static Method method(Class owner, String name, Class... parameters) { + try { return owner.getMethod(name, parameters); } + catch (ReflectiveOperationException missing) { throw new AssertionError(missing); } + } + + @Test + void exposesTheStableRegionDepositIdentityContract() throws Exception { + assertEquals(long.class, OreGenerationContext.class.getMethod("worldSeed").getReturnType()); + assertEquals(ResourceLocation.class, + OreGenerationContext.class.getMethod("dimension").getReturnType()); + assertEquals(int.class, OreGenerationContext.class.getMethod("chunkX").getReturnType()); + assertEquals(int.class, OreGenerationContext.class.getMethod("chunkZ").getReturnType()); + assertEquals(Optional.class, + OreGenerationContext.class.getMethod("geologySampler").getReturnType()); + assertTrue(OreGenerationContext.class.getMethod("tryPlace", + int.class, int.class, int.class, long.class).isDefault()); + } + + @Test + void oreSpawnPatternContextImplementsTheExtendedContract() throws Exception { + Class implementation = Class.forName( + "zone.moddev.mc.orespawn.worldgen.OreSpawnOreGeneration$PatternContext"); + assertTrue(OreGenerationContext.class.isAssignableFrom(implementation)); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java index 1159c2fe..22050972 100644 --- a/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/api/WorldgenProviderTest.java @@ -41,18 +41,20 @@ void terrainHostContractRetainsNaturalSourceOrder() { } @Test - void serializesTypedSchemaFourProvider() { + void serializesTypedSchemaFiveProvider() { ResourceLocation overworld = id("minecraft:overworld"); WorldgenProvider provider = WorldgenProvider.builder("examplemod", 4) .rock(id("examplemod:slate"), GeologyFamily.METAMORPHIC, rock -> rock .depth(20, 36).weight(1.25D).oreReplaceable(true)) .ore(id("examplemod:tin_ore"), ore -> ore + .material(id("orespawn:tin")) .output(id("examplemod:tin_ore"), 9.0D) .output(id("examplemod:rich_tin_ore"), 1.0D, -64, 24) .suppressVanilla(true).retrogen(false) .dimension(overworld, dimension -> dimension .yRange(-16, 96).attempts(6.5D).quantity(8) .pattern(OrePattern.CLUSTER) + .placementChannel(id("orespawn:standard")) .heightDistribution(OreHeightDistribution.BOTTOM_TRIANGLE) .discardChanceOnAirExposure(0.75D) .hostFamily(GeologyFamily.METAMORPHIC) @@ -62,7 +64,7 @@ void serializesTypedSchemaFourProvider() { .build(); JsonObject json = provider.toJson(); - assertEquals(4, json.get("schema_version").getAsInt()); + assertEquals(5, json.get("schema_version").getAsInt()); assertEquals("examplemod", json.get("provider_modid").getAsString()); assertTrue(json.getAsJsonObject("rocks").has("examplemod:rock/examplemod/slate")); assertEquals("examplemod:slate", json.getAsJsonObject("rocks") @@ -70,6 +72,9 @@ void serializesTypedSchemaFourProvider() { assertTrue(json.getAsJsonObject("ores").has("examplemod:ore/examplemod/tin_ore")); JsonObject ore = json.getAsJsonObject("ores").getAsJsonObject("examplemod:ore/examplemod/tin_ore"); assertEquals(2, ore.getAsJsonArray("outputs").size()); + assertEquals("orespawn:tin", ore.get("material").getAsString()); + assertEquals("orespawn:standard", ore.getAsJsonObject("dimensions") + .getAsJsonObject("minecraft:overworld").get("placement_channel").getAsString()); assertFalse(ore.get("retrogen").getAsBoolean()); assertEquals(0.75D, ore.getAsJsonObject("dimensions").getAsJsonObject("minecraft:overworld") .getAsJsonArray("host_blocks").get(0).getAsJsonObject().get("weight").getAsDouble()); diff --git a/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java new file mode 100644 index 00000000..c18d93ef --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/api/client/WorldSettingsExtensionRegistryTest.java @@ -0,0 +1,134 @@ +package zone.moddev.mc.orespawn.api.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.BeforeAll; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.server.Bootstrap; +import net.minecraft.SharedConstants; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.network.chat.TextComponent; + +class WorldSettingsExtensionRegistryTest { + @BeforeAll + static void bootstrapMinecraft() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } + + @Test + void preservesRegistrationOrderAndReturnsAnImmutableSnapshot() { + WorldSettingsExtension first = register("order_first", "settings", parent -> new Screen(new TextComponent("test")) { }); + WorldSettingsExtension second = register("order_second", "settings", parent -> new Screen(new TextComponent("test")) { }); + List extensions = WorldSettingsExtensionRegistry.extensions(); + + assertTrue(extensions.indexOf(first) < extensions.indexOf(second)); + assertThrows(UnsupportedOperationException.class, () -> extensions.add(first)); + } + + @Test + void rejectsDuplicateOwnershipAndInvalidRegistrations() { + ResourceLocation id = id("duplicate_owner", "first"); + WorldSettingsExtensionRegistry.register(id, "button.realisticdeposits.settings", + parent -> new Screen(new TextComponent("test")) { }); + assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register( + id("duplicate_owner", "second"), + "button.realisticdeposits.other", parent -> new Screen(new TextComponent("test")) { })); + assertThrows(IllegalArgumentException.class, () -> WorldSettingsExtensionRegistry.register( + id("blank_key", "settings"), " ", parent -> new Screen(new TextComponent("test")) { })); + assertThrows(IllegalArgumentException.class, () -> + WorldSettingsExtensionRegistry.registerConfigScreen("Bad Mod", parent -> parent)); + assertThrows(NullPointerException.class, () -> + WorldSettingsExtensionRegistry.registerConfigScreen("null_factory", null)); + } + + @Test + void preferredRegistrationOwnsOneConventionalScreenPerMod() { + WorldSettingsExtension extension = WorldSettingsExtensionRegistry.registerConfigScreen( + "preferred_form", parent -> parent); + + assertEquals("preferred_form", extension.id().getNamespace()); + assertEquals(new ResourceLocation("preferred_form", "configuration"), extension.id()); + assertEquals("button.orespawn.mod.configure", extension.buttonTranslationKey()); + assertThrows(IllegalStateException.class, () -> WorldSettingsExtensionRegistry.register( + id("preferred_form", "legacy_form"), "button.example.settings", parent -> parent)); + } + + @Test + void passesTheCurrentOreSpawnScreenToTheFactory() { + AtomicReference receivedParent = new AtomicReference<>(); + Screen expectedParent = new Screen(new TextComponent("test")) { }; + Screen expectedChild = new Screen(new TextComponent("test")) { }; + WorldSettingsExtension extension = register("parent_handoff", "settings", parent -> { + receivedParent.set(parent); + return expectedChild; + }); + + assertSame(expectedChild, extension.createScreen(expectedParent)); + assertSame(expectedParent, receivedParent.get()); + assertEquals("button.realisticdeposits.settings", extension.buttonTranslationKey()); + } + + @Test + void screenResultsMayBeNullOrThrowForTheDirectoryToHandle() { + Screen parent = new Screen(new TextComponent("test")) { }; + WorldSettingsExtension missing = register("missing_screen", "settings", ignored -> null); + WorldSettingsExtension failing = register("throwing_screen", "settings", ignored -> { + throw new IllegalArgumentException("fixture"); + }); + + assertSame(null, missing.createScreen(parent)); + assertThrows(IllegalArgumentException.class, () -> failing.createScreen(parent)); + } + + @Test + void oldAndPreferredJvmDescriptorsRemainAvailable() throws Exception { + assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("register", + ResourceLocation.class, String.class, WorldSettingsScreenFactory.class)); + assertNotNull(WorldSettingsExtensionRegistry.class.getDeclaredMethod("registerConfigScreen", + String.class, WorldSettingsScreenFactory.class)); + } + + @Test + void mainScreenUsesOneBoundedDirectoryEntryPointInsteadOfExtensionRows() throws Exception { + String source = new String(Files.readAllBytes(Paths.get("src", "main", "java", "zone", + "moddev", "mc", "orespawn", "client", "OreSpawnWorldSettingsScreen.java")), + StandardCharsets.UTF_8); + assertTrue(!source.contains("WorldSettingsExtensionRegistry.extensions()")); + assertEquals(2, occurrences(source, "button.orespawn.mods"), + "Each mutually exclusive main-screen layout owns exactly one Mods button"); + assertTrue(source.contains("new TranslatableComponent(\"button.orespawn.mods\")")); + assertTrue(source.contains("new OreSpawnModsScreen(this)")); + assertTrue(source.contains("syncSession();")); + } + + private static int occurrences(String source, String value) { + int count = 0; + for (int index = 0; (index = source.indexOf(value, index)) >= 0; index += value.length()) { + count++; + } + return count; + } + + private static WorldSettingsExtension register(String modId, String path, + WorldSettingsScreenFactory factory) { + return WorldSettingsExtensionRegistry.register(id(modId, path), + "button.realisticdeposits.settings", factory); + } + + private static ResourceLocation id(String modId, String path) { + return new ResourceLocation(modId, path); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java new file mode 100644 index 00000000..be747ac8 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/BiomeDirectoryModelTest.java @@ -0,0 +1,167 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashSet; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; +import zone.moddev.mc.orespawn.test.Forge36TestBootstrap; + +import net.minecraft.world.level.biome.Biomes; + +class BiomeDirectoryModelTest { + @BeforeAll + static void registerVanilla() { + Forge36TestBootstrap.registerVanilla(); + } + + @Test + void derivesVanillaRoutineDimensionsFromForgeBiomeTypes() { + assertEquals("minecraft:overworld", BiomeDirectoryModel.routineDimension(Biomes.BEACH)); + assertEquals("minecraft:the_nether", BiomeDirectoryModel.routineDimension(Biomes.NETHER_WASTES)); + assertEquals("minecraft:the_end", BiomeDirectoryModel.routineDimension(Biomes.THE_END)); + } + + @Test + void classifiesManagedLayeredDisabledReplacedUnmanagedAndMissingBiomes() { + JsonObject profile = root(); + addPalette(profile, "provider:first", true, "example:managed", "example:layered", "example:disabled"); + profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:first") + .getAsJsonObject("biomes").getAsJsonObject("example:disabled") + .addProperty("enabled", false); + addPalette(profile, "provider:second", true, "example:layered", "missing:biome"); + BiomeReplacementRules.set(profile, "minecraft:overworld", "example:managed", "example:unmanaged"); + JsonObject defaults = new JsonObject(); + addPalette(defaultsRoot(defaults), "provider:first", true, "example:managed"); + + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, defaults, + new LinkedHashSet<>(Collections.singletonList("provider")), Arrays.asList( + loaded("example:managed", "Managed"), loaded("example:layered", "Layered"), + loaded("example:disabled", "Disabled"), loaded("example:unmanaged", "Unmanaged")), + Collections.singletonList("minecraft:overworld")); + assertEquals(BiomeDirectoryModel.Status.USER_REPLACED, find(snapshot, "example:managed").status); + assertEquals(BiomeDirectoryModel.Status.LAYERED, find(snapshot, "example:layered").status); + assertEquals(BiomeDirectoryModel.Status.DISABLED, find(snapshot, "example:disabled").status); + assertEquals(BiomeDirectoryModel.Status.UNMANAGED, find(snapshot, "example:unmanaged").status); + assertEquals(BiomeDirectoryModel.Status.MISSING, find(snapshot, "missing:biome").status); + assertEquals(4, snapshot.entries("minecraft:overworld", false).size()); + assertEquals(5, snapshot.entries("minecraft:overworld", true).size()); + } + + @Test + void retainsProfilePaletteOrderAndMarksOnlyLastEnabledSurfaceEffective() { + JsonObject profile = root(); + addPalette(profile, "z:first", true, "example:managed"); + addPalette(profile, "a:second", true, "example:managed"); + for (String id : Arrays.asList("z:first", "a:second")) { + profile.getAsJsonObject("biome_palettes").getAsJsonObject(id) + .getAsJsonObject("biomes").getAsJsonObject("example:managed") + .add("surface", new JsonObject()); + profile.getAsJsonObject("biome_palettes").getAsJsonObject(id) + .getAsJsonObject("biomes").getAsJsonObject("example:managed") + .getAsJsonObject("surface").addProperty("top_block", "minecraft:stone"); + } + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.emptySet(), + Collections.singletonList(loaded("example:managed", "Managed")), + Collections.singletonList("minecraft:overworld")); + assertEquals("z:first", snapshot.palettes.get(0).id); + assertEquals("a:second", snapshot.palettes.get(1).id); + assertTrue(!find(snapshot, "example:managed").placements.get(0).effectiveSurface); + assertTrue(find(snapshot, "example:managed").placements.get(1).effectiveSurface); + } + + @Test + void returnedCollectionsAndDefinitionsAreDetached() { + JsonObject profile = root(); + addPalette(profile, "provider:first", true, "example:managed"); + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.emptySet(), + Collections.singletonList(loaded("example:managed", "Managed")), + Collections.singletonList("minecraft:overworld")); + assertThrows(UnsupportedOperationException.class, () -> snapshot.entries.clear()); + snapshot.palettes.get(0).definition.addProperty("enabled", false); + assertTrue(profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:first") + .get("enabled").getAsBoolean()); + } + + @Test + void showAllFiltersRoutineBiomesByDimensionAndRetainsExplicitPlacements() { + JsonObject profile = root(); + addPalette(profile, "provider:nether", true, "example:declared_nether"); + profile.getAsJsonObject("biome_palettes").getAsJsonObject("provider:nether") + .addProperty("dimension", "minecraft:the_nether"); + BiomeDirectoryModel.Snapshot snapshot = BiomeDirectoryModel.assemble(profile, + new JsonObject(), Collections.singleton("provider"), Arrays.asList( + loaded("minecraft:beaches", "Beach", "minecraft:overworld"), + loaded("minecraft:hell", "Hell", "minecraft:the_nether"), + loaded("minecraft:sky", "The End", "minecraft:the_end"), + loaded("example:declared_nether", "Declared Nether", "minecraft:overworld")), + Arrays.asList("minecraft:overworld", "minecraft:the_nether", "minecraft:the_end")); + + assertTrue(contains(snapshot, "minecraft:overworld", "minecraft:beaches")); + assertFalse(contains(snapshot, "minecraft:overworld", "minecraft:hell")); + assertFalse(contains(snapshot, "minecraft:overworld", "minecraft:sky")); + assertFalse(contains(snapshot, "minecraft:overworld", "example:declared_nether")); + assertTrue(contains(snapshot, "minecraft:the_nether", "minecraft:hell")); + assertTrue(contains(snapshot, "minecraft:the_nether", "example:declared_nether")); + assertFalse(contains(snapshot, "minecraft:the_nether", "minecraft:beaches")); + assertTrue(contains(snapshot, "minecraft:the_end", "minecraft:sky")); + assertFalse(contains(snapshot, "minecraft:the_end", "minecraft:beaches")); + } + + private static BiomeDirectoryModel.BiomeEntry find(BiomeDirectoryModel.Snapshot snapshot, String id) { + return snapshot.entries.stream().filter(entry -> id.equals(entry.id)).findFirst().orElseThrow(AssertionError::new); + } + + private static BiomeDirectoryModel.LoadedBiome loaded(String id, String name) { + return new BiomeDirectoryModel.LoadedBiome(id, name, "Example", "1"); + } + + private static BiomeDirectoryModel.LoadedBiome loaded(String id, String name, String dimension) { + return new BiomeDirectoryModel.LoadedBiome(id, name, "Example", "1", dimension); + } + + private static boolean contains(BiomeDirectoryModel.Snapshot snapshot, String dimension, String id) { + return snapshot.entries(dimension, true).stream().anyMatch(entry -> id.equals(entry.id)); + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("biome_palettes", new JsonObject()); + return root; + } + + private static JsonObject defaultsRoot(JsonObject palettes) { + JsonObject root = new JsonObject(); + root.add("biome_palettes", palettes); + return root; + } + + private static void addPalette(JsonObject root, String id, boolean enabled, String... biomes) { + JsonObject section = root.has("biome_palettes") ? root.getAsJsonObject("biome_palettes") : root; + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", enabled); + palette.addProperty("source_provider", "provider"); + JsonObject entries = new JsonObject(); + for (String biome : biomes) { + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + placement.add("similar_biomes", new JsonArray()); + entries.add(biome, placement); + } + palette.add("biomes", entries); + section.add(id, palette); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java b/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java new file mode 100644 index 00000000..daa382d1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/BiomeReplacementRulesTest.java @@ -0,0 +1,59 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +class BiomeReplacementRulesTest { + @Test + void storesExactMappingsInTheReservedTerminalPalette() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "minecraft:desert", "example:orchard"); + Map mappings = BiomeReplacementRules.read(root, "minecraft:overworld"); + assertEquals("example:orchard", mappings.get("minecraft:desert")); + JsonObject palette = root.getAsJsonObject("biome_palettes") + .getAsJsonObject(BiomeReplacementRules.paletteId("minecraft:overworld")); + assertEquals("replace", palette.get("mode").getAsString()); + assertEquals("all", palette.get("scope").getAsString()); + assertEquals(1.0D, palette.get("coverage").getAsDouble()); + assertEquals(0.0D, palette.get("fallback_weight").getAsDouble()); + } + + @Test + void flattensChainsAndRepointsInboundMappings() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "example:b"); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:b", "example:c"); + Map mappings = BiomeReplacementRules.read(root, "minecraft:overworld"); + assertEquals("example:c", mappings.get("example:a")); + assertEquals("example:c", mappings.get("example:b")); + } + + @Test + void rejectsSelfMappingsAndCycles() { + JsonObject root = new JsonObject(); + assertThrows(IllegalArgumentException.class, () -> BiomeReplacementRules.set(root, + "minecraft:overworld", "example:a", "example:a")); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "example:b"); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:b", "example:c"); + assertThrows(IllegalArgumentException.class, () -> BiomeReplacementRules.set(root, + "minecraft:overworld", "example:c", "example:a")); + } + + @Test + void removingLastMappingRemovesOnlyTheReservedPalette() { + JsonObject root = new JsonObject(); + BiomeReplacementRules.set(root, "minecraft:overworld", "example:a", "missing:target"); + BiomeReplacementRules.remove(root, "minecraft:overworld", "example:a"); + assertTrue(BiomeReplacementRules.read(root, "minecraft:overworld").isEmpty()); + assertFalse(root.getAsJsonObject("biome_palettes") + .has(BiomeReplacementRules.paletteId("minecraft:overworld"))); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java b/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java new file mode 100644 index 00000000..91470fa1 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/CompactScrollListTest.java @@ -0,0 +1,61 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import org.junit.jupiter.api.Test; + +class CompactScrollListTest { + @Test + void bothSupportedSizesKeepRowsAndSideScrollbars() { + assertEquals(13, CompactScrollList.visibleRows(209, 16)); + assertEquals(11, CompactScrollList.visibleRows(184, 16)); + assertEquals(5, CompactScrollList.maxFirst(18, 13)); + assertEquals(7, CompactScrollList.maxFirst(18, 11)); + assertEquals(137, CompactScrollList.thumbHeight(18, 13, 191)); + assertEquals(3, CompactScrollList.firstFromThumb(27, 54, 5)); + } + + @Test + void wheelArrowsTrackThumbAndRowTargetsUseOnlyTheirOwnList() { + ProbeList list = new ProbeList(); + assertTrue(list.mouseWheel(20, 30, -120)); + assertEquals(3, list.firstIndex()); + assertFalse(list.mouseWheel(2, 2, -120)); + + list.mouseClicked(105, 83, 0); // Lower scrollbar arrow. + assertEquals(4, list.firstIndex()); + list.mouseClicked(105, 65, 0); // Track below the thumb. + assertEquals(8, list.firstIndex()); + + list.setFirstIndex(0); + list.mouseClicked(20, 53, 0); + assertEquals(2, list.selected); + list.mouseClicked(95, 21, 0); + assertEquals(0, list.action); + list.setFirstIndex(20); + list.ensureVisible(5); + assertEquals(5, list.firstIndex()); + list.ensureVisible(12); + assertEquals(9, list.firstIndex()); + + list.setFirstIndex(0); + list.mouseClicked(105, 31, 0); // Grab the scrollbar thumb. + list.mouseDragged(105, 75, 0, 0, 44); + assertTrue(list.firstIndex() > 0); + list.mouseReleased(105, 75, 0); + } + + private static final class ProbeList extends CompactScrollList { + int selected = -1; + int action = -1; + + ProbeList() { super(null, 10, 20, 100, 64); } + @Override protected int size() { return 30; } + @Override protected String rowText(int index) { return Integer.toString(index); } + @Override protected void onRowPressed(int index) { selected = index; } + @Override protected int rowActionWidth(int index) { return 10; } + @Override protected void onRowActionPressed(int index) { action = index; } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/EditorNavigationWiringTest.java b/src/test/java/zone/moddev/mc/orespawn/client/EditorNavigationWiringTest.java new file mode 100644 index 00000000..9df1ce5c --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/EditorNavigationWiringTest.java @@ -0,0 +1,34 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +/** Keeps the main routes connected when a screen is moved between versions. */ +class EditorNavigationWiringTest { + @Test + void oreSourcesAndModsHaveVisibleEntryPoints() throws Exception { + assertTrue(classBytes(GeologyMaterialsScreen.class).contains("OreSourceListScreen"), + "The ORES tab must open the Ore Sources editor"); + + Class entry; + try { + entry = Class.forName("zone.moddev.mc.orespawn.client.OreSpawnWorldCreationTab"); + } catch (ClassNotFoundException olderTarget) { + entry = OreSpawnWorldSettingsScreen.class; + } + assertTrue(classBytes(entry).contains("OreSpawnModsScreen"), + "The world editor must open the Mods directory"); + } + + private static String classBytes(Class type) throws IOException { + try (InputStream stream = type.getResourceAsStream(type.getSimpleName() + ".class")) { + if (stream == null) throw new IOException("Compiled class is missing: " + type.getName()); + return new String(stream.readAllBytes(), StandardCharsets.ISO_8859_1); + } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java index e7226feb..977ce946 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/GeologyEditorSessionTest.java @@ -2,9 +2,12 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.Arrays; +import net.minecraft.world.level.block.Blocks; +import zone.moddev.mc.orespawn.util.FluidBlocks; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -17,6 +20,20 @@ import net.minecraft.server.Bootstrap; class GeologyEditorSessionTest { + @Test + void geomeIdsAreNotSilentlyLowercased() { + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); + session.addGeome("Bad:Upper"); + session.addGeome(null); + session.addGeome(""); + session.addGeome(" cakeworld:cocoa_basin "); + JsonObject beforeDuplicate = session.profile().rootCopy(); + session.addGeome("cakeworld:cocoa_basin"); + assertFalse(session.section("geomes").has("bad:upper")); + assertFalse(session.section("geomes").has("")); + assertTrue(session.section("geomes").has("cakeworld:cocoa_basin")); + assertEquals(beforeDuplicate, session.profile().rootCopy()); + } @BeforeAll static void bootstrapMinecraft() { SharedConstants.tryDetectVersion(); @@ -126,6 +143,22 @@ void rangedSelectorOreIsValidWithoutAnExplicitDimension() { assertTrue(errors.isEmpty(), errors.toString()); } + @Test + void fluidPickerExcludesWaterloggedBlocksAndBubbleColumns() { + GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); + assertTrue(FluidBlocks.isFluidBlock(Blocks.WATER)); + assertTrue(FluidBlocks.isFluidBlock(Blocks.LAVA)); + assertFalse(FluidBlocks.isFluidBlock(Blocks.BRAIN_CORAL_WALL_FAN)); + assertFalse(FluidBlocks.isFluidBlock(Blocks.BUBBLE_COLUMN)); + assertFalse(session.availableFluidBlockIds("").contains("minecraft:brain_coral_wall_fan")); + assertFalse(session.availableFluidBlockIds("").contains("minecraft:bubble_column")); + assertFalse(session.availableMaterialBlockIds("", true).contains("minecraft:brain_coral_wall_fan")); + assertNull(session.assignFluidDeposit("minecraft:brain_coral_wall_fan")); + session.setMaterialBlock("minecraft:overworld", "default_fluid", + "minecraft:brain_coral_wall_fan", true); + assertNull(session.dimensionMaterials("minecraft:overworld", false)); + } + @Test void standaloneFluidPickerCreatesAUsableCoveredOverworldRule() { GeologyEditorSession session = new GeologyEditorSession(WorldGeologyProfile.recommended(false)); @@ -170,4 +203,69 @@ void namespacedGeomesCanBeAddedValidatedAndRoundTripped() { assertEquals(3.0D, reopened.rock("minecraft:stone").getAsJsonObject("geomes") .get(geomeId).getAsDouble()); } + + @Test + void balancedModeRestoresEveryEligibleOutputAfterCustomSelection() { + JsonObject root = WorldGeologyProfile.recommended(false).rootCopy(); + JsonObject policy = new JsonObject(); + policy.addProperty("material", "orespawn:sulfur"); + policy.addProperty("domain", "minecraft:overworld"); + policy.addProperty("mode", "keep_separate"); + policy.addProperty("output_mode", "custom"); + JsonObject outputs = new JsonObject(); + outputs.addProperty("mineralogy:sulfur", 1.0D); + policy.add("outputs", outputs); + JsonObject placements = new JsonObject(); + placements.addProperty("orespawn:standard", "mineralogy:sulfur"); + policy.add("placement_sources", placements); + JsonArray candidates = new JsonArray(); + candidates.add(oreSourceCandidate("mineralogy:sulfur", "mineralogy:sulfur_ore", false)); + candidates.add(oreSourceCandidate("baseminerals:sulfur", "baseminerals:sulfur_ore", false)); + candidates.add(oreSourceCandidate("outside:sulfur", "outside:sulfur_ore", true)); + policy.add("candidates", candidates); + JsonObject policies = new JsonObject(); + String key = "orespawn:sulfur|minecraft:overworld"; + policies.add(key, policy); + root.add("ore_source_policies", policies); + WorldGeologyProfile original = WorldGeologyProfile.fromJson(root, + WorldGeologyProfile.recommended(false)); + JsonObject before = original.rootCopy(); + GeologyEditorSession session = new GeologyEditorSession(original); + + session.setOreSourceOutputMode(key, "balanced"); + GeologyEditorSession.OreSourceGroup balanced = session.oreSourceGroups().get(0); + assertEquals("consolidated", balanced.mode); + assertEquals(2, balanced.outputs.size()); + assertTrue(balanced.outputs.containsKey("mineralogy:sulfur")); + assertTrue(balanced.outputs.containsKey("baseminerals:sulfur")); + assertFalse(balanced.outputs.containsKey("outside:sulfur")); + + session.setOreSourceOutputMode(key, "custom"); + session.setOreSourceOutput(key, "baseminerals:sulfur", true, 2.5D); + session.setOreSourceOutput(key, "mineralogy:sulfur", false, 1.0D); + assertEquals(1, session.oreSourceGroups().get(0).outputs.size()); + session.setOreSourceOutputMode(key, "balanced"); + assertEquals(2, session.oreSourceGroups().get(0).outputs.size()); + assertEquals(1.0D, session.oreSourceGroups().get(0).outputs + .get("mineralogy:sulfur").doubleValue()); + assertEquals(before, original.rootCopy(), "the main editor still owns persistence"); + } + + private static JsonObject oreSourceCandidate(String source, String block, boolean external) { + JsonObject candidate = new JsonObject(); + candidate.addProperty("source_id", source); + candidate.addProperty("owner", source.substring(0, source.indexOf(':'))); + candidate.addProperty("registry_id", block); + candidate.addProperty("metadata", 0); + candidate.addProperty("material", "orespawn:sulfur"); + candidate.addProperty("domain", "minecraft:overworld"); + candidate.addProperty("placement_channel", "orespawn:standard"); + JsonArray tags = new JsonArray(); + tags.add("forge:ores/sulfur"); + candidate.add("block_tags", tags); + candidate.addProperty("loaded", true); + candidate.addProperty("placement_active", !external); + candidate.addProperty("external", external); + return candidate; + } } diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java new file mode 100644 index 00000000..3a1016d5 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnModDirectoryModelTest.java @@ -0,0 +1,112 @@ +package zone.moddev.mc.orespawn.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import org.junit.jupiter.api.Test; + +import net.minecraft.client.gui.screens.Screen; +import net.minecraft.network.chat.TextComponent; +import net.minecraft.resources.ResourceLocation; +import zone.moddev.mc.orespawn.api.ProviderStatus; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtension; +import zone.moddev.mc.orespawn.api.client.WorldSettingsExtensionRegistry; +import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.Entry; +import zone.moddev.mc.orespawn.client.OreSpawnModDirectoryModel.LoadedMod; +import zone.moddev.mc.orespawn.integration.WorldgenIntegrationManager.ProviderIntegrationInfo; + +class OreSpawnModDirectoryModelTest { + @Test + void discoversLoadedNativeLegacyUiOnlyInactiveAndRejectedIntegrations() { + WorldSettingsExtension realisticScreen = WorldSettingsExtensionRegistry.register( + new ResourceLocation("directory_realistic", "settings"), + "button.realisticdeposits.settings", parent -> new Screen(new TextComponent("test")) { }); + WorldSettingsExtension uiOnlyScreen = WorldSettingsExtensionRegistry.registerConfigScreen( + "directory_ui_only", parent -> new Screen(new TextComponent("test")) { }); + + List loaded = Arrays.asList( + new LoadedMod("orespawn", "MMD OreSpawn", "4.1.0.112021"), + new LoadedMod("directory_realistic", "Realistic Deposits", "0.1.0.110021"), + new LoadedMod("directory_base", "Base Metals", "2.5.0-beta5"), + new LoadedMod("directory_mineralogy", "Mineralogy", "6.0.2.110021"), + new LoadedMod("directory_ui_only", "Client Tweaks", "1.2.3"), + new LoadedMod("directory_inactive", "Inactive Rocks", "2.0"), + new LoadedMod("directory_rejected", "Rejected Ores", "3.0")); + List providers = Arrays.asList( + provider("directory_mineralogy", true, true, 4, 3, + ProviderStatus.ACTIVE, false), + provider("directory_base", false, true, 4, 2, + ProviderStatus.ACTIVE, false, 3), + provider("directory_realistic", true, true, 4, 1, + ProviderStatus.ACTIVE, false, 3), + provider("directory_inactive", true, true, 4, 1, + ProviderStatus.INACTIVE, false), + provider("directory_rejected", true, true, -1, -1, + ProviderStatus.INACTIVE, true), + provider("directory_unloaded", true, true, 4, 9, + ProviderStatus.ACTIVE, false), + provider("orespawn", true, true, 4, 1, + ProviderStatus.ACTIVE, false)); + + List result = OreSpawnModDirectoryModel.assemble(loaded, providers, + Arrays.asList(realisticScreen, uiOnlyScreen)); + + assertEquals(Arrays.asList("directory_base", "directory_ui_only", "directory_inactive", + "directory_mineralogy", "directory_realistic", "directory_rejected"), + result.stream().map(entry -> entry.modId).collect(java.util.stream.Collectors.toList())); + assertEquals(6, result.size()); + assertEquals("config_only", find(result, "directory_ui_only").statusKey()); + assertEquals("inactive", find(result, "directory_inactive").statusKey()); + assertEquals("rejected", find(result, "directory_rejected").statusKey()); + assertFalse(find(result, "directory_base").nativeOs4()); + assertEquals(Collections.singletonList(3), find(result, "directory_base").legacyLineages()); + assertTrue(find(result, "directory_realistic").nativeOs4()); + assertTrue(find(result, "directory_realistic").configurable()); + assertThrows(UnsupportedOperationException.class, () -> result.clear()); + } + + @Test + void deduplicatesByModIdAndSortsEqualNamesByModId() { + List loaded = Arrays.asList( + new LoadedMod("directory_sort_b", "Same Name", "1"), + new LoadedMod("directory_sort_a", "Same Name", "2")); + List providers = Arrays.asList( + provider("directory_sort_b", false, true, 4, 1, ProviderStatus.ACTIVE, false, 1, 2, 3), + provider("directory_sort_a", true, true, 4, 2, ProviderStatus.PENDING, false)); + + List result = OreSpawnModDirectoryModel.assemble(loaded, providers, + Collections.emptyList()); + + assertEquals("directory_sort_a", result.get(0).modId); + assertEquals("directory_sort_b", result.get(1).modId); + assertEquals(Arrays.asList(1, 2, 3), result.get(1).legacyLineages()); + assertThrows(UnsupportedOperationException.class, + () -> result.get(1).legacyLineages().add(2)); + } + + @Test + void paginationRemainsBoundedAtMinimumResolution() { + assertEquals(3, OreSpawnModsScreen.pageSize(240)); + assertEquals(1, OreSpawnModsScreen.pageCount(0, 3)); + assertEquals(1, OreSpawnModsScreen.pageCount(3, 3)); + assertEquals(2, OreSpawnModsScreen.pageCount(4, 3)); + } + + private static Entry find(List entries, String modId) { + return entries.stream().filter(entry -> modId.equals(entry.modId)).findFirst().orElseThrow( + () -> new AssertionError("Missing " + modId)); + } + + private static ProviderIntegrationInfo provider(String modId, boolean nativeOs4, + boolean hasProvider, int schema, int revision, ProviderStatus status, + boolean rejected, Integer... lineages) { + return new ProviderIntegrationInfo(modId, nativeOs4, hasProvider, schema, revision, + status, rejected, Arrays.asList(lineages)); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java index 7d1e83c4..02cc38a4 100644 --- a/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/client/OreSpawnScreenLayoutTest.java @@ -23,14 +23,25 @@ void everyConcreteScreenClearsThePreviousFrameBeforeDrawingWidgets() throws Exce .sorted() .toList(); } - assertEquals(24, screens.size(), "Review this render-order gate when screens are added or removed"); + assertEquals(29, screens.size(), "Review this render-order gate when screens are added or removed"); for (Path screen : screens) { String source = Files.readString(screen, StandardCharsets.UTF_8); - int render = source.indexOf("public void render(PoseStack"); - int background = source.indexOf("renderBackground(poseStack);", render); - int widgets = source.indexOf("super.render(poseStack", render); String name = screen.getFileName().toString(); - assertTrue(render >= 0, name + " must own its 1.18.2 render pass"); + if ("OreSpawnScreen.java".equals(name)) { + assertTrue(source.contains("render(mouseX, mouseY, partialTick);"), + "The 1.18 bridge must pass the frame to compact screens"); + assertTrue(source.contains("super.render(currentPoseStack, mouseX, mouseY, partialTick);"), + "The 1.18 bridge must draw the widgets with the current pose stack"); + continue; + } + boolean compact = source.contains("public void render(int mouseX"); + int render = source.indexOf(compact + ? "public void render(int mouseX" : "public void render(PoseStack"); + int background = source.indexOf(compact + ? "renderBackground();" : "renderBackground(poseStack);", render); + int widgets = source.indexOf(compact + ? "super.render(mouseX" : "super.render(poseStack", render); + assertTrue(render >= 0, name + " must own its render pass"); assertTrue(background > render, name + " must clear the previous frame"); assertTrue(widgets > background, name + " must clear before drawing widgets and tooltips"); } @@ -56,6 +67,27 @@ void compactOrePlacementRowsStayAboveFooterAtMinimumTestHeight() { assertCompactOrePlacementClearsFooter(240); } + @Test + void oreSourcesKeepTwoCompactListsAtBothSupportedSizes() { + assertEquals(406, OreSourceListScreen.contentWidth(426)); + assertEquals(174, OreSourceListScreen.leftPaneWidth(426)); + assertEquals(209, OreSourceListScreen.listHeight(265)); + assertEquals(300, OreSourceListScreen.contentWidth(320)); + assertEquals(129, OreSourceListScreen.leftPaneWidth(320)); + assertEquals(184, OreSourceListScreen.listHeight(240)); + assertEquals(80, OreSourceGroupSettingsScreen.aliasListHeight(265)); + assertEquals(64, OreSourceGroupSettingsScreen.aliasListHeight(240)); + assertEquals(58, OreSourceListScreen.compactFilterWidth(110, 48)); + } + + @Test + void biomeDirectoryUsesPagesOnlyAtTheMinimumSize() { + assertTrue(426 >= BiomeWorldMaterialsScreen.TWO_PANE_MINIMUM); + assertTrue(320 < BiomeWorldMaterialsScreen.TWO_PANE_MINIMUM); + assertTrue(BiomeWorldMaterialsScreen.listHeight(265) > 0); + assertTrue(BiomeWorldMaterialsScreen.listHeight(240) > 0); + } + private static void assertRowsClearFooter(int height) { int lastRowBottom = OreSpawnScreenLayout.mainTop(height) + (OreSpawnScreenLayout.mainRowSpacing(height) * 7) + 20; diff --git a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java index b9a6b6b7..61a73249 100644 --- a/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/integration/WorldgenIntegrationManagerTest.java @@ -37,6 +37,10 @@ void rejectsImpossibleFluidOutputs() { "examplemod", provider("minecraft:air", true, 3))); assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( "examplemod", provider("minecraft:stone", true, 3))); + assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( + "examplemod", provider("minecraft:brain_coral_wall_fan", true, 3))); + assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( + "examplemod", provider("minecraft:bubble_column", true, 3))); assertThrows(JsonSyntaxException.class, () -> WorldgenIntegrationManager.validateProvider( "examplemod", provider("missingmod:not_here", true, 3))); } diff --git a/src/test/java/zone/moddev/mc/orespawn/test/Forge36TestBootstrap.java b/src/test/java/zone/moddev/mc/orespawn/test/Forge36TestBootstrap.java new file mode 100644 index 00000000..5f07e25d --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/test/Forge36TestBootstrap.java @@ -0,0 +1,14 @@ +package zone.moddev.mc.orespawn.test; + +import net.minecraft.server.Bootstrap; +import net.minecraft.SharedConstants; + +/** Initializes vanilla registries for tests that inspect biome definitions. */ +public final class Forge36TestBootstrap { + private Forge36TestBootstrap() { } + + public static void registerVanilla() { + SharedConstants.tryDetectVersion(); + Bootstrap.bootStrap(); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java new file mode 100644 index 00000000..90afd219 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/BiomePaletteOrderingTest.java @@ -0,0 +1,148 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.stream.Stream; +import java.util.IdentityHashMap; +import java.util.List; +import java.util.Map.Entry; +import java.util.Random; +import java.util.Set; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonPrimitive; + +import net.minecraft.resources.ResourceLocation; +import net.minecraft.data.BuiltinRegistries; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Holder; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.biome.Climate; +import net.minecraftforge.registries.ForgeRegistries; +import zone.moddev.mc.orespawn.test.Forge36TestBootstrap; +import zone.moddev.mc.orespawn.worldgen.BakedBiomeWorldgen.Palette; + +class BiomePaletteOrderingTest { + @BeforeAll static void bootstrapMinecraft() { Forge36TestBootstrap.registerVanilla(); } + + @Test void ordinaryPalettesKeepTheirOrderAndTheUiOverrideRunsLast() { + JsonObject palettes = new JsonObject(); + palettes.add("provider:first", new JsonObject()); + palettes.add("orespawn:ui/biome_overrides/minecraft_overworld", new JsonObject()); + palettes.add("provider:second", new JsonObject()); + List ids = BiomeWorldgenManager.orderedPaletteEntries(palettes).stream() + .map(Entry::getKey).collect(Collectors.toList()); + assertEquals(java.util.Arrays.asList("provider:first", "provider:second", + "orespawn:ui/biome_overrides/minecraft_overworld"), ids); + } + + @Test void exactReplacementRunsAfterTheProviderEvenWhenStoredFirst() { + JsonObject root = root(); + JsonObject palettes = root.getAsJsonObject("biome_palettes"); + palettes.add("orespawn:ui/biome_overrides/minecraft_overworld", + palette("minecraft:forest", "minecraft:desert")); + palettes.add("provider:ordinary", palette("minecraft:desert", "minecraft:plains")); + List baked = BiomeWorldgenManager.bakePalettes(root, BuiltinRegistries.BIOME, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()); + BiomeOverlaySource overlay = new BiomeOverlaySource( + new ConstantBiomeProvider(biome("minecraft:plains")), baked, 78431L); + assertEquals(2, baked.size()); + assertSame(biome("minecraft:forest"), biomeAt(overlay, new BlockPos(0, 64, 0))); + assertSame(biome("minecraft:forest"), biomeAt(overlay, new BlockPos(-257, 64, 389))); + } + + @Test void aMissingTargetStaysDormant() { + JsonObject root = root(); + root.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", + palette("missing:orchard", "minecraft:plains")); + assertTrue(BiomeWorldgenManager.bakePalettes(root, BuiltinRegistries.BIOME, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()).isEmpty()); + assertTrue(root.toString().contains("missing:orchard")); + } + + @Test void aDisabledOverrideDoesNotChangeExistingBiomeChoices() { + JsonObject root = root(); + root.getAsJsonObject("biome_palettes").add("provider:first", + palette("minecraft:desert", "minecraft:plains")); + JsonObject disabled = new com.google.gson.JsonParser().parse(root.toString()).getAsJsonObject(); + JsonObject override = palette("minecraft:forest", "minecraft:desert"); + override.addProperty("enabled", false); + disabled.getAsJsonObject("biome_palettes").add( + "orespawn:ui/biome_overrides/minecraft_overworld", override); + Biome source = biome("minecraft:plains"); + BiomeOverlaySource baseline = overlay(root, source); + BiomeOverlaySource candidate = overlay(disabled, source); + for (int z = -96; z <= 96; z += 3) { + for (int x = -96; x <= 96; x += 3) { + BlockPos pos = new BlockPos(x, 63, z); + assertSame(biomeAt(baseline, pos), biomeAt(candidate, pos)); + } + } + } + + private static BiomeOverlaySource overlay(JsonObject root, Biome source) { + return new BiomeOverlaySource(new ConstantBiomeProvider(source), + BiomeWorldgenManager.bakePalettes(root, BuiltinRegistries.BIOME, + new ResourceLocation("minecraft:overworld"), new IdentityHashMap<>()), 918273L); + } + + private static Biome biomeAt(BiomeOverlaySource source, BlockPos pos) { + return source.getNoiseBiome(pos.getX() >> 2, pos.getY() >> 2, pos.getZ() >> 2, null).value(); + } + + private static JsonObject root() { + JsonObject root = new JsonObject(); + root.add("biome_palettes", new JsonObject()); + return root; + } + + private static JsonObject palette(String target, String source) { + JsonObject palette = new JsonObject(); + palette.addProperty("dimension", "minecraft:overworld"); + palette.addProperty("enabled", true); + palette.addProperty("mode", "replace"); + palette.addProperty("scope", "all"); + palette.addProperty("region_size", "tiny"); + palette.addProperty("coverage", 1.0D); + palette.addProperty("fallback_weight", 0.0D); + JsonObject placement = new JsonObject(); + placement.addProperty("enabled", true); + placement.addProperty("weight", 1.0D); + JsonArray similar = new JsonArray(); + similar.add(new JsonPrimitive(source)); + placement.add("similar_biomes", similar); + JsonObject biomes = new JsonObject(); + biomes.add(target, placement); + palette.add("biomes", biomes); + return palette; + } + + private static Biome biome(String id) { + Biome biome = ForgeRegistries.BIOMES.getValue(new ResourceLocation(id)); + if (biome == null) throw new AssertionError("Missing test biome " + id); + return biome; + } + + private static final class ConstantBiomeProvider extends BiomeSource { + private final Holder biome; + ConstantBiomeProvider(Biome biome) { this(Holder.direct(biome)); } + private ConstantBiomeProvider(Holder biome) { super(Stream.of(biome)); this.biome = biome; } + @Override public Holder getNoiseBiome(int x, int y, int z, Climate.Sampler sampler) { + return biome; + } + @Override protected com.mojang.serialization.Codec codec() { + return BiomeSource.CODEC; + } + @Override public BiomeSource withSeed(long seed) { return this; } + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java index 72f923ac..72de8a8a 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/FluidDepositFeatureTest.java @@ -15,7 +15,7 @@ class FluidDepositFeatureTest { @Test - void explicitBiomeFiltersBakeAsDynamicRegistryKeys() { + void explicitBiomeFiltersBakeAsDynamicResourceKeys() { JsonObject rule = new JsonObject(); JsonArray ids = new JsonArray(); ids.add("minecraft:cold_ocean"); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java new file mode 100644 index 00000000..4f9e6db7 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/MmdOreConflictCatalogTest.java @@ -0,0 +1,54 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; + +import org.junit.jupiter.api.Test; + +class MmdOreConflictCatalogTest { + @Test + void futureProvidersOnlyBalanceWhenBothLoadedAndSharingAnExactTag() { + Map> loaded = new LinkedHashMap<>(); + loaded.put("baseminerals", tags("forge:ores/sulfur")); + assertFalse(MmdOreConflictCatalog.automaticallyBalance(loaded)); + loaded.put("electricadvantage", tags("forge:ores/sulfur")); + assertTrue(MmdOreConflictCatalog.automaticallyBalance(loaded)); + loaded.put("electricadvantage", tags("forge:ores/sulphur")); + assertFalse(MmdOreConflictCatalog.automaticallyBalance(loaded)); + } + + @Test + void unrelatedExternalAndEnrichmentProvidersDoNotTriggerBalancing() { + Map> loaded = new LinkedHashMap<>(); + loaded.put("basemetals", tags("forge:ores/copper")); + loaded.put("externalgenerator", tags("forge:ores/copper")); + loaded.put("densemetals", tags("forge:ores/copper")); + assertFalse(MmdOreConflictCatalog.automaticallyBalance(loaded)); + loaded.put("modernmetals", tags("forge:ores/copper")); + assertTrue(MmdOreConflictCatalog.automaticallyBalance(loaded)); + } + + @Test + void establishedPrioritiesAndDimensionProvidersRemainDistinct() { + assertEquals(Arrays.asList("mineralogy", "baseminerals", "electricadvantage"), + MmdOreConflictCatalog.priority("sulfur")); + assertEquals(Arrays.asList("baseminerals", "electricadvantage"), + MmdOreConflictCatalog.priority("lithium")); + assertEquals(Collections.emptyList(), MmdOreConflictCatalog.priority("copper")); + assertTrue(MmdOreConflictCatalog.netherOnly("nethermetals")); + assertTrue(MmdOreConflictCatalog.endOnly("endmetals")); + assertFalse(MmdOreConflictCatalog.ordinary("densemetals")); + } + + private static Set tags(String tag) { + return new LinkedHashSet<>(Collections.singletonList(tag)); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java new file mode 100644 index 00000000..f2a4e2ad --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreMaterialGroupsTest.java @@ -0,0 +1,65 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Arrays; +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonArray; +import com.google.gson.JsonObject; + +import net.minecraft.resources.ResourceLocation; + +class OreMaterialGroupsTest { + @Test + void curatedAliasesUseExactBlockTags() { + JsonObject root = new JsonObject(); + OreMaterialGroups.initialize(root); + assertEquals(new ResourceLocation("orespawn:sulfur"), OreMaterialGroups.infer(root, + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")).material); + assertFalse(OreMaterialGroups.infer(root, + Arrays.asList("forge:ores/sulfur", "forge:ores/sulphur")).reviewRequired); + assertEquals(new ResourceLocation("orespawn:aluminum"), OreMaterialGroups.infer(root, + Arrays.asList("forge:ores/aluminum", "forge:ores/aluminium")).material); + } + + @Test + void importedOreDictionaryAliasesStayDormantWithoutExactTags() { + JsonObject root = new JsonObject(); + JsonObject groups = new JsonObject(); + JsonObject oldGroup = new JsonObject(); + oldGroup.addProperty("display_name", "Rare stone"); + JsonArray aliases = new JsonArray(); + aliases.add("oreRareStone"); + oldGroup.add(OreMaterialGroups.LEGACY_ALIASES, aliases); + groups.add("example:rare_stone", oldGroup); + root.add(OreMaterialGroups.SECTION, groups); + OreMaterialGroups.initialize(root); + OreMaterialGroups.Definition definition = OreMaterialGroups.definition(root, + new ResourceLocation("example:rare_stone")); + assertEquals(Collections.singletonList("oreRareStone"), definition.dormantOreDictionaryEntries); + assertTrue(definition.blockTagEntries.isEmpty()); + assertEquals(null, OreMaterialGroups.infer(root, Collections.singletonList("oreRareStone")).material); + assertEquals(null, OreMaterialGroups.infer(root, + Collections.singletonList("example:rare_stone_ore")).material); + } + + @Test + void exactSharedTagIsStableAndGroupOwnershipIsExplicit() { + JsonObject root = new JsonObject(); + OreMaterialGroups.initialize(root); + ResourceLocation inferred = OreMaterialGroups.infer(root, + Collections.singletonList("forge:ores/copper")).material; + assertEquals(new ResourceLocation("orespawn:copper"), inferred); + assertTrue(OreMaterialGroups.ensureDefinition(root, inferred, + Collections.singletonList("forge:ores/copper"))); + assertEquals(Collections.singletonList("forge:ores/copper"), + OreMaterialGroups.definition(root, inferred).blockTagEntries); + assertFalse(OreMaterialGroups.ensureDefinition(root, inferred, + Collections.singletonList("forge:ores/copper"))); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java new file mode 100644 index 00000000..3e52b4d8 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSourcePoliciesTest.java @@ -0,0 +1,103 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.Collections; + +import org.junit.jupiter.api.Test; + +import com.google.gson.JsonObject; + +import net.minecraft.resources.ResourceLocation; + +class OreSourcePoliciesTest { + private static final ResourceLocation SULFUR = id("orespawn:sulfur"); + private static final ResourceLocation OVERWORLD = id("minecraft:overworld"); + + @Test + void newWorldBalancesOnlyTwoLoadedOrdinaryOwnersWithAnExactSharedTag() { + OreSourcePolicies.Group one = group(); + one.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + assertSeparate(OreSourcePolicies.createPolicy(one, false)); + + OreSourcePolicies.Group differentTag = group(); + differentTag.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + differentTag.add(candidate("electricadvantage:sulfur", "electricadvantage", + "electricadvantage:sulfur_ore", "forge:ores/sulphur", true, true, false, false)); + assertSeparate(OreSourcePolicies.createPolicy(differentTag, false)); + + OreSourcePolicies.Group shared = group(); + shared.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + shared.add(candidate("electricadvantage:sulfur", "electricadvantage", + "electricadvantage:sulfur_ore", "forge:ores/sulfur", true, true, false, false)); + JsonObject balanced = OreSourcePolicies.createPolicy(shared, false); + assertEquals("consolidated", balanced.get("mode").getAsString()); + assertEquals("balanced", balanced.get("output_mode").getAsString()); + assertEquals(2, balanced.getAsJsonObject("outputs").size()); + assertEquals(1, balanced.getAsJsonObject("placement_sources").size()); + assertEquals("mineralogy:sulfur", balanced.getAsJsonObject("placement_sources") + .get("orespawn:standard").getAsString()); + } + + @Test + void oldWorldKeepsOriginalAndExternalOrEnrichmentDoesNotTriggerBalance() { + OreSourcePolicies.Group group = group(); + group.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + group.add(candidate("electricadvantage:sulfur", "electricadvantage", + "electricadvantage:sulfur_ore", "forge:ores/sulfur", true, true, false, false)); + assertSeparate(OreSourcePolicies.createPolicy(group, true)); + + OreSourcePolicies.Group onlyExternal = group(); + onlyExternal.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + onlyExternal.add(candidate("outside:sulfur", "outside", "outside:sulfur_ore", + "forge:ores/sulfur", true, false, true, false)); + assertSeparate(OreSourcePolicies.createPolicy(onlyExternal, false)); + + OreSourcePolicies.Group enrichment = group(); + enrichment.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + enrichment.add(candidate("densemetals:sulfur", "densemetals", "densemetals:sulfur_ore", + "forge:ores/sulfur", true, true, false, true)); + assertSeparate(OreSourcePolicies.createPolicy(enrichment, false)); + } + + @Test + void unloadedFutureProviderCreatesNeitherAnOutputNorAPlacementBudget() { + OreSourcePolicies.Group group = group(); + group.add(candidate("mineralogy:sulfur", "mineralogy", "mineralogy:sulfur_ore", + "forge:ores/sulfur", true, true, false, false)); + group.add(candidate("baseminerals:sulfur", "baseminerals", "baseminerals:sulfur_ore", + "forge:ores/sulfur", false, false, false, false)); + JsonObject policy = OreSourcePolicies.createPolicy(group, false); + assertSeparate(policy); + assertFalse(policy.getAsJsonObject("outputs").has("baseminerals:sulfur")); + assertEquals(1, policy.getAsJsonObject("placement_sources").size()); + assertTrue(policy.getAsJsonObject("placement_sources").has("orespawn:standard")); + } + + private static void assertSeparate(JsonObject policy) { + assertEquals("keep_separate", policy.get("mode").getAsString()); + } + + private static OreSourcePolicies.Group group() { + return new OreSourcePolicies.Group(SULFUR, OVERWORLD); + } + + private static OreSourcePolicies.Candidate candidate(String source, String owner, String block, + String tag, boolean loaded, boolean active, boolean external, boolean enrichment) { + return new OreSourcePolicies.Candidate(source, owner, owner, "1", id(block), 0, + SULFUR, OVERWORLD, OreSourcePolicies.STANDARD, Collections.singletonList(tag), + loaded, active, external, enrichment, false, false); + } + + private static ResourceLocation id(String value) { + return new ResourceLocation(value); + } +} diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java index 81cd2e37..11935ae6 100644 --- a/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/OreSpawnOreGenerationTest.java @@ -23,7 +23,7 @@ class OreSpawnOreGenerationTest { @Test - void biomeFiltersRetainUnknownDynamicRegistryKeys() { + void biomeFiltersRetainUnknownDynamicResourceKeys() { ResourceKey sodaOcean = ResourceKey.create(Registry.BIOME_REGISTRY, new ResourceLocation("cakeworld", "soda_ocean")); JsonObject rule = new JsonObject(); diff --git a/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGateTest.java b/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGateTest.java new file mode 100644 index 00000000..9364b442 --- /dev/null +++ b/src/test/java/zone/moddev/mc/orespawn/worldgen/VanillaOreFeatureGateTest.java @@ -0,0 +1,119 @@ +package zone.moddev.mc.orespawn.worldgen; + +import static org.junit.jupiter.api.Assertions.*; +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import net.minecraft.core.Holder; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.Block; +import net.minecraft.data.BuiltinRegistries; +import net.minecraft.world.level.biome.Biome; +import net.minecraft.world.level.biome.BiomeGenerationSettings; +import net.minecraft.world.level.biome.BiomeSource; +import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.biome.FixedBiomeSource; +import net.minecraft.world.level.levelgen.feature.ConfiguredFeature; +import net.minecraft.world.level.levelgen.feature.Feature; +import net.minecraft.world.level.levelgen.feature.configurations.OreConfiguration; +import net.minecraft.world.level.levelgen.placement.PlacedFeature; +import net.minecraft.world.level.levelgen.structure.templatesystem.BlockMatchTest; +import zone.moddev.mc.orespawn.test.Forge36TestBootstrap; + +class VanillaOreFeatureGateTest { + @BeforeAll + static void bootstrap() { Forge36TestBootstrap.registerVanilla(); } + + @Test + void inlineQuartzKeepsItsListPositionAndSharedIdentity() { + ConfiguredFeature quartz = new ConfiguredFeature<>(Feature.ORE, + new OreConfiguration(new BlockMatchTest(Blocks.NETHERRACK), + Blocks.NETHER_QUARTZ_ORE.defaultBlockState(), 14)); + PlacedFeature original = new PlacedFeature(Holder.direct(quartz), List.of()); + List> first = new ArrayList<>(List.of(Holder.direct(original))); + List> second = new ArrayList<>(List.of(Holder.direct(original))); + assertTrue(VanillaOreFeatureGate.wrapFeatureList(first)); + assertTrue(VanillaOreFeatureGate.wrapFeatureList(second)); + assertNotSame(original, first.get(0).value()); + assertSame(first.get(0).value(), second.get(0).value()); + assertSame(original.placement(), first.get(0).value().placement()); + assertFalse(VanillaOreFeatureGate.wrapFeatureList(first)); + VanillaOreFeatureGate.clearInlineGates(); + List> nextWorld = new ArrayList<>(List.of(Holder.direct(original))); + VanillaOreFeatureGate.wrapFeatureList(nextWorld); + assertNotSame(first.get(0).value(), nextWorld.get(0).value()); + } + + @Test + void nativeFamiliesRejectMixedUnknownAndEmptyOutputs() { + assertSame(Blocks.COPPER_ORE, VanillaOreFeatureGate.nativeOutput( + ore(Blocks.COPPER_ORE, Blocks.DEEPSLATE_COPPER_ORE))); + assertSame(Blocks.COPPER_ORE, VanillaOreFeatureGate.nativeOutput(ore(Blocks.DEEPSLATE_COPPER_ORE))); + assertSame(Blocks.EMERALD_ORE, VanillaOreFeatureGate.nativeOutput( + ore(Blocks.EMERALD_ORE, Blocks.DEEPSLATE_EMERALD_ORE))); + assertNull(VanillaOreFeatureGate.nativeOutput(ore(Blocks.COPPER_ORE, Blocks.DIAMOND_ORE))); + assertNull(VanillaOreFeatureGate.nativeOutput(ore(Blocks.COPPER_ORE, Blocks.BLUE_WOOL))); + assertNull(VanillaOreFeatureGate.nativeOutput(ore(Blocks.BLUE_WOOL))); + assertNull(VanillaOreFeatureGate.nativeOutput(ore())); + ConfiguredFeature debris = new ConfiguredFeature<>(Feature.SCATTERED_ORE, + ore(Blocks.ANCIENT_DEBRIS).config()); + assertSame(Blocks.ANCIENT_DEBRIS, VanillaOreFeatureGate.nativeOutput(debris)); + } + + @Test + void realBiomeSourceSorterKeepsTheSameIndicesAcrossBiomes() throws Exception { + Holder plains = BuiltinRegistries.BIOME.getHolderOrThrow(Biomes.PLAINS); + Holder forest = BuiltinRegistries.BIOME.getHolderOrThrow(Biomes.FOREST); + BiomeGenerationSettings oldPlains = plains.value().getGenerationSettings(); + BiomeGenerationSettings oldForest = forest.value().getGenerationSettings(); + List> first = new ArrayList<>(List.of( + placed(Blocks.BLUE_WOOL), placed(Blocks.NETHER_QUARTZ_ORE), placed(Blocks.GREEN_WOOL))); + List> second = new ArrayList<>(first.subList(1, 3)); + java.lang.reflect.Method sorter = BiomeSource.class.getDeclaredMethod( + "buildFeaturesPerStep", List.class, boolean.class); + sorter.setAccessible(true); + try { + setFeatures(plains.value(), first); + setFeatures(forest.value(), second); + List before = sort(sorter, plains, List.of(plains, forest)); + assertTrue(VanillaOreFeatureGate.wrapFeatureList(first)); + assertTrue(VanillaOreFeatureGate.wrapFeatureList(second)); + setFeatures(plains.value(), first); + setFeatures(forest.value(), second); + List after = sort(sorter, plains, List.of(plains, forest)); + assertEquals(before.get(0).features().size(), after.get(0).features().size()); + for (int i = 0; i < first.size(); i++) { + assertEquals(i, after.get(0).indexMapping().applyAsInt(first.get(i).value())); + } + assertSame(first.get(1).value(), second.get(0).value()); + } finally { + plains.value().generationSettings = oldPlains; + forest.value().generationSettings = oldForest; + VanillaOreFeatureGate.clearInlineGates(); + } + } + + @SuppressWarnings("unchecked") + private static List sort(java.lang.reflect.Method sorter, + Holder biome, List> biomes) throws Exception { + return (List) sorter.invoke(new FixedBiomeSource(biome), biomes, true); + } + + private static void setFeatures(Biome biome, List> features) { + BiomeGenerationSettings.Builder builder = new BiomeGenerationSettings.Builder(); + features.forEach(feature -> builder.addFeature(0, feature)); + biome.generationSettings = builder.build(); + } + + private static Holder placed(Block block) { + return Holder.direct(new PlacedFeature(Holder.direct(ore(block)), List.of())); + } + + private static ConfiguredFeature ore(Block... outputs) { + List targets = new ArrayList<>(); + for (Block output : outputs) targets.add(OreConfiguration.target( + new BlockMatchTest(Blocks.NETHERRACK), output.defaultBlockState())); + return new ConfiguredFeature<>(Feature.ORE, new OreConfiguration(targets, 14)); + } +} diff --git a/src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java b/src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java new file mode 100644 index 00000000..79833f69 --- /dev/null +++ b/src/worldHashTest/java/zone/moddev/mc/orespawn/testmod/WorldHashProbeTestMod.java @@ -0,0 +1,232 @@ +package zone.moddev.mc.orespawn.testmod; + +import java.io.IOException; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Properties; +import java.util.HashMap; +import java.util.Map; + +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.resources.ResourceLocation; +import net.minecraft.core.Registry; +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.storage.LevelResource; +import net.minecraftforge.common.MinecraftForge; +import net.minecraftforge.eventbus.api.EventPriority; +import net.minecraftforge.fml.common.Mod; +import net.minecraftforge.event.server.ServerStartedEvent; + +/** Test-only checksum of completed chunks; it works with both 4.0.16 and 4.1. */ +@Mod("worldhashprobe") +public final class WorldHashProbeTestMod { + public WorldHashProbeTestMod() { + MinecraftForge.EVENT_BUS.addListener(EventPriority.LOWEST, this::audit); + } + + private void audit(ServerStartedEvent event) { + String overridePhase = System.getProperty("orespawn.biomeOverridePhase", ""); + if (!overridePhase.isEmpty()) { + auditBiomeOverride(event, overridePhase); + return; + } + ServerLevel world = event.getServer().getLevel(Level.OVERWORLD); + if (world == null) throw new IllegalStateException("Overworld is unavailable"); + // Adjacent veins can write into the outer benchmark row. Finish the + // surrounding chunks before comparing the completed 81-chunk square. + for (int x = 251; x <= 261; x++) { + world.getChunk(x, 251); + world.getChunk(x, 261); + } + for (int z = 252; z <= 260; z++) { + world.getChunk(251, z); + world.getChunk(261, z); + } + MessageDigest blocks = digest(); + MessageDigest biomes = digest(); + MessageDigest managedBlocks = digest(); + MessageDigest managedOres = digest(); + int minY = world.getMinBuildHeight(); + int maxY = world.getMaxBuildHeight(); + MessageDigest[] levels = new MessageDigest[maxY - minY]; + for (int y = 0; y < levels.length; y++) levels[y] = digest(); + Properties marker = new Properties(); + Map blockCounts = new HashMap<>(); + Map managed = new HashMap<>(); + Map ores = new HashMap<>(); + long managedCount = 0; + long oreCount = 0; + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + int chunks = 0; + for (int chunkZ = 252; chunkZ <= 260; chunkZ++) { + for (int chunkX = 252; chunkX <= 260; chunkX++) { + if (!world.hasChunk(chunkX, chunkZ)) { + throw new IllegalStateException("Benchmark did not finish chunk " + chunkX + "," + chunkZ); + } + LevelChunk chunk = world.getChunk(chunkX, chunkZ); + MessageDigest chunkBlocks = digest(); + chunks++; + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + int absoluteX = (chunkX << 4) + x; + int absoluteZ = (chunkZ << 4) + z; + ResourceLocation biome = world.getBiome(cursor.set(absoluteX, 64, absoluteZ)) + .unwrapKey().orElseThrow().location(); + if (biome == null) throw new IllegalStateException("Unregistered biome in benchmark"); + biomes.update(biome.toString().getBytes(java.nio.charset.StandardCharsets.UTF_8)); + biomes.update((byte) 0); + for (int y = minY; y < maxY; y++) { + cursor.set(absoluteX, y, absoluteZ); + BlockState blockState = chunk.getBlockState(cursor); + blockCounts.merge(blockState.getBlock(), 1L, Long::sum); + int state = Block.getId(blockState); + if (managed.computeIfAbsent(blockState.getBlock(), block -> { + ResourceLocation id = Registry.BLOCK.getKey(block); + return id != null && ("mineralogy".equals(id.getNamespace()) + || "orespawn".equals(id.getNamespace())); + })) { + updateInt(managedBlocks, absoluteX); + updateInt(managedBlocks, y); + updateInt(managedBlocks, absoluteZ); + updateInt(managedBlocks, state); + managedCount++; + } + if (ores.computeIfAbsent(blockState.getBlock(), block -> { + ResourceLocation id = Registry.BLOCK.getKey(block); + return id != null && "mineralogy".equals(id.getNamespace()) + && id.getPath().endsWith("_ore"); + })) { + updateInt(managedOres, absoluteX); + updateInt(managedOres, y); + updateInt(managedOres, absoluteZ); + updateInt(managedOres, state); + oreCount++; + } + updateInt(blocks, state); + updateInt(chunkBlocks, state); + updateInt(levels[y - minY], state); + } + } + } + marker.setProperty("chunk." + chunkX + "." + chunkZ, hex(chunkBlocks.digest())); + } + } + for (int y = 0; y < levels.length; y++) + marker.setProperty("level." + (y + minY), hex(levels[y].digest())); + marker.setProperty("chunks", Integer.toString(chunks)); + marker.setProperty("blocks_sha256", hex(blocks.digest())); + marker.setProperty("biomes_sha256", hex(biomes.digest())); + marker.setProperty("managed_blocks_sha256", hex(managedBlocks.digest())); + marker.setProperty("managed_block_count", Long.toString(managedCount)); + marker.setProperty("managed_ores_sha256", hex(managedOres.digest())); + marker.setProperty("managed_ore_count", Long.toString(oreCount)); + for (Map.Entry entry : blockCounts.entrySet()) { + ResourceLocation id = Registry.BLOCK.getKey(entry.getKey()); + if (id != null) marker.setProperty("count." + id, Long.toString(entry.getValue())); + } + Path worldRoot = event.getServer().getWorldPath(LevelResource.ROOT).normalize(); + try (OutputStream output = Files.newOutputStream(worldRoot.resolve("worldhashprobe.properties"))) { + marker.store(output, "Completed benchmark chunks"); + } catch (IOException failure) { + throw new IllegalStateException("Could not save benchmark hashes", failure); + } + } + + private static void auditBiomeOverride(ServerStartedEvent event, String phase) { + ServerLevel world = event.getServer().getLevel(Level.OVERWORLD); + if (world == null) throw new IllegalStateException("Overworld is unavailable"); + Path markerPath = event.getServer().getWorldPath(LevelResource.ROOT) + .resolve("biomeoverrideprobe.properties"); + BlockPos oldPos = new BlockPos((96 << 4) + 8, 64, (96 << 4) + 8); + BlockPos newPos = new BlockPos((160 << 4) + 8, 64, (160 << 4) + 8); + Properties marker = new Properties(); + if ("fresh".equals(phase)) { + if (Files.exists(markerPath) || world.hasChunk(160, 160)) { + throw new IllegalStateException("Biome override fresh world is not empty"); + } + String source = biomeId(world, newPos); + if (world.hasChunk(160, 160)) { + throw new IllegalStateException("Sampling the future biome generated its chunk"); + } + LevelChunk oldChunk = world.getChunk(96, 96); + marker.setProperty("source", source); + marker.setProperty("old_biome", biomeId(world, oldPos)); + marker.setProperty("old_blocks", chunkHash(world, oldChunk)); + } else if ("reload".equals(phase)) { + try (java.io.InputStream input = Files.newInputStream(markerPath)) { + marker.load(input); + } catch (IOException failure) { + throw new IllegalStateException("Could not read biome override marker", failure); + } + LevelChunk oldChunk = world.getChunk(96, 96); + if (!marker.getProperty("old_blocks").equals(chunkHash(world, oldChunk)) + || !marker.getProperty("old_biome").equals(biomeId(world, oldPos))) { + throw new IllegalStateException("An existing chunk changed after the biome override"); + } + if (world.hasChunk(160, 160)) { + throw new IllegalStateException("The new-terrain chunk already existed"); + } + world.getChunk(160, 160); + String actual = biomeId(world, newPos); + if (!marker.getProperty("target").equals(actual)) { + throw new IllegalStateException("New terrain used " + actual + " rather than " + + marker.getProperty("target")); + } + marker.setProperty("reload_verified", "true"); + } else { + throw new IllegalArgumentException("Unknown biome override phase: " + phase); + } + try (OutputStream output = Files.newOutputStream(markerPath)) { + marker.store(output, "Exact biome replacement and no-retrogen proof"); + } catch (IOException failure) { + throw new IllegalStateException("Could not save biome override marker", failure); + } + event.getServer().halt(false); + } + + private static String biomeId(ServerLevel world, BlockPos pos) { + return world.getBiome(pos).unwrapKey().orElseThrow().location().toString(); + } + + private static String chunkHash(ServerLevel world, LevelChunk chunk) { + MessageDigest digest = digest(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + for (int y = world.getMinBuildHeight(); y < world.getMaxBuildHeight(); y++) { + for (int z = 0; z < 16; z++) { + for (int x = 0; x < 16; x++) { + cursor.set((96 << 4) + x, y, (96 << 4) + z); + updateInt(digest, Block.getId(chunk.getBlockState(cursor))); + } + } + } + return hex(digest.digest()); + } + + private static MessageDigest digest() { + try { return MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException failure) { throw new IllegalStateException(failure); } + } + + private static void updateInt(MessageDigest digest, int value) { + digest.update((byte) (value >>> 24)); + digest.update((byte) (value >>> 16)); + digest.update((byte) (value >>> 8)); + digest.update((byte) value); + } + + private static String hex(byte[] bytes) { + StringBuilder result = new StringBuilder(bytes.length * 2); + for (byte value : bytes) { + result.append(Character.forDigit((value >>> 4) & 15, 16)); + result.append(Character.forDigit(value & 15, 16)); + } + return result.toString(); + } +} diff --git a/src/worldHashTest/resources/META-INF/mods.toml b/src/worldHashTest/resources/META-INF/mods.toml new file mode 100644 index 00000000..86e2def3 --- /dev/null +++ b/src/worldHashTest/resources/META-INF/mods.toml @@ -0,0 +1,9 @@ +modLoader="javafml" +loaderVersion="[40,)" +license="MIT" + +[[mods]] +modId="worldhashprobe" +version="1" +displayName="OreSpawn World Hash Test" +description="Test-only checksum of completed benchmark chunks." diff --git a/src/worldHashTest/resources/pack.mcmeta b/src/worldHashTest/resources/pack.mcmeta new file mode 100644 index 00000000..44acedf2 --- /dev/null +++ b/src/worldHashTest/resources/pack.mcmeta @@ -0,0 +1 @@ +{"pack":{"pack_format":9,"description":"OreSpawn world hash test fixture"}}